diff --git a/.ai/lessons.md b/.ai/lessons.md deleted file mode 100644 index 0c08f5d495..0000000000 --- a/.ai/lessons.md +++ /dev/null @@ -1,7 +0,0 @@ -# Lessons - -## Alpine x-transition + tw-animate-css exit animations flash at the end -- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears. -- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity. -- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`. -- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one. diff --git a/.env.development.example b/.env.development.example index 380f10a446..56c17128ce 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,3 +53,4 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= +AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index b067b4c5c0..626d76ff63 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,3 +11,4 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify +AVATAR_CDN_URL= diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index f5d0c3f0ad..de0cf3b8d7 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -1,6 +1,7 @@ name: Coolify Helper Image on: + workflow_dispatch: push: branches: [ "main" ] paths: diff --git a/AGENTS.md b/AGENTS.md index 5563a18ec1..f9f8ca563a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,9 +99,30 @@ function loginAsRoot(): mixed ``` - See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. -- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`). - Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. +### How Browser Tests Actually Run (no Docker, no display needed) + +`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin: + +1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`). +2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself. + +Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres. + +`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements). + +### Browser Test Gotchas + +- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`. +- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache. +- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`. +- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up. +- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds. +- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions. +- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible. +- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner. + ## Architecture ### Backend Structure (app/) @@ -127,6 +148,11 @@ function loginAsRoot(): mixed - Custom gates: `createAnyResource`, `canAccessTerminal` - Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods - Multi-tenancy via Teams — team auto-initializes notification settings on creation +- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. +- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. +- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. +- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. ### Event Broadcasting - Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) @@ -170,6 +196,23 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin - Exception handler: `app/Exceptions/Handler.php` - Service providers in `app/Providers/` +## Livewire conventions + +### Dynamic lists and snapshot errors + +When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it. + +- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key. +- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead. +- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM. +- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible. +- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one. +- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty. +- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race. +- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console. + +The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`. + ## Key Conventions - Use `php artisan make:*` commands with `--no-interaction` to create files diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73b048f4b6..626f6d7b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor ## Local Development To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) +### Testing the Coolify Helper Locally + +Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications. + +Run the complete workflow: + +```bash +./scripts/dev-helper test my-helper-test +``` + +This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications. + +You can also run each step separately: + +```bash +./scripts/dev-helper build my-helper-test +./scripts/dev-helper use my-helper-test +./scripts/dev-helper verify my-helper-test +./scripts/dev-helper deploy my-helper-test +``` + +Clear the helper override when finished: + +```bash +./scripts/dev-helper reset +``` + +The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name. + ### macOS Development with Lima Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 66ceb95f64..3feb5117d8 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -13,8 +13,9 @@ class StopApplication public string $jobQueue = 'high'; - public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string { + $containerPresent = ! $removeContainers; $servers = collect([$application->destination->server]); if ($application?->additional_servers?->count() > 0) { $servers = $servers->merge($application->additional_servers); @@ -26,6 +27,7 @@ class StopApplication } if ($server->isSwarm()) { + $containerPresent = false; instant_remote_process(["docker stack rm {$application->uuid}"], $server); continue; @@ -39,13 +41,17 @@ class StopApplication $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainers) { + $commands[] = "docker rm -f $containerName"; + } else { + array_unshift($commands, "docker update --restart=no $containerName"); + } + + instant_remote_process(command: $commands, server: $server, throwError: false); } - if ($application->build_pack === 'dockercompose') { + if ($removeContainers && $application->build_pack === 'dockercompose') { $application->deleteConnectedNetworks(); } @@ -57,12 +63,16 @@ class StopApplication } } - $status = ['status' => 'exited']; + $status = [ + 'status' => 'exited', + 'container_present' => $containerPresent, + ]; if ($resetRestartCount) { $status = array_merge($status, [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } $application->update($status); diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 10f5b85f21..b25eb481b6 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -29,7 +29,7 @@ class StopApplicationOneServer instant_remote_process( [ dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", + dockerRemoveCommand($containerName), ], $server ); diff --git a/app/Actions/Application/StopApplicationPreview.php b/app/Actions/Application/StopApplicationPreview.php new file mode 100644 index 0000000000..af5f3fc0f0 --- /dev/null +++ b/app/Actions/Application/StopApplicationPreview.php @@ -0,0 +1,36 @@ +application; + $server = $application->destination->server; + $containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id); + + foreach ($containers->pluck('Names') as $containerName) { + $commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } else { + array_unshift($commands, "docker update --restart=no $containerName"); + } + instant_remote_process($commands, $server, false); + } + + $preview->update(['status' => 'exited']); + if ($resetRestartCount) { + $preview->resetRestartLimit(); + } + + ServiceStatusChanged::dispatch($application->environment->project->team->id); + } +} diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index b256eb2255..f9e92e08f1 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -3,12 +3,14 @@ namespace App\Actions\Database; use App\Models\StandaloneClickhouse; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartClickhouse { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneClickhouse $database; @@ -16,7 +18,11 @@ class StartClickhouse public string $configuration_dir; - public function handle(StandaloneClickhouse $database) + private string $resolvedClickhouseUser; + + private string $resolvedClickhousePassword; + + public function handle(StandaloneClickhouse $database, ?Activity $activity = null) { $this->database = $database; @@ -51,7 +57,7 @@ class StartClickhouse ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1', + 'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -109,7 +115,7 @@ class StartClickhouse $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -147,8 +153,17 @@ class StartClickhouse private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user; + $this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'CLICKHOUSE_USER') { + $this->resolvedClickhouseUser = $rawValue; + } elseif ($env->key === 'CLICKHOUSE_PASSWORD') { + $this->resolvedClickhousePassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php index 4b55b0c1df..c7fbff37b5 100644 --- a/app/Actions/Database/StartDatabase.php +++ b/app/Actions/Database/StartDatabase.php @@ -2,6 +2,9 @@ namespace App\Actions\Database; +use App\Enums\ActivityTypes; +use App\Enums\ProcessStatus; +use App\Jobs\DatabaseStartJob; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; +use Spatie\Activitylog\Models\Activity; class StartDatabase { @@ -22,38 +26,40 @@ class StartDatabase $job->onQueue(deployment_queue()); } - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - switch ($database->getMorphClass()) { - case StandalonePostgresql::class: - $activity = StartPostgresql::run($database); - break; - case StandaloneRedis::class: - $activity = StartRedis::run($database); - break; - case StandaloneMongodb::class: - $activity = StartMongodb::run($database); - break; - case StandaloneMysql::class: - $activity = StartMysql::run($database); - break; - case StandaloneMariadb::class: - $activity = StartMariadb::run($database); - break; - case StandaloneKeydb::class: - $activity = StartKeydb::run($database); - break; - case StandaloneDragonfly::class: - $activity = StartDragonfly::run($database); - break; - case StandaloneClickhouse::class: - $activity = StartClickhouse::run($database); - break; + $database->resetRestartLimit(); + + $activity = activity() + ->withProperties([ + 'server_uuid' => $server->uuid, + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $database->uuid, + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $server->team_id, + 'operation' => 'database-start', + ]) + ->performedOn($database) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); + + if ($activity === null) { + return 'Database start could not be queued because activity logging is disabled.'; + } + + DatabaseStartJob::dispatch( + $database->getMorphClass(), + (int) $database->getKey(), + (int) $database->team()->id, + (int) $activity->getKey(), + auth()->id(), + ); + if ($database->is_public && $database->public_port) { StartDatabaseProxy::dispatch($database); } diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index ddd930f278..078d557f57 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartDragonfly { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneDragonfly $database; @@ -20,7 +22,9 @@ class StartDragonfly private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneDragonfly $database) + private string $resolvedRedisPassword; + + public function handle(StandaloneDragonfly $database, ?Activity $activity = null) { $this->database = $database; @@ -107,7 +111,7 @@ class StartDragonfly ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping', + 'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -196,12 +200,13 @@ class StartDragonfly $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function buildStartCommand(): string { - $command = "dragonfly --requirepass {$this->database->dragonfly_password}"; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); + $command = "dragonfly --requirepass {$escapedRedisPassword}"; if ($this->database->enable_ssl) { $sslArgs = [ @@ -251,8 +256,14 @@ class StartDragonfly private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedRedisPassword = (string) $this->database->dragonfly_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index cc017e3514..3b9cba28f4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneKeydb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartKeydb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneKeydb $database; @@ -20,7 +22,9 @@ class StartKeydb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneKeydb $database) + private string $resolvedRedisPassword; + + public function handle(StandaloneKeydb $database, ?Activity $activity = null) { $this->database = $database; @@ -109,7 +113,7 @@ class StartKeydb ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping', + 'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -214,7 +218,7 @@ class StartKeydb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -252,8 +256,14 @@ class StartKeydb private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedRedisPassword = (string) $this->database->keydb_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { @@ -280,6 +290,7 @@ class StartKeydb { $hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf); $keydbConfPath = '/etc/keydb/keydb.conf'; + $escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword); if ($hasKeydbConf) { $confContent = $this->database->keydb_conf; @@ -288,10 +299,10 @@ class StartKeydb if ($hasRequirePass) { $command = "keydb-server $keydbConfPath"; } else { - $command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}"; + $command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}"; } } else { - $command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; + $command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes"; } if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2f030ae299..a05da25efd 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMariadb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMariadb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMariadb $database; @@ -20,7 +22,7 @@ class StartMariadb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMariadb $database) + public function handle(StandaloneMariadb $database, ?Activity $activity = null) { $this->database = $database; @@ -216,7 +218,7 @@ class StartMariadb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -255,7 +257,7 @@ class StartMariadb { $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 097e19f7b2..ff338aa99f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMongodb; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMongodb { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMongodb $database; @@ -20,7 +22,13 @@ class StartMongodb private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMongodb $database) + private string $resolvedMongoUsername; + + private string $resolvedMongoPassword; + + private string $resolvedMongoDatabase; + + public function handle(StandaloneMongodb $database, ?Activity $activity = null) { $this->database = $database; @@ -265,7 +273,7 @@ class StartMongodb $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -303,8 +311,20 @@ class StartMongodb private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username; + $this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password; + $this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') { + $this->resolvedMongoUsername = $rawValue; + } elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') { + $this->resolvedMongoPassword = $rawValue; + } elseif ($env->key === 'MONGO_INITDB_DATABASE') { + $this->resolvedMongoDatabase = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { @@ -337,9 +357,9 @@ class StartMongodb private function add_default_database() { - $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES); - $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES); - $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES); + $dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES); + $userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES); + $pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES); $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});"; $content_base64 = base64_encode($content); $this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index d21ee02fb1..cff8d0b363 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneMysql; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartMysql { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneMysql $database; @@ -20,7 +22,9 @@ class StartMysql private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneMysql $database) + private string $resolvedMysqlRootPassword; + + public function handle(StandaloneMysql $database, ?Activity $activity = null) { $this->database = $database; @@ -104,7 +108,7 @@ class StartMysql ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}", + 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}", ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -218,7 +222,7 @@ class StartMysql $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -256,8 +260,14 @@ class StartMysql private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'MYSQL_ROOT_PASSWORD') { + $this->resolvedMysqlRootPassword = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index f70e8f3cfd..f9dd7a3c4f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandalonePostgresql; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartPostgresql { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandalonePostgresql $database; @@ -22,7 +24,11 @@ class StartPostgresql private ?SslCertificate $ssl_certificate = null; - public function handle(StandalonePostgresql $database) + private string $resolvedPostgresUser; + + private string $resolvedPostgresDatabase; + + public function handle(StandalonePostgresql $database, ?Activity $activity = null) { $this->database = $database; $container_name = $this->database->uuid; @@ -111,7 +117,7 @@ class StartPostgresql ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ - 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1', + 'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1', ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -227,7 +233,7 @@ class StartPostgresql $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -265,8 +271,17 @@ class StartPostgresql private function generate_environment_variables() { $environment_variables = collect(); + $this->resolvedPostgresUser = (string) $this->database->postgres_user; + $this->resolvedPostgresDatabase = (string) $this->database->postgres_db; foreach ($this->database->runtime_environment_variables as $env) { - $environment_variables->push("$env->key=$env->real_value"); + $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + $environment_variables->push($env->key.'='.$resolvedValue); + if ($env->key === 'POSTGRES_USER') { + $this->resolvedPostgresUser = $rawValue; + } elseif ($env->key === 'POSTGRES_DB') { + $this->resolvedPostgresDatabase = $rawValue; + } } if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 8d65453f70..41ece532b1 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -5,12 +5,14 @@ namespace App\Actions\Database; use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; class StartRedis { - use AsAction; + use AsAction, ExecutesDatabaseStartCommands; public StandaloneRedis $database; @@ -20,7 +22,11 @@ class StartRedis private ?SslCertificate $ssl_certificate = null; - public function handle(StandaloneRedis $database) + private ?string $resolvedRedisPassword = null; + + private ?string $resolvedRedisUsername = null; + + public function handle(StandaloneRedis $database, ?Activity $activity = null) { $this->database = $database; @@ -209,7 +215,7 @@ class StartRedis $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; - return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + return $this->executeDatabaseStartCommands($this->commands, $database, $activity); } private function generate_local_persistent_volumes() @@ -249,23 +255,40 @@ class StartRedis $environment_variables = collect(); foreach ($this->database->runtime_environment_variables as $env) { + $usesSecretManager = $this->database->environmentVariableUsesSecretManager($env); + if ($env->is_shared) { - $environment_variables->push("$env->key=$env->real_value"); + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); if ($env->key === 'REDIS_PASSWORD') { - $this->database->update(['redis_password' => $env->real_value]); + $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + + if (! $usesSecretManager) { + $this->database->update(['redis_password' => $this->resolvedRedisPassword]); + } } if ($env->key === 'REDIS_USERNAME') { - $this->database->update(['redis_username' => $env->real_value]); + $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + + if (! $usesSecretManager) { + $this->database->update(['redis_username' => $this->resolvedRedisUsername]); + } } } else { - if ($env->key === 'REDIS_PASSWORD') { + if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) { $env->update(['value' => $this->database->redis_password]); - } elseif ($env->key === 'REDIS_USERNAME') { + } elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) { $env->update(['value' => $this->database->redis_username]); } - $environment_variables->push("$env->key=$env->real_value"); + + if ($env->key === 'REDIS_PASSWORD') { + $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + } elseif ($env->key === 'REDIS_USERNAME') { + $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + } + + $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } } @@ -276,6 +299,7 @@ class StartRedis private function buildStartCommand(): string { + $redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password; $hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf); $redisConfPath = '/usr/local/etc/redis/redis.conf'; @@ -286,10 +310,10 @@ class StartRedis if ($hasRequirePass) { $command = "redis-server $redisConfPath"; } else { - $command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}"; + $command = "redis-server $redisConfPath --requirepass {$redisPassword}"; } } else { - $command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; + $command = "redis-server --requirepass {$redisPassword} --appendonly yes"; } if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index a3a7f16ef0..f3c591acfc 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -4,6 +4,7 @@ namespace App\Actions\Database; use App\Actions\Server\CleanupDocker; use App\Events\ServiceStatusChanged; +use App\Models\BaseModel; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -18,7 +19,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string { try { $server = $database->destination->server; @@ -26,15 +27,13 @@ class StopDatabase return 'Server is not functional'; } - $this->stopContainer($database, $database->uuid, 30); + $this->stopContainer($database, $database->uuid, 30, $removeContainer); // Reset restart tracking when database is manually stopped - $database->update([ - 'status' => 'exited', - 'restart_count' => 0, - 'last_restart_at' => null, - 'last_restart_type' => null, - ]); + $database->update(['status' => 'exited']); + if ($resetRestartCount) { + $database->resetRestartLimit(); + } if ($dockerCleanup) { CleanupDocker::dispatch($server, false, false); @@ -53,12 +52,15 @@ class StopDatabase } - private function stopContainer($database, string $containerName, int $timeout = 30): void + private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void { $server = $database->destination->server; - instant_remote_process(command: [ - dockerStopCommand($timeout, $containerName, $server), - "docker rm -f $containerName", - ], server: $server, throwError: false); + $commands = [dockerStopCommand($timeout, $containerName, $server)]; + if ($removeContainer) { + $commands[] = "docker rm -f $containerName"; + } else { + array_unshift($commands, "docker update --restart=no $containerName"); + } + instant_remote_process(command: $commands, server: $server, throwError: false); } } diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a1097662..e6789202f3 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -24,10 +24,10 @@ class StopDatabaseProxy { $server = data_get($database, 'destination.server'); $uuid = $database->uuid; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $server = data_get($database, 'service.server'); } - instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); + instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server); $database->save(); diff --git a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php index 21c40a50ad..3e1b5380b6 100644 --- a/app/Actions/Destination/RemoveStandaloneDockerNetwork.php +++ b/app/Actions/Destination/RemoveStandaloneDockerNetwork.php @@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork $safeNetwork = escapeshellarg($destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); + instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server); } } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 904885dfc5..be098e481c 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -3,15 +3,20 @@ namespace App\Actions\Docker; use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; +use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Events\ServiceChecked; +use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Server; use App\Models\ServiceDatabase; use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Support\Arr; use Illuminate\Support\Collection; @@ -37,8 +42,12 @@ class GetContainersStatus protected ?Collection $applicationContainerRestartCounts; + protected ?Collection $previewContainerRestartCounts; + protected ?Collection $serviceContainerStatuses; + protected ?Collection $serviceContainerRestartCounts; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -117,6 +126,9 @@ class GetContainersStatus $containerStatus = "$containerStatus:$healthSuffix"; } $labels = Arr::undot(format_docker_labels_to_json($labels)); + if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { $pullRequestId = data_get($labels, 'coolify.pullRequestId'); @@ -133,6 +145,12 @@ class GetContainersStatus } else { $preview->update(['last_online_at' => now()]); } + $key = $applicationId.':'.$pullRequestId; + $this->previewContainerRestartCounts ??= collect(); + $this->previewContainerRestartCounts->push([ + 'key' => $key, + 'count' => (int) data_get($container, 'RestartCount', 0), + ]); } else { // Notify user that this container should not be there. } @@ -140,6 +158,9 @@ class GetContainersStatus $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; + if ($application->container_present !== true) { + $application->update(['container_present' => true]); + } // Store container status for aggregation if (! isset($this->applicationContainerStatuses)) { $this->applicationContainerStatuses = collect(); @@ -220,23 +241,19 @@ class GetContainersStatus // Track restart count for databases (single-container) $restartCount = data_get($container, 'RestartCount', 0); - $previousRestartCount = $database->restart_count ?? 0; - if ($statusFromDb !== $containerStatus) { $updateData = ['status' => $containerStatus]; } else { $updateData = ['last_online_at' => now()]; } - // Update restart tracking if restart count increased - if ($restartCount > $previousRestartCount) { - $updateData['restart_count'] = $restartCount; - $updateData['last_restart_at'] = now(); - $updateData['last_restart_type'] = 'crash'; - } - $database->update($updateData); + if ($database->trackRestartCount((int) $restartCount)) { + StopDatabase::dispatch($database, false, false, false); + $database->team()?->notify(new ApplicationRestartLimitReached($database)); + } + if ($isPublic) { $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { if ($this->server->isSwarm()) { @@ -292,6 +309,11 @@ class GetContainersStatus $containerName = data_get($labels, 'com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->serviceContainerRestartCounts ??= collect(); + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0)); } // Mark service as found @@ -335,46 +357,35 @@ class GetContainersStatus continue; } - $name = data_get($exitedService, 'name'); - $fqdn = data_get($exitedService, 'fqdn'); - if ($name) { - if ($fqdn) { - $containerName = "$name, available at $fqdn"; - } else { - $containerName = $name; - } - } else { - if ($fqdn) { - $containerName = $fqdn; - } else { - $containerName = null; - } + if (! $exitedService->stoppedAfterRestartLimit()) { + $exitedService->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); } - $projectUuid = data_get($service, 'environment.project.uuid'); - $serviceUuid = data_get($service, 'uuid'); - $environmentName = data_get($service, 'environment.name'); - - if ($projectUuid && $serviceUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); - $exitedService->update(['status' => 'exited']); } $notRunningApplications = $this->applications->pluck('id')->diff($foundApplications); foreach ($notRunningApplications as $applicationId) { $application = $this->applications->where('id', $applicationId)->first(); - if (str($application->status)->startsWith('exited')) { - continue; - } // Only protection: If no containers at all, Docker query might have failed if ($this->containers->isEmpty()) { continue; } + if (str($application->status)->startsWith('exited')) { + $application->update([ + 'container_present' => false, + 'restart_limit_reached' => false, + ]); + + continue; + } + // If container was recently restarting (crash loop), keep it as degraded for a grace period // This prevents false "exited" status during the brief moment between container removal and recreation $recentlyRestarted = $application->restart_count > 0 && @@ -388,9 +399,11 @@ class GetContainersStatus // Reset restart count when application exits completely $application->update([ 'status' => 'exited', + 'container_present' => false, 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } } @@ -411,6 +424,9 @@ class GetContainersStatus $notRunningDatabases = $databases->pluck('id')->diff($foundDatabases); foreach ($notRunningDatabases as $database) { $database = $databases->where('id', $database)->first(); + if ($database->stoppedAfterRestartLimit()) { + continue; + } if (str($database->status)->startsWith('exited')) { continue; } @@ -426,6 +442,7 @@ class GetContainersStatus 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); // Stop proxy if database was public @@ -433,23 +450,10 @@ class GetContainersStatus StopDatabaseProxy::run($database); } - $name = data_get($database, 'name'); - $fqdn = data_get($database, 'fqdn'); - - $containerName = $name; - - $projectUuid = data_get($database, 'environment.project.uuid'); - $environmentName = data_get($database, 'environment.name'); - $databaseUuid = data_get($database, 'uuid'); - - if ($projectUuid && $databaseUuid && $environmentName) { - $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid; - } else { - $url = null; - } - // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + $this->trackPreviewRestartCounts($previews); + // Aggregate multi-container application statuses if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { @@ -470,21 +474,21 @@ class GetContainersStatus DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) { $previousRestartCount = $application->restart_count ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $previousRestartCount, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); - if ($maxRestartCount > $previousRestartCount) { - // Restart count increased - this is a crash restart + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; $application->update([ - 'restart_count' => $maxRestartCount, - 'last_restart_at' => now(), - 'last_restart_type' => 'crash', + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, ]); - - // Check if restart limit has been reached - $maxAllowedRestarts = $application->max_restart_count ?? 0; - if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) { - $restartLimitReached = true; - } } + $restartLimitReached = $restartState['restart_limit_reached']; // Aggregate status after tracking restart counts $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount); @@ -499,9 +503,22 @@ class GetContainersStatus }); if ($restartLimitReached) { - $application->refresh(); - StopApplication::dispatch($application, false, true, false); - $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } } } } @@ -562,6 +579,16 @@ class GetContainersStatus continue; } + $restartCount = isset($this->serviceContainerRestartCounts) + ? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0) + : 0; + if ($subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -602,4 +629,24 @@ class GetContainersStatus } } } + + private function trackPreviewRestartCounts(Collection $previews): void + { + if (! isset($this->previewContainerRestartCounts)) { + return; + } + + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key) use ($previews): void { + [$applicationId, $pullRequestId] = explode(':', $key); + $preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId + && (string) $preview->pull_request_id === $pullRequestId + ); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index d437a3a176..c69863c572 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,9 +2,9 @@ namespace App\Actions\Fortify; +use App\Jobs\SendVerificationEmailJob; use App\Models\Team; use App\Models\User; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; @@ -22,8 +22,6 @@ class CreateNewUser implements CreatesNewUsers private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; - public function __construct(private readonly Request $request) {} - /** * Validate and create a newly registered user. * @@ -77,7 +75,7 @@ class CreateNewUser implements CreatesNewUsers ]); $team = $user->teams()->first(); if (isCloud()) { - $user->sendVerificationEmail(); + SendVerificationEmailJob::dispatch($user); } else { $user->markEmailAsVerified(); } @@ -95,7 +93,7 @@ class CreateNewUser implements CreatesNewUsers { $keys = [ [ - 'key' => 'registration:ip:'.sha1($this->realIp()), + 'key' => 'registration:ip:'.sha1(auth_rate_limit_ip(request())), 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, ], @@ -120,9 +118,4 @@ class CreateNewUser implements CreatesNewUsers RateLimiter::hit($limit['key'], $limit['decay']); } } - - private function realIp(): string - { - return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); - } } diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 9d0f16c293..f7c73523b3 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -79,7 +79,7 @@ class StartSentinel $trafficMount = $server->isTrafficAnalyticsEnabled() ? '-v '.escapeshellarg($server->proxyPath().':'.$server->proxyPath().':ro').' ' : ''; - $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; instant_remote_process([ 'docker rm -f coolify-sentinel || true', diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 463a8ad5bf..13371d1265 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -24,6 +24,8 @@ class StartService } $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); + $service->applications()->get()->each->resetRestartLimit(); + $service->databases()->get()->each->resetRestartLimit(); $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 5e34c8e6a2..341687d0d2 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -49,8 +49,14 @@ class StopService $this->stopContainersInParallel($containersToStop, $server); } - $applications->each->update(['status' => 'exited']); - $dbs->each->update(['status' => 'exited']); + $applications->each(function ($application): void { + $application->update(['status' => 'exited']); + $application->resetRestartLimit(); + }); + $dbs->each(function ($database): void { + $database->update(['status' => 'exited']); + $database->resetRestartLimit(); + }); if ($deleteConnectedNetworks) { $service->deleteConnectedNetworks(); diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index 184dcb4919..fa93a78807 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -13,17 +13,26 @@ class StopServiceApplication public string $jobQueue = 'high'; - public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void + public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void { $service = $serviceApplication->service; $server = $service->destination->server; $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); - instant_remote_process([ - "docker stop {$containerName}", - ], $server); + if ($removeContainer) { + $commands = ["docker rm -f {$containerName}"]; + } else { + $commands = [ + "docker update --restart=no {$containerName}", + "docker stop {$containerName}", + ]; + } + instant_remote_process($commands, $server, throwError: ! $removeContainer); $serviceApplication->update(['status' => 'exited']); + if ($resetRestartCount) { + $serviceApplication->resetRestartLimit(); + } ServiceStatusChanged::dispatch($service->environment->project->team->id); } } diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 123b752c0f..004403975b 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -56,7 +56,7 @@ class UpdateServiceApplicationFromApi } } - $serviceApplication->fqdn = $parsed['normalized']; + $serviceApplication->setEditableUrls($parsed['normalized']); } if (array_key_exists('noindex_domains', $payload)) { diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php new file mode 100644 index 0000000000..d0cea0fb1c --- /dev/null +++ b/app/Actions/Shared/CheckDomainDns.php @@ -0,0 +1,142 @@ + $entries + * @return array + */ + public function handle( + array $entries, + ?Server $server, + ?string $expectedIp, + bool $skipForMultipleServers = false, + int $timeoutSeconds = 5, + ): array { + if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) { + return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp); + } + + if (! $server) { + return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null); + } + + if ($skipForMultipleServers) { + return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp); + } + + $deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000); + $dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers')) + ->explode(',') + ->map(fn ($dnsServer) => trim((string) $dnsServer)) + ->filter() + ->values(); + $results = []; + + foreach ($entries as $key => $url) { + $results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline); + } + + return $results; + } + + /** + * @param array $dnsServers + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array + { + try { + $host = Url::fromString($url)->getHost(); + } catch (\Throwable) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + if (str($host)->contains('sslip.io')) { + return $this->result('ok', 'DNS looks correct.', $expectedIp); + } + + $type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; + + foreach ($dnsServers as $dnsServer) { + $remainingNanoseconds = $deadline - hrtime(true); + if ($remainingNanoseconds < 1_000_000_000) { + return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp); + } + + try { + $query = app()->make(DNSQuery::class, [ + 'server' => $dnsServer, + 'port' => 53, + 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), + ]); + $records = $query->query($host, $type); + + if ($records === false || $query->hasError()) { + continue; + } + + foreach ($records as $record) { + if ($record->getType() !== $type) { + continue; + } + + if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) { + return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp); + } + } + } catch (\Throwable) { + continue; + } + } + + return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp); + } + + private function successMessage(Server $server, ?string $expectedIp): string + { + if ( + filled($expectedIp) + && filled($server->ip) + && $server->ip !== $expectedIp + && filter_var($server->ip, FILTER_VALIDATE_IP) === false + ) { + return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare)."; + } + + return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.'; + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function result(string $status, string $message, ?string $expectedIp): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $entries + * @return array + */ + private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array + { + $result = $this->result($status, $message, $expectedIp); + + return array_fill_keys(array_keys($entries), $result); + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 347ea94193..65f686ba61 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Models\AuditEvent; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -49,6 +50,12 @@ class CleanupDatabase extends Command $activity_log->delete(); } + $count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count(); + echo "Delete $count entries from audit_events.\n"; + if ($this->option('yes')) { + AuditEvent::pruneExpired(); + } + // Cleanup application_deployment_queues table $application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10); $count = $application_deployment_queues->count(); diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 165a3ae219..0874970fb6 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -13,7 +13,6 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; -use App\Models\SslCertificate; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -39,13 +38,14 @@ class CleanupStuckedResources extends Command private function cleanup_stucked_resources() { try { - $teams = Team::all()->filter(function ($team) { - return $team->members()->count() === 0 && $team->servers()->count() === 0; - }); + $teams = Team::query() + ->whereDoesntHave('members') + ->whereDoesntHave('servers') + ->lazyById(); foreach ($teams as $team) { $team->delete(); } - $servers = Server::all()->filter(function ($server) { + $servers = Server::query()->with('team.subscription')->lazyById()->filter(function ($server) { return $server->isFunctional(); }); if (isCloud()) { @@ -60,7 +60,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; } try { - $servers = Server::onlyTrashed()->get(); + $servers = Server::onlyTrashed()->lazyById(); foreach ($servers as $server) { echo "Force deleting stuck server: {$server->name}\n"; $server->forceDelete(); @@ -69,7 +69,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck servers: {$e->getMessage()}\n"; } try { - $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); + $applicationsDeploymentQueue = ApplicationDeploymentQueue::query()->lazyById(); foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { if (is_null($applicationDeploymentQueue->application)) { echo "Deleting stuck application deployment queue: {$applicationDeploymentQueue->id}\n"; @@ -80,7 +80,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application deployment queue: {$e->getMessage()}\n"; } try { - $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); + $applications = Application::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; DeleteResourceJob::dispatch($application); @@ -89,18 +89,18 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::get(); + $applicationsPreviews = ApplicationPreview::query() + ->whereDoesntHave('application') + ->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { - if (! data_get($applicationPreview, 'application')) { - echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - DeleteResourceJob::dispatch($applicationPreview); - } + echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; + DeleteResourceJob::dispatch($applicationPreview); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; DeleteResourceJob::dispatch($applicationPreview); @@ -109,7 +109,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); + $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; DeleteResourceJob::dispatch($postgresql); @@ -118,7 +118,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; DeleteResourceJob::dispatch($redis); @@ -127,7 +127,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; } try { - $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); + $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; DeleteResourceJob::dispatch($keydb); @@ -136,7 +136,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; } try { - $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); + $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; DeleteResourceJob::dispatch($dragonfly); @@ -145,7 +145,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; } try { - $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); + $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; DeleteResourceJob::dispatch($clickhouse); @@ -154,7 +154,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; } try { - $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); + $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; DeleteResourceJob::dispatch($mongodb); @@ -163,7 +163,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; } try { - $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); + $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; DeleteResourceJob::dispatch($mysql); @@ -172,7 +172,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; } try { - $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); + $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; DeleteResourceJob::dispatch($mariadb); @@ -181,7 +181,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; } try { - $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); + $services = Service::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; DeleteResourceJob::dispatch($service); @@ -190,7 +190,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck service: {$e->getMessage()}\n"; } try { - $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceApps as $serviceApp) { echo "Deleting stuck serviceapp: {$serviceApp->name}\n"; $serviceApp->forceDelete(); @@ -199,7 +199,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceDbs as $serviceDb) { echo "Deleting stuck serviceapp: {$serviceDb->name}\n"; $serviceDb->forceDelete(); @@ -208,19 +208,27 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $scheduled_tasks = ScheduledTask::all(); + $scheduled_tasks = ScheduledTask::query() + ->where(function ($query): void { + $query->where(function ($query): void { + $query->whereNull('application_id')->whereNull('service_id'); + })->orWhere(function ($query): void { + $query->whereNotNull('application_id')->whereDoesntHave('application'); + })->orWhere(function ($query): void { + $query->whereNotNull('service_id')->whereDoesntHave('service'); + }); + }) + ->lazyById(); foreach ($scheduled_tasks as $scheduled_task) { - if (! $scheduled_task->service && ! $scheduled_task->application) { - echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; - $scheduled_task->delete(); - } + echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; + $scheduled_task->delete(); } } catch (\Throwable $e) { echo "Error in cleaning stuck scheduledtasks: {$e->getMessage()}\n"; } try { - $scheduled_backups = ScheduledDatabaseBackup::all(); + $scheduled_backups = ScheduledDatabaseBackup::query()->lazyById(); foreach ($scheduled_backups as $scheduled_backup) { try { $server = $scheduled_backup->server(); @@ -238,7 +246,7 @@ class CleanupStuckedResources extends Command // Cleanup any resources that are not attached to any environment or destination or server try { - $applications = Application::all(); + $applications = Application::query()->lazyById(); foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; @@ -263,7 +271,7 @@ class CleanupStuckedResources extends Command echo "Error in application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::all()->where('id', '!=', 0); + $postgresqls = StandalonePostgresql::query()->where('id', '!=', 0)->lazyById(); foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; @@ -288,7 +296,7 @@ class CleanupStuckedResources extends Command echo "Error in postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::all(); + $redis = StandaloneRedis::query()->lazyById(); foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; @@ -314,7 +322,7 @@ class CleanupStuckedResources extends Command } try { - $mongodbs = StandaloneMongodb::all(); + $mongodbs = StandaloneMongodb::query()->lazyById(); foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; @@ -340,7 +348,7 @@ class CleanupStuckedResources extends Command } try { - $mysqls = StandaloneMysql::all(); + $mysqls = StandaloneMysql::query()->lazyById(); foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; @@ -366,7 +374,7 @@ class CleanupStuckedResources extends Command } try { - $mariadbs = StandaloneMariadb::all(); + $mariadbs = StandaloneMariadb::query()->lazyById(); foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; @@ -392,7 +400,7 @@ class CleanupStuckedResources extends Command } try { - $services = Service::all(); + $services = Service::query()->lazyById(); foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; @@ -417,43 +425,23 @@ class CleanupStuckedResources extends Command echo "Error in service: {$e->getMessage()}\n"; } try { - $serviceApplications = ServiceApplication::all(); + $serviceApplications = ServiceApplication::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceApplications as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceApplication without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in serviceApplications: {$e->getMessage()}\n"; } try { - $serviceDatabases = ServiceDatabase::all(); + $serviceDatabases = ServiceDatabase::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceDatabases as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceDatabase without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in ServiceDatabases: {$e->getMessage()}\n"; } - try { - $orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) { - $query->select('id')->from('servers'); - })->get(); - - foreach ($orphanedCerts as $cert) { - echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n"; - $cert->delete(); - } - } catch (\Throwable $e) { - echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n"; - } } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6dc323838..8d4d017c81 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -46,6 +46,10 @@ class Kernel extends ConsoleKernel ->hourly() ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->command('cleanup:stucked-resources') + ->daily() + ->onOneServer() + ->withoutOverlapping(60); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Http/Controllers/Api/ApplicationSecretManagerController.php b/app/Http/Controllers/Api/ApplicationSecretManagerController.php new file mode 100644 index 0000000000..c8c311766d --- /dev/null +++ b/app/Http/Controllers/Api/ApplicationSecretManagerController.php @@ -0,0 +1,123 @@ + []]], + tags: ['Secret Managers'], + parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['integration_token_uuid'], + properties: [ + new OA\Property(property: 'integration_token_uuid', type: 'string'), + new OA\Property(property: 'settings', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Secret manager configured.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $request->route('uuid')) + ->first(); + + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $body = $request->json()->all(); + $token = IntegrationToken::query() + ->where('team_id', $teamId) + ->where('uuid', $body['integration_token_uuid'] ?? '') + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->first(); + + if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) { + return response()->json(['message' => 'Secret manager integration token not found.'], 404); + } + + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + 'settings' => ['sometimes', 'array'], + ]; + $rules += match ($token->provider) { + 'doppler' => $token->dopplerTokenType() === 'service_account' ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value)); + $application->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + + auditLog('api.application.secret_manager.updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'integration_token_uuid' => $token->uuid, + ]); + + return response()->json([ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + 'settings' => $settings ?: null, + ]); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 601c364de2..727f34f801 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -23,6 +23,7 @@ use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -1604,7 +1605,12 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); + $githubApp = GithubApp::where('uuid', $githubAppUuid) + ->where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + }) + ->first(); if (! $githubApp) { return response()->json(['message' => 'Github App not found.'], 404); } @@ -2482,6 +2488,256 @@ class ApplicationsController extends Controller ]); } + #[OA\Patch( + summary: 'Update Preview Domains', + description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.', + path: '/applications/{uuid}/previews/{pull_request_id}', + operationId: 'update-preview-domains-by-pull-request-id', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'), + new OA\Property( + property: 'docker_compose_domains', + type: 'array', + nullable: true, + items: new OA\Items(properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'domain', type: 'string', nullable: true), + new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']), + ], type: 'object'), + ), + new OA\Property(property: 'force_domain_override', type: 'boolean', default: false), + ], + )), + responses: [ + new OA\Response(response: 200, description: 'Preview domains updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, ref: '#/components/responses/403'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Domain conflict.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update_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('update', $application); + + $pullRequestIdRaw = $request->route('pull_request_id'); + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + return response()->json(['message' => 'Invalid pull_request_id.'], 422); + } + + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', (int) $pullRequestIdRaw) + ->first(); + if (! $preview) { + return response()->json(['message' => 'Preview not found.'], 404); + } + + $isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value; + $validationRules = ['force_domain_override' => 'boolean']; + if ($isCompose) { + $validationRules = array_merge($validationRules, [ + 'domains' => 'missing', + 'docker_compose_domains' => 'present|array', + 'docker_compose_domains.*' => 'array:name,domain,redirect', + 'docker_compose_domains.*.name' => 'required|string|distinct', + 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(), + 'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both', + ]); + } else { + $validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()]; + $validationRules['docker_compose_domains'] = 'missing'; + } + + $validator = Validator::make($request->all(), $validationRules); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $dockerComposeDomains = null; + $dockerComposeDomainsResponse = null; + if ($isCompose) { + try { + $compose = Yaml::parse($application->docker_compose_raw ?? ''); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'], + ], 422); + } + + $services = data_get($compose, 'services'); + if (! is_array($services) || $services === []) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'], + ], 422); + } + + $composeServices = collect($services) + ->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image'))) + ->keys() + ->map(fn (mixed $name): string => (string) $name) + ->values(); + $requestedServices = collect($request->input('docker_compose_domains'))->pluck('name'); + if ($requestedServices->diff($composeServices)->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'], + ], 422); + } + + $existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: []; + $dockerComposeDomains = $composeServices + ->mapWithKeys(function (string $service) use ($existingComposeDomains): array { + $entry = ['domain' => '']; + $redirect = $existingComposeDomains[$service]['redirect'] ?? null; + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + + return [$service => $entry]; + }) + ->all(); + foreach ($request->input('docker_compose_domains') as $item) { + $entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? '']; + $redirect = array_key_exists('redirect', $item) + ? data_get($item, 'redirect') + : ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null); + if (in_array($redirect, ['www', 'non-www', 'both'], true)) { + $entry['redirect'] = $redirect; + } + $dockerComposeDomains[data_get($item, 'name')] = $entry; + } + $domains = collect($dockerComposeDomains) + ->pluck('domain') + ->filter() + ->implode(',') ?: null; + } else { + $domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains')); + } + + $submittedUrls = collect(ValidationPatterns::applicationDomainList($domains)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)); + if ($submittedUrls->duplicates()->isNotEmpty()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + $isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.', + ], + ], 422); + } + + $normalized = DomainPortOverrides::normalize($domains, null); + $portlessDomains = $normalized['fqdn']; + if ($isCompose) { + foreach ($dockerComposeDomains as $service => $entry) { + $dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'])) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + } + $dockerComposeDomainsResponse = collect($dockerComposeDomains) + ->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry]) + ->values() + ->all(); + } + $urls = collect(ValidationPatterns::applicationDomainList($portlessDomains)); + $conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId); + if (isset($conflicts['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']], + ], 422); + } + if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $conflicts['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $hostCandidates = $urls + ->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST)) + ->filter(); + $conflictingPreview = null; + if ($hostCandidates->isNotEmpty()) { + $conflictingPreview = ApplicationPreview::query() + ->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId) + ->withoutGlobalScope('withRelations') + ->reorder() + ->select('applications.id')) + ->whereKeyNot($preview->id) + ->whereNotNull('fqdn') + ->where(function ($query) use ($hostCandidates): void { + foreach ($hostCandidates as $host) { + $query->orWhere('fqdn', 'like', '%'.$host.'%'); + } + }) + ->get(['uuid', 'pull_request_id', 'fqdn']) + ->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->isNotEmpty()); + } + + if ($conflictingPreview && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => [[ + 'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->intersect($urls) + ->first(), + 'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id, + 'resource_uuid' => $conflictingPreview->uuid, + 'resource_type' => 'application', + 'message' => 'Domain is already in use by another preview deployment.', + ]], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + + $preview->domain_port_overrides = $normalized['overrides']; + $preview->fqdn = $portlessDomains; + if ($isCompose) { + $preview->docker_compose_domains = json_encode($dockerComposeDomains); + } + $preview->save(); + + auditLog('api.application.preview_updated', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'], + ]); + + return response()->json([ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'domains' => $preview->fqdn, + 'docker_compose_domains' => $dockerComposeDomainsResponse, + 'domain_port_overrides' => $preview->domain_port_overrides, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete application by UUID.', @@ -2555,6 +2811,8 @@ class ApplicationsController extends Controller $this->authorize('delete', $application); + $application->delete(); + DeleteResourceJob::dispatch( resource: $application, deleteVolumes: $request->boolean('delete_volumes', true), @@ -2811,6 +3069,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => 'string', 'http_basic_auth_password' => 'string', 'include_source_commit_in_build' => 'boolean', + 'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); $validationMessages = [ @@ -2819,10 +3078,10 @@ class ApplicationsController extends Controller $validator = Validator::make($request->all(), $validationRules, $validationMessages); // Validate ports_exposes - if ($request->has('ports_exposes')) { + if ($request->filled('ports_exposes')) { $ports = explode(',', $request->ports_exposes); foreach ($ports as $port) { - if (! is_numeric($port)) { + if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -3122,7 +3381,7 @@ class ApplicationsController extends Controller if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); } - $application->save(); + $application->withoutAuditLogging(fn () => $application->save()); auditLog('api.application.updated', [ 'team_id' => $teamId, @@ -5145,7 +5404,7 @@ class ApplicationsController extends Controller $this->authorize('delete', $application); $pullRequestIdRaw = $request->route('pull_request_id'); - if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { + if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) { return response()->json(['message' => 'Invalid pull_request_id.'], 422); } $pullRequestId = (int) $pullRequestIdRaw; @@ -5630,14 +5889,6 @@ class ApplicationsController extends Controller return response()->json(['message' => $result['message']], 200); } - auditLog('api.application.rollback', [ - 'team_id' => $teamId, - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $deployment_uuid, - 'commit' => $commit, - ]); - return response()->json([ 'message' => 'Rollback deployment queued.', 'deployment_uuid' => $deployment_uuid, diff --git a/app/Http/Controllers/Api/AuditEventsController.php b/app/Http/Controllers/Api/AuditEventsController.php new file mode 100644 index 0000000000..da452bb303 --- /dev/null +++ b/app/Http/Controllers/Api/AuditEventsController.php @@ -0,0 +1,80 @@ +user()->isAdminOfTeam($teamId)) { + return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403); + } + + $validator = Validator::make($request->all(), [ + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'search' => ['sometimes', 'nullable', 'string', 'max:255'], + 'action' => ['sometimes', 'nullable', 'string', 'max:255'], + 'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])], + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $validated = $validator->validated(); + $perPage = (int) ($validated['per_page'] ?? 25); + $search = trim((string) ($validated['search'] ?? '')); + $canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true; + $events = AuditEvent::query() + ->select([ + 'id', + 'team_id', + 'event', + 'source', + 'action', + 'actor_type', + 'actor_id', + 'actor_name', + 'resource_type', + 'resource_uuid', + 'resource_name', + 'description', + 'created_at', + ]) + ->when($canReadSensitive, fn ($query) => $query->addSelect([ + 'actor_email', + 'actor_token_id', + 'actor_token_name', + 'metadata', + 'ip_address', + 'user_agent', + ])) + ->visibleToTeam($teamId) + ->filtered( + search: $search, + action: (string) ($validated['action'] ?? 'all'), + source: (string) ($validated['source'] ?? 'all'), + searchSensitiveFields: $canReadSensitive, + ) + ->latestFirst() + ->paginate($perPage); + + return response()->json(serializeApiResponse($events)); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7b62b4980a..aeb69ac8b4 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2586,6 +2586,8 @@ class DatabasesController extends Controller $this->authorize('delete', $database); + $database->delete(); + DeleteResourceJob::dispatch( resource: $database, deleteVolumes: $request->boolean('delete_volumes', true), diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 840a11f692..5a0e74d0d9 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -642,7 +642,7 @@ class GithubController extends Controller $rules['webhook_secret'] = 'string'; } if (isset($payload['private_key_uuid'])) { - $rules['private_key_uuid'] = 'string|uuid'; + $rules['private_key_uuid'] = 'string'; } if (! isCloud() && isset($payload['is_system_wide'])) { $rules['is_system_wide'] = 'boolean'; diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php new file mode 100644 index 0000000000..13a225a107 --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -0,0 +1,108 @@ + []]], + tags: ['Secret Managers'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['provider', 'name', 'token'], + properties: [ + new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'metadata', type: 'object'), + ], + ), + ), + responses: [ + new OA\Response(response: 201, description: 'Integration token created.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', IntegrationToken::class); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $body = $request->json()->all(); + $rules = [ + 'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'metadata' => ['sometimes', 'array'], + ]; + + if (($body['provider'] ?? null) === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } elseif (($body['provider'] ?? null) === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.client_id'] = ['required', 'string']; + } elseif (($body['provider'] ?? null) === 'vault') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + $validator = customApiValidator($body, $rules); + $extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']); + + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $validated = $validator->validated(); + $metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value)); + + if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) { + return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400); + } + + $integrationToken = IntegrationToken::query()->create([ + 'team_id' => $teamId, + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => ['secrets'], + 'metadata' => $metadata ?: null, + ]); + + auditLog('api.integration_token.created', [ + 'team_id' => $teamId, + 'integration_token_uuid' => $integrationToken->uuid, + 'provider' => $integrationToken->provider, + ]); + + return response()->json(['uuid' => $integrationToken->uuid], 201); + } +} diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index f5493d0249..1cca69a663 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -15,6 +15,7 @@ use App\Rules\ValidHostname; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class NotificationsController extends Controller @@ -45,6 +46,7 @@ class NotificationsController extends Controller 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', + 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', 'backup_failure_email_notifications' => 'sometimes|boolean', 'scheduled_task_success_email_notifications' => 'sometimes|boolean', @@ -66,6 +68,7 @@ class NotificationsController extends Controller 'deployment_success_discord_notifications' => 'sometimes|boolean', 'deployment_failure_discord_notifications' => 'sometimes|boolean', 'status_change_discord_notifications' => 'sometimes|boolean', + 'restart_limit_reached_discord_notifications' => 'sometimes|boolean', 'backup_success_discord_notifications' => 'sometimes|boolean', 'backup_failure_discord_notifications' => 'sometimes|boolean', 'scheduled_task_success_discord_notifications' => 'sometimes|boolean', @@ -88,6 +91,7 @@ class NotificationsController extends Controller 'deployment_success_slack_notifications' => 'sometimes|boolean', 'deployment_failure_slack_notifications' => 'sometimes|boolean', 'status_change_slack_notifications' => 'sometimes|boolean', + 'restart_limit_reached_slack_notifications' => 'sometimes|boolean', 'backup_success_slack_notifications' => 'sometimes|boolean', 'backup_failure_slack_notifications' => 'sometimes|boolean', 'scheduled_task_success_slack_notifications' => 'sometimes|boolean', @@ -110,6 +114,7 @@ class NotificationsController extends Controller 'deployment_success_telegram_notifications' => 'sometimes|boolean', 'deployment_failure_telegram_notifications' => 'sometimes|boolean', 'status_change_telegram_notifications' => 'sometimes|boolean', + 'restart_limit_reached_telegram_notifications' => 'sometimes|boolean', 'backup_success_telegram_notifications' => 'sometimes|boolean', 'backup_failure_telegram_notifications' => 'sometimes|boolean', 'scheduled_task_success_telegram_notifications' => 'sometimes|boolean', @@ -124,6 +129,7 @@ class NotificationsController extends Controller 'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255', 'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255', @@ -146,6 +152,7 @@ class NotificationsController extends Controller 'deployment_success_pushover_notifications' => 'sometimes|boolean', 'deployment_failure_pushover_notifications' => 'sometimes|boolean', 'status_change_pushover_notifications' => 'sometimes|boolean', + 'restart_limit_reached_pushover_notifications' => 'sometimes|boolean', 'backup_success_pushover_notifications' => 'sometimes|boolean', 'backup_failure_pushover_notifications' => 'sometimes|boolean', 'scheduled_task_success_pushover_notifications' => 'sometimes|boolean', @@ -167,6 +174,7 @@ class NotificationsController extends Controller 'deployment_success_webhook_notifications' => 'sometimes|boolean', 'deployment_failure_webhook_notifications' => 'sometimes|boolean', 'status_change_webhook_notifications' => 'sometimes|boolean', + 'restart_limit_reached_webhook_notifications' => 'sometimes|boolean', 'backup_success_webhook_notifications' => 'sometimes|boolean', 'backup_failure_webhook_notifications' => 'sometimes|boolean', 'scheduled_task_success_webhook_notifications' => 'sometimes|boolean', @@ -249,7 +257,7 @@ class NotificationsController extends Controller $body = $request->json()->all(); $config = $this->channelConfig($channel); - $validator = customApiValidator($body, $config['rules']); + $validator = Validator::make($body, $config['rules']); $extraFields = array_diff(array_keys($body), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index eb137c5349..16eff1ba18 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -271,12 +271,6 @@ class ProjectController extends Controller 'team_id' => $teamId, ]); - auditLog('api.project.created', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - ]); - return response()->json([ 'uuid' => $project->uuid, ])->setStatusCode(201); @@ -396,13 +390,6 @@ class ProjectController extends Controller $project->update($request->only($allowedFields)); - auditLog('api.project.updated', [ - 'team_id' => $teamId, - 'project_uuid' => $project->uuid, - 'project_name' => $project->name, - 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), - ]); - return response()->json([ 'uuid' => $project->uuid, 'name' => $project->name, @@ -482,16 +469,8 @@ class ProjectController extends Controller return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } - $projectUuid = $project->uuid; - $projectName = $project->name; $project->delete(); - auditLog('api.project.deleted', [ - 'team_id' => $teamId, - 'project_uuid' => $projectUuid, - 'project_name' => $projectName, - ]); - return response()->json(['message' => 'Project deleted.']); } diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index b3685daa4b..81b932365d 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -138,7 +138,7 @@ class SentinelController extends Controller /** * Build a stable hash of container state. * - * Covers [name, state] only — metrics, filesystem_usage_root, and + * Covers [name, state, restart_count] only — metrics, filesystem_usage_root, and * health_status are excluded on purpose. Disk % churns constantly, and * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat @@ -153,6 +153,7 @@ class SentinelController extends Controller ->map(fn ($c) => [ 'name' => data_get($c, 'name'), 'state' => data_get($c, 'state'), + 'restart_count' => data_get($c, 'restart_count'), ]) ->sortBy('name') ->values() diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index d7d4953f9c..9bfcfd8539 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1020,6 +1020,8 @@ class ServicesController extends Controller $this->authorize('delete', $service); + $service->delete(); + DeleteResourceJob::dispatch( resource: $service, deleteVolumes: $request->boolean('delete_volumes', true), diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index aca4293919..b1cb8d853d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Http\Middleware\HandleCors; use Illuminate\Http\Middleware\SetCacheHeaders; @@ -59,6 +60,7 @@ class Kernel extends HttpKernel ValidatePostSize::class, TrimStrings::class, ConvertEmptyStringsToNull::class, + InvokeDeferredCallbacks::class, ]; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1e8450c1b9..0887e7e864 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Support\RemoteSecretReferences; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; @@ -44,6 +45,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env'; + public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env'; + + public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env'; + private const BUILD_SCRIPT_PATH = '/artifacts/build.sh'; private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; @@ -143,6 +148,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; + /** @var array|null */ + private ?array $remote_secrets_cache = null; + private $env_nixpacks_args; private $env_railpack_args; @@ -201,6 +209,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerSecretsSupported = false; + private bool $dockerSecretsAvailable = false; + + private bool $useBuildtimeEnvironmentLauncher = false; + private bool $skip_build = false; private Collection|string $build_secrets; @@ -261,14 +273,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; - $this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id); - if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) { - if ($this->pull_request_id === 0) { - $this->container_name = $this->application->settings->custom_internal_name; - } else { - $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); - } - } + $this->container_name = $this->resolveContainerName(); $this->saved_outputs = collect(); @@ -425,6 +430,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function detectBuildKitCapabilities(): void { + $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; + $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -475,18 +485,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - // If build secrets are enabled and BuildKit is available, verify --secret flag support - if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { - $this->dockerSecretsSupported = true; - $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); - } else { - $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = true; + if ($this->application->settings->use_build_secrets) { + $this->dockerSecretsSupported = true; + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } + } elseif ($this->application->settings->use_build_secrets) { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } @@ -494,6 +505,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; + $this->dockerSecretsAvailable = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } @@ -614,6 +626,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->dockerImagePreviewTag; } + if ($this->rollback && str($this->commit)->isNotEmpty()) { + return $this->commit; + } + if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { return $this->application->docker_registry_image_tag; } @@ -1275,6 +1291,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return true; } + if ($this->has_remote_buildtime_secret_references()) { + $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.'); + + return false; + } $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); if (! $configurationDiff->requiresBuild()) { $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); @@ -1302,6 +1323,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return false; } + private function has_remote_buildtime_secret_references(): bool + { + $environmentVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + : $this->application->environment_variables_preview(); + + return $environmentVariables + ->where('is_buildtime', true) + ->get(['value']) + ->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value)); + } + private function check_image_locally_or_remotely() { $this->execute_remote_command([ @@ -1323,6 +1356,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * Fetch the secrets from the application's secret manager source. Values + * live only in memory during the deployment and in the generated .env on + * the server — they are never persisted in the Coolify database. Fetched + * lazily (only when a variable references a secret), once per deployment. + * A fetch failure fails the deployment. + * + * @return array + */ + private function remote_secrets(): array + { + if ($this->remote_secrets_cache !== null) { + return $this->remote_secrets_cache; + } + + $link = $this->application->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.'); + } + + $provider = $link->integrationToken->providerName(); + $tokenName = $link->integrationToken->name; + + try { + $secrets = $link->fetchSecrets(); + } catch (Throwable $e) { + $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr'); + + throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); + } + + $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); + + return $this->remote_secrets_cache = $secrets; + } + + /** + * Replace {{vault.KEY}} references with values from the configured secret + * manager source. Missing keys fail the deployment with a + * list — changing the source never re-checks references, so this is the + * moment problems surface. + */ + private function substitute_remote_secrets(string $value, string $envKey): string + { + $secrets = $this->remote_secrets(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey})."; + $this->application_deployment_queue->addLogEntry($message, 'stderr'); + + throw new DeploymentException($message.' Check the secret manager source of this application.'); + } + + return RemoteSecretReferences::substitute($value, $secrets); + } + + /** + * Resolve shared variables, then secret references, in a raw variable value. + */ + private function resolve_environment_variable_raw(EnvironmentVariable $env): string + { + $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); + + return $this->substitute_remote_secrets($value ?? '', $env->key); + } + + /** + * Resolve a runtime variable to its dotenv representation. Values with + * secret references are substituted and written as literals. + */ + private function resolve_environment_variable(EnvironmentVariable $env): ?string + { + if (! RemoteSecretReferences::containsReference($env->value)) { + return $env->getResolvedValueWithServer($this->mainServer); + } + + return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env)); + } + + /** + * Format a remote secret value for the runtime .env file (dotenv syntax read + * by docker compose). Values are treated as literals — no interpolation. + */ + private function format_remote_secret_value(string $value): string + { + if (! str_contains($value, "'")) { + return "'".$value."'"; + } + + // Fall back to double quotes; $$ escapes compose interpolation. + return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"'; + } + private function generate_runtime_environment_variables() { $envs = collect([]); @@ -1391,7 +1519,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Check for PORT environment variable mismatch with ports_exposes @@ -1458,7 +1586,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } // Fall back to production env vars for keys not overridden by preview vars, @@ -1472,7 +1600,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { - $envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer)); + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } } @@ -1580,6 +1708,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + 'skip_command_log' => true, ] ); @@ -1598,6 +1727,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + 'skip_command_log' => true, ] ); $this->server = $this->build_server; @@ -1605,6 +1735,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command( [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + 'skip_command_log' => true, ] ); } @@ -1634,11 +1765,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($planVariables as $key => $value) { + $key = (string) $key; + // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { continue; } + $key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan'); $escapedValue = escapeBashEnvValue($value); $envs_dict[$key] = $escapedValue; @@ -1728,6 +1862,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1783,6 +1923,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue continue; } + if (RemoteSecretReferences::containsReference($env->value)) { + $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); + + continue; + } + $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { @@ -1826,6 +1972,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Convert dictionary back to collection in KEY=VALUE format $envs = collect([]); foreach ($envs_dict as $key => $value) { + $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment'); $envs->push($key.'='.$value); } @@ -1839,44 +1986,132 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $envs; } - private function save_buildtime_environment_variables() + private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string { - // Generate build-time environment variables locally - $environment_variables = $this->generate_buildtime_environment_variables(); - // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images - if ($environment_variables->isNotEmpty()) { - $envs_base64 = base64_encode($environment_variables->implode("\n")); + try { + if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException('Invalid build-time environment variable key.'); - $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); - - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), - ] - ); - - if (isDev()) { - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), - 'hidden' => true, - ] - ); } - } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { - // For build packs that source the build-time .env file, create an empty file even if there are no build-time variables - // This ensures the file exists when referenced in build commands - $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), - ] + return $key; + } catch (\InvalidArgumentException $exception) { + $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin); + + throw new DeploymentException( + "Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', + previous: $exception, ); } } + private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void + { + $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + $this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr'); + $this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr'); + $this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info'); + + if ($origin === 'the Nixpacks plan') { + $this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info'); + $this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + $this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info'); + $this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info'); + } else { + $this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info'); + $this->logSuggestedShellEnvironmentVariableKey($key); + } + + $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); + } + + private function logSuggestedShellEnvironmentVariableKey(string $key): void + { + $suggestedKey = str_replace('.', '_', $key); + if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) { + return; + } + + $displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey); + + $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); + } + + private function save_buildtime_environment_variables() + { + $environment_variables = $this->generate_buildtime_environment_variables(); + [$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool { + [$key] = explode('=', $environmentVariable, 2); + + return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1; + }); + + if ($dotted_environment_variables->isEmpty()) { + $this->useBuildtimeEnvironmentLauncher = false; + + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), + ]); + + if (isDev()) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH), + 'hidden' => true, + ]); + } + } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { + $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), + ]); + } + + return; + } + + $this->useBuildtimeEnvironmentLauncher = true; + + $launcher = [ + '#!/bin/bash', + 'set -a', + 'source '.self::BUILD_TIME_SHELL_ENV_PATH, + 'set +a', + ]; + + $launcher[] = 'exec env \\'; + foreach ($dotted_environment_variables as $environmentVariable) { + $launcher[] = " {$environmentVariable} \\"; + } + $launcher[] = ' "$@"'; + + $files = [ + self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"), + self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"), + self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n", + ]; + + $this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true); + + foreach ($files as $path => $contents) { + $contents_base64 = base64_encode($contents); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"), + ]); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH), + ]); + } + private function elixir_finetunes() { if ($this->pull_request_id === 0) { @@ -1982,6 +2217,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + private function resolveContainerName(): string + { + if (str($this->application->settings->custom_internal_name)->isEmpty()) { + return generateApplicationContainerName($this->application, $this->pull_request_id); + } + + if ($this->pull_request_id === 0) { + return $this->application->settings->custom_internal_name; + } + + return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); + } + private function health_check() { try { @@ -2270,9 +2518,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { - $url = Url::fromString($fqdn); - $fqdn = $url->getHost(); - $url = $url->withHost($fqdn)->withPort(null)->__toString(); + $domains = str($fqdn)->explode(',')->map(fn (string $domain) => trim($domain))->filter(); + $url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(','); + $fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(','); if ((int) $this->application->compose_parsing_version >= 3) { $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; @@ -2651,6 +2899,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { + if (RemoteSecretReferences::containsReference($environmentVariable->value)) { + $resolved = $this->resolve_environment_variable_raw($environmentVariable); + + return $resolved === '' ? null : $resolved; + } + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); if (is_null($resolvedValue) || $resolvedValue === '') { return null; @@ -3194,7 +3448,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -3210,7 +3466,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { - $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); + $resolvedValue = RemoteSecretReferences::containsReference($env->value) + ? $this->resolve_environment_variable_raw($env) + : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } @@ -3297,6 +3555,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; + if ($this->application->settings->stop_grace_period !== null) { + $docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s'; + } + // Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added @@ -3419,24 +3681,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); if ((bool) $this->application->settings->is_consistent_container_name_enabled) { - if (! $this->application->settings->custom_internal_name) { - $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; - if (count($custom_compose) > 0) { - $ipv4 = data_get($custom_compose, 'ip.0'); - $ipv6 = data_get($custom_compose, 'ip6.0'); - data_forget($custom_compose, 'ip'); - data_forget($custom_compose, 'ip6'); - if ($ipv4 || $ipv6) { - data_forget($docker_compose['services'][$this->application->uuid], 'networks'); - } - if ($ipv4) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; - } - if ($ipv6) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; - } - $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); + $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; + if ($this->container_name !== $this->application->uuid) { + unset($docker_compose['services'][$this->container_name]); + } + if (count($custom_compose) > 0) { + $ipv4 = data_get($custom_compose, 'ip.0'); + $ipv6 = data_get($custom_compose, 'ip6.0'); + data_forget($custom_compose, 'ip'); + data_forget($custom_compose, 'ip6'); + if ($ipv4) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; } + if ($ipv6) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; + } + $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); } } else { if (count($custom_compose) > 0) { @@ -3641,7 +3901,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); */ private function wrap_build_command_with_env_export(string $build_command): string { - return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + if (! $this->useBuildtimeEnvironmentLauncher) { + return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; + } + + $escapedBuildCommand = escapeBashEnvValue($build_command); + + return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}"; } private function build_image() @@ -4024,7 +4290,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Removing old containers.'); if ($this->newVersionIsHealthy || $force) { if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - $this->graceful_shutdown_container($this->container_name); + $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); + $this->containerNamesToRemove($containers)->each(function (string $containerName) { + $this->graceful_shutdown_container($containerName); + }); } else { $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { @@ -4063,6 +4332,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + private function containerNamesToRemove(Collection $containers): Collection + { + return $containers + ->pluck('Names') + ->push($this->container_name) + ->filter() + ->unique() + ->values(); + } + private function start_by_compose_file() { try { @@ -4151,6 +4430,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->analyzeBuildTimeVariables($variables); } + $requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks' + && $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.')); + + if ($requiresDottedEnvironmentSecrets) { + if (! $this->dockerSecretsAvailable) { + $dottedKeys = $variables->keys() + ->filter(fn ($key): bool => str_contains((string) $key, '.')) + ->implode(', '); + + throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server."); + } + + $this->dockerSecretsSupported = true; + } + if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; @@ -4268,7 +4562,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { $secrets_string = $variables ->map(function ($env) { - return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"; + return "{$env->key}={$this->resolve_environment_variable($env)}"; }) ->sort() ->implode('|'); @@ -4334,7 +4628,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); } } // Add Coolify variables as ARGs @@ -4356,7 +4650,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (data_get($env, 'is_multiline') === true) { $argsToInsert->push("ARG {$env->key}"); } else { - $argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}"); + $argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env))); } } // Add Coolify variables as ARGs @@ -4370,6 +4664,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + if ($argsToInsert->isNotEmpty()) { + $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) { + return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))]; + }); + $secretsHash = $this->generate_secrets_hash($environmentVariables); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"); + } + // Development logging to show what ARGs are being injected if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); @@ -4391,11 +4693,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); } } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->getResolvedValueWithServer($this->mainServer)]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); @@ -4404,18 +4701,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'ignore_errors' => true, + 'skip_command_log' => true, ]); } private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount - if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + if (empty($this->build_secrets)) { return; } @@ -4439,18 +4732,51 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->generate_env_variables(); } - $variables = $this->env_args; + $variables = $this->application->build_pack === 'nixpacks' + ? collect($this->nixpacks_plan_json->get('variables')) + : $this->env_args; if ($variables->isEmpty()) { return; } + $dottedKeys = $variables->keys() + ->map(fn ($key): string => (string) $key) + ->filter(fn (string $key): bool => str_contains($key, '.')); + + if ($dottedKeys->isNotEmpty()) { + $originalDockerfile = $dockerfile; + $dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string { + $trimmedLine = trim($line); + + if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) { + return $line; + } + + [$instruction, $arguments] = explode(' ', $trimmedLine, 2); + $filteredArguments = collect(preg_split('/\s+/', $arguments)) + ->reject(function (string $argument) use ($dottedKeys): bool { + $key = str($argument)->before('=')->toString(); + + return $dottedKeys->contains($key); + }); + + if ($filteredArguments->isEmpty()) { + return null; + } + + return $instruction.' '.$filteredArguments->implode(' '); + })->filter()->values(); + + $modified = $dockerfile->all() !== $originalDockerfile->values()->all(); + } + // Generate mount strings for all secrets $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; - $modified = false; + $modified ??= false; $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { $trimmed = ltrim($line); @@ -4947,11 +5273,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Reset restart count after successful deployment // This is done here (not in Livewire) to avoid race conditions // with GetContainersStatus reading old container restart counts - $this->application->update([ + $restartState = [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, - ]); + ]; + + if ($this->pull_request_id === 0) { + $restartState['restart_limit_reached'] = false; + } + + if ($this->pull_request_id === 0) { + $this->application->update($restartState); + } else { + $this->preview?->resetRestartLimit(); + } try { $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); diff --git a/app/Jobs/CheckDomainDnsJob.php b/app/Jobs/CheckDomainDnsJob.php new file mode 100644 index 0000000000..c013da25a6 --- /dev/null +++ b/app/Jobs/CheckDomainDnsJob.php @@ -0,0 +1,91 @@ +persistResults(CheckDomainDns::run( + [$this->statusKey => $this->url], + $this->server, + $this->expectedIp, + $this->skipForMultipleServers, + )); + } + + public function failed(?\Throwable $exception): void + { + $this->persistResults([ + $this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'), + ]); + } + + /** + * @return array{status: string, message: string, expected_ip: ?string, checked_at: string} + */ + private function status(string $status, string $message): array + { + return [ + 'status' => $status, + 'message' => $message, + 'expected_ip' => $this->expectedIp, + 'checked_at' => now()->toIso8601String(), + ]; + } + + /** + * @param array $results + */ + private function persistResults(array $results): void + { + DB::transaction(function () use ($results): void { + $resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey()); + if (! $resource) { + return; + } + + $statuses = $resource->domain_dns_statuses ?? []; + + foreach ($results as $key => $result) { + if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) { + continue; + } + + $statuses[$key] = $result; + } + + $resource->domain_dns_statuses = $statuses === [] ? null : $statuses; + $resource->save(); + }); + } +} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 1838feb9e7..b1f2c38b96 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -322,6 +322,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { if ($database === '*') { @@ -343,6 +344,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -357,6 +359,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -371,6 +374,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mariadb($database); } elseif ($this->database instanceof StandaloneClickhouse) { $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; @@ -382,6 +386,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); @@ -480,14 +485,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } catch (Throwable $e) { throw $e; } finally { - if ($this->team) { - BackupCreated::dispatch($this->team->id); - } if ($this->backup_log) { $this->backup_log->update([ 'finished_at' => Carbon::now()->toImmutable(), ]); } + if ($this->team) { + BackupCreated::dispatch($this->team->id); + } } } @@ -798,7 +803,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->add_to_error_output($e->getMessage()); throw $e; } finally { - $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; + $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}"); instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/DatabaseStartJob.php b/app/Jobs/DatabaseStartJob.php new file mode 100644 index 0000000000..e21ee38c61 --- /dev/null +++ b/app/Jobs/DatabaseStartJob.php @@ -0,0 +1,88 @@ +onQueue(deployment_queue()); + } + + public function handle(): void + { + $database = $this->databaseClass::query()->findOrFail($this->databaseId); + abort_unless((int) $database->team()->id === $this->teamId, 403); + $activity = Activity::query()->findOrFail($this->activityId); + + match ($database->getMorphClass()) { + StandalonePostgresql::class => StartPostgresql::run($database, $activity), + StandaloneRedis::class => StartRedis::run($database, $activity), + StandaloneMongodb::class => StartMongodb::run($database, $activity), + StandaloneMysql::class => StartMysql::run($database, $activity), + StandaloneMariadb::class => StartMariadb::run($database, $activity), + StandaloneKeydb::class => StartKeydb::run($database, $activity), + StandaloneDragonfly::class => StartDragonfly::run($database, $activity), + StandaloneClickhouse::class => StartClickhouse::run($database, $activity), + }; + + event(new DatabaseStatusChanged($this->userId)); + } + + public function failed(?Throwable $exception): void + { + try { + $activity = Activity::query()->find($this->activityId); + if (! $activity) { + return; + } + + $activity->properties = $activity->properties->merge([ + 'status' => ProcessStatus::ERROR->value, + 'error' => 'Database start failed.', + 'failed_at' => now()->toIso8601String(), + ]); + $activity->save(); + } finally { + event(new DatabaseStatusChanged($this->userId)); + } + } +} diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dff7d88de1..d07f346e56 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -26,7 +26,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -47,9 +46,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { if ($this->resource instanceof ApplicationPreview) { - DB::transaction(function (): void { - $this->deleteApplicationPreview(); - }); + $this->deleteApplicationPreview(); return; } @@ -99,17 +96,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]); } - DB::transaction(function (): void { - try { - $this->deleteScheduledVolumeBackups(); - } catch (\Throwable $e) { - Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ - 'resource_id' => $this->resource->id, - 'resource_type' => $this->resource->type(), - 'error' => $e->getMessage(), - ]); - } + try { + $this->deleteScheduledVolumeBackups(); + } catch (\Throwable $e) { + Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + DB::transaction(function (): void { if ($this->resource instanceof Service) { app(DeleteService::class)->deleteLocal($this->resource); @@ -130,7 +127,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); }); - Artisan::queue('cleanup:stucked-resources'); } private function isDatabase(): bool @@ -163,10 +159,22 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } } - private function deleteApplicationPreview() + private function deleteApplicationPreview(): void { $application = $this->resource->application; - $server = $application->destination->server; + + if (! $application) { + $this->deleteApplicationPreviewLocally(); + + return; + } + + $server = $application->destination?->server; + if (! $server) { + $this->deleteApplicationPreviewLocally(); + + return; + } $pull_request_id = $this->resource->pull_request_id; // Ensure the preview is soft deleted (may already be done in Livewire component) @@ -239,6 +247,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); } + private function deleteApplicationPreviewLocally(): void + { + DB::transaction(function (): void { + $this->resource->persistentStorages()->delete(); + ApplicationPreview::withoutEvents(fn () => $this->resource->forceDelete()); + }); + } + private function stopPreviewContainers(array $containers, $server, int $timeout = 30) { if (empty($containers)) { diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9c4a2531a9..0e73ee41b2 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -2,11 +2,15 @@ namespace App\Jobs; +use App\Actions\Application\StopApplication; +use App\Actions\Application\StopApplicationPreview; use App\Actions\Database\StartDatabaseProxy; +use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabaseProxy; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Actions\Server\StartLogDrain; +use App\Actions\Service\StopServiceApplication; use App\Actions\Shared\ComplexStatusCheck; use App\Models\Application; use App\Models\ApplicationPreview; @@ -23,8 +27,10 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\SwarmDocker; +use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached; use App\Notifications\Container\ContainerRestarted; use App\Services\ContainerStatusAggregator; +use App\Services\RestartCountTracker; use App\Traits\CalculatesExcludedStatus; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -95,8 +101,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $applicationContainerStatuses; + public Collection $applicationContainerRestartCounts; + public Collection $serviceContainerStatuses; + public Collection $previewContainerRestartCounts; + + public Collection $serviceContainerRestartCounts; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; @@ -122,7 +134,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); $this->applicationContainerStatuses = collect(); + $this->applicationContainerRestartCounts = collect(); $this->serviceContainerStatuses = collect(); + $this->previewContainerRestartCounts = collect(); + $this->serviceContainerRestartCounts = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -140,7 +155,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced { // Defensive initialization for Collection properties to handle queue deserialization edge cases $this->serviceContainerStatuses ??= collect(); + $this->previewContainerRestartCounts ??= collect(); + $this->serviceContainerRestartCounts ??= collect(); $this->applicationContainerStatuses ??= collect(); + $this->applicationContainerRestartCounts ??= collect(); $this->foundApplicationIds ??= collect(); $this->foundDatabaseUuids ??= collect(); $this->foundServiceApplicationIds ??= collect(); @@ -231,6 +249,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if (! $coolify_managed) { continue; } + if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) { + continue; + } $name = data_get($container, 'name'); if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) { @@ -241,6 +262,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $pullRequestId = $labels->get('coolify.pullRequestId', '0'); try { if ($pullRequestId === '0') { + $application = $this->applicationsById->get((string) $applicationId); + if ($application && $application->container_present !== true) { + $application->update(['container_present' => true]); + } if ($this->allApplicationIds->contains($applicationId)) { $this->foundApplicationIds->push($applicationId); } @@ -251,6 +276,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + if (! $this->applicationContainerRestartCounts->has($applicationId)) { + $this->applicationContainerRestartCounts->put($applicationId, collect()); + } + $this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount); + } } } else { $previewKey = $applicationId.':'.$pullRequestId; @@ -258,6 +290,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundApplicationPreviewsIds->push($previewKey); } $this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus); + $restartCount = data_get($container, 'restart_count'); + if (is_numeric($restartCount)) { + $this->previewContainerRestartCounts->push([ + 'key' => $previewKey, + 'count' => (int) $restartCount, + ]); + } } } catch (\Exception $e) { } @@ -278,6 +317,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } elseif ($subType === 'database') { $this->foundServiceDatabaseIds->push($subId); @@ -289,6 +329,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $containerName = $labels->get('com.docker.compose.service'); if ($containerName) { $this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus); + $this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count')); } } } else { @@ -302,9 +343,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundDatabaseUuids->push($uuid); // TCP proxy should only be started/managed when database is actually running if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true); } else { - $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false); + $this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false); } } } @@ -317,6 +358,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateProxyStatus(); + Application::whereIn('id', $this->foundApplicationIds->unique()) + ->update(['container_present' => true]); + $this->updateNotFoundApplicationStatus(); $this->updateNotFoundApplicationPreviewStatus(); $this->updateNotFoundDatabaseStatus(); @@ -324,6 +368,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateAdditionalServersStatus(); + $this->trackPreviewRestartCounts(); + // Aggregate multi-container application statuses $this->aggregateMultiContainerStatuses(); @@ -349,11 +395,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds)) @@ -372,11 +425,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'uuid', 'name', 'status', + 'container_present', 'build_pack', 'docker_compose_raw', + 'environment_id', 'destination_id', 'destination_type', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->withCount('additional_servers') ->whereIn('id', $additionalApplicationIds) @@ -402,6 +462,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'pull_request_id', 'status', 'last_online_at', + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', ]) ->whereIn('application_id', $applicationIds) ->get(); @@ -417,8 +482,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'docker_compose_raw', ]) ->with([ - 'applications:id,service_id,status,last_online_at', - 'databases:id,service_id,status,last_online_at,is_public,name', + 'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', + 'databases:id,service_id,status,last_online_at,is_public,name,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type', ]) ->get(); } @@ -441,6 +506,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced 'restart_count', 'last_restart_at', 'last_restart_type', + 'max_restart_count', + 'restart_limit_reached', ]; return collect([ @@ -495,6 +562,53 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced continue; } + $maxRestartCount = 0; + $restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId); + if ($restartCountsAvailable) { + $maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0; + $restartState = (new RestartCountTracker)->evaluate( + previousRestartCount: $application->restart_count ?? 0, + observedRestartCount: $maxRestartCount, + maxRestartCount: $application->max_restart_count ?? 0, + ); + + if ($restartState['restart_count_changed']) { + $hasCrashRestarts = $restartState['restart_count'] > 0; + $application->update([ + 'restart_count' => $restartState['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if ($restartState['restart_limit_reached']) { + $restartLimitClaimed = Application::query() + ->whereKey($application->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($restartLimitClaimed) { + $application->refresh(); + StopApplication::dispatch( + application: $application, + previewDeployments: false, + dockerCleanup: false, + resetRestartCount: false, + removeContainers: false, + ); + $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application)); + } + } + } + + if ($application->stoppedAfterRestartLimit() && $containerStatuses->every( + fn (string $status): bool => str($status)->contains('exited') + )) { + $application->update(['status' => 'exited']); + + continue; + } + // Parse docker compose to check for excluded containers $dockerComposeRaw = data_get($application, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -519,7 +633,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Use ContainerStatusAggregator service for state machine logic // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -560,6 +674,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced continue; } + $restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0; + if ($subResource->trackRestartCount($restartCount)) { + StopServiceApplication::dispatch($subResource, false, false); + $subResource->team()?->notify(new ApplicationRestartLimitReached($subResource)); + + continue; + } + // Parse docker compose from service to check for excluded containers $dockerComposeRaw = data_get($service, 'docker_compose_raw'); $excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw); @@ -581,10 +703,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } // Use ContainerStatusAggregator service for state machine logic - // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { @@ -627,8 +748,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update: mark all not-found applications as exited (excluding already exited ones) Application::whereIn('id', $notFoundApplicationIds) - ->where('status', 'not like', 'exited%') - ->update(['status' => 'exited']); + ->update([ + 'status' => 'exited', + 'container_present' => false, + 'restart_limit_reached' => false, + ]); } private function updateNotFoundApplicationPreviewStatus() @@ -687,7 +811,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } - private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false) + private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void { $database = $this->databasesByUuid->get($databaseUuid); if (! $database) { @@ -697,6 +821,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $database->status = $containerStatus; $database->save(); } + if (is_numeric($restartCount) && $database->trackRestartCount((int) $restartCount)) { + StopDatabase::dispatch($database, false, false, false); + $database->team()?->notify(new ApplicationRestartLimitReached($database)); + + return; + } if (! $this->isCompleteSnapshot()) { return; } @@ -719,6 +849,30 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced } } + private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void + { + if (! is_numeric($restartCount)) { + return; + } + if (! $this->serviceContainerRestartCounts->has($key)) { + $this->serviceContainerRestartCounts->put($key, collect()); + } + $this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount); + } + + private function trackPreviewRestartCounts(): void + { + $this->previewContainerRestartCounts + ->groupBy('key') + ->each(function (Collection $counts, string $key): void { + $preview = $this->previewsByKey->get($key); + if ($preview?->trackRestartCount((int) $counts->max('count'))) { + StopApplicationPreview::dispatch($preview, false, false); + $preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview)); + } + }); + } + private function updateNotFoundDatabaseStatus() { $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); @@ -729,12 +883,16 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $notFoundDatabaseUuids->each(function ($databaseUuid) { $database = $this->databasesByUuid->get($databaseUuid); if ($database) { + if ($database->stoppedAfterRestartLimit()) { + return; + } if (! str($database->status)->startsWith('exited')) { $database->update([ 'status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, + 'restart_limit_reached' => false, ]); } if ($database->is_public) { @@ -752,15 +910,17 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced // Batch update service applications if ($notFoundServiceApplicationIds->isNotEmpty()) { ServiceApplication::whereIn('id', $notFoundServiceApplicationIds) + ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited']); + ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); } // Batch update service databases if ($notFoundServiceDatabaseIds->isNotEmpty()) { ServiceDatabase::whereIn('id', $notFoundServiceDatabaseIds) + ->where('restart_limit_reached', false) ->where('status', '!=', 'exited') - ->update(['status' => 'exited']); + ->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]); } } diff --git a/app/Jobs/SendVerificationEmailJob.php b/app/Jobs/SendVerificationEmailJob.php new file mode 100644 index 0000000000..b9d2c8e11e --- /dev/null +++ b/app/Jobs/SendVerificationEmailJob.php @@ -0,0 +1,29 @@ +onQueue('high'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->user->sendVerificationEmail(); + } +} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index fe7a20972c..97d211d247 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -100,7 +100,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue ]); if ($this->server->unreachable_count > 0) { - $this->server->update(['unreachable_count' => 0]); + // Direct assignment: unreachable_count is not mass-assignable, + // so update() would silently drop the reset. + $this->server->unreachable_count = 0; + $this->server->save(); } $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 30ecb2d8d5..9b117d4e13 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit if ($status === 'created') { instant_remote_process([ - 'docker rm -f coolify-proxy', + dockerRemoveCommand('coolify-proxy'), ], $server); } } diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 1b344c9056..b0ab4d183e 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -43,7 +43,7 @@ class Show extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -81,11 +81,11 @@ class Show extends Component } $safeNetwork = escapeshellarg($this->destination->network); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); - instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); + instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server); } $this->destination->delete(); - return redirect()->route('destination.index'); + return redirectRoute($this, 'destination.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Dev/LivewireRequestFailurePreview.php b/app/Livewire/Dev/LivewireRequestFailurePreview.php new file mode 100644 index 0000000000..5cdda5d781 --- /dev/null +++ b/app/Livewire/Dev/LivewireRequestFailurePreview.php @@ -0,0 +1,31 @@ + + */ + public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]; + + public function fail(int $status): never + { + abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND); + + throw new HttpResponseException(response( + '

Gateway time-out

cloudflare proxy error '.$status.'

', + $status, + ['Content-Type' => 'text/html'] + )); + } + + public function render(): mixed + { + return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple'); + } +} diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index bf64ee8e9d..c6ed818e97 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1507,8 +1507,7 @@ class GlobalSearch extends Component 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', - 'logo' => data_get($service, 'logo'), - ] + array_filter([ + ] + service_logo_urls(data_get($service, 'logo')) + array_filter([ 'amd_only' => data_get($service, 'amd_only') ? true : null, 'arm_only' => data_get($service, 'arm_only') ? true : null, ])); diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 59ecb06e8e..cb31e6c111 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -34,6 +34,9 @@ class Discord extends Component #[Validate(['boolean'])] public bool $statusChangeDiscordNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedDiscordNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessDiscordNotifications = false; @@ -82,17 +85,17 @@ class Discord extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_webhook_url = $this->discordWebhookUrl; $this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications; $this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications; $this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications; + $this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications; $this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications; $this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications; $this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications; @@ -118,6 +121,7 @@ class Discord extends Component $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; $this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications; + $this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications; $this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications; $this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications; $this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications; @@ -193,6 +197,7 @@ class Discord extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -203,6 +208,7 @@ class Discord extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -212,6 +218,8 @@ class Discord extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 2a373a5065..ea626ed57a 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,6 +2,7 @@ namespace App\Livewire\Notifications; +use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -14,7 +15,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests; + use AuthorizesRequests, TogglesNotificationEvents; protected $listeners = ['refresh' => '$refresh']; @@ -78,6 +79,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedEmailNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessEmailNotifications = false; @@ -128,12 +132,11 @@ class Email extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); $this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]); - $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -154,6 +157,7 @@ class Email extends Component $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; + $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; $this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications; $this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications; @@ -192,6 +196,7 @@ class Email extends Component $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; + $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; $this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications; $this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications; @@ -218,6 +223,8 @@ class Email extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); $this->dispatch('success', 'Email notifications settings updated.'); } diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index b1608c5ea2..cae1c3d689 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -41,6 +41,9 @@ class Pushover extends Component #[Validate(['boolean'])] public bool $statusChangePushoverNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedPushoverNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessPushoverNotifications = false; @@ -86,11 +89,10 @@ class Pushover extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_api_token = $this->pushoverApiToken; @@ -98,6 +100,7 @@ class Pushover extends Component $this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications; $this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications; $this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications; + $this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications; $this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications; $this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications; $this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications; @@ -125,6 +128,7 @@ class Pushover extends Component $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; $this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications; + $this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications; $this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications; $this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications; $this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications; @@ -190,6 +194,7 @@ class Pushover extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -202,6 +207,7 @@ class Pushover extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -211,6 +217,8 @@ class Pushover extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index c4ca7da802..644252c1a3 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -39,6 +39,9 @@ class Slack extends Component #[Validate(['boolean'])] public bool $statusChangeSlackNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedSlackNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessSlackNotifications = false; @@ -84,17 +87,17 @@ class Slack extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_webhook_url = $this->slackWebhookUrl; $this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications; $this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications; $this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications; + $this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications; $this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications; $this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications; $this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications; @@ -118,6 +121,7 @@ class Slack extends Component $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; $this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications; + $this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications; $this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications; $this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications; $this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications; @@ -179,6 +183,7 @@ class Slack extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -191,6 +196,7 @@ class Slack extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -200,6 +206,8 @@ class Slack extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 9f19b22f5f..f999294477 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -41,6 +41,9 @@ class Telegram extends Component #[Validate(['boolean'])] public bool $statusChangeTelegramNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedTelegramNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessTelegramNotifications = false; @@ -83,6 +86,9 @@ class Telegram extends Component #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsStatusChangeThreadId = null; + #[Validate(['nullable', 'string', 'max:255'])] + public ?string $telegramNotificationsRestartLimitReachedThreadId = null; + #[Validate(['nullable', 'string'])] public ?string $telegramNotificationsBackupSuccessThreadId = null; @@ -128,11 +134,10 @@ class Telegram extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_chat_id = $this->telegramChatId; @@ -140,6 +145,7 @@ class Telegram extends Component $this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications; $this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications; $this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications; + $this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications; $this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications; $this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications; $this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications; @@ -155,6 +161,7 @@ class Telegram extends Component $this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId; $this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId; $this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId; + $this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId; $this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId; $this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId; $this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId; @@ -173,6 +180,21 @@ class Telegram extends Component if (auth()->user()->can('update', $this->settings)) { $this->telegramToken = $this->settings->telegram_token; $this->telegramChatId = $this->settings->telegram_chat_id; + $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; + $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; + $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; + $this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id; + $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; + $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; + $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; + $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; + $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; + $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; + $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; + $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; + $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; + $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; + $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } else { $this->telegramToken = null; $this->telegramChatId = null; @@ -181,6 +203,7 @@ class Telegram extends Component $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; $this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications; + $this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications; $this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications; $this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications; $this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications; @@ -193,26 +216,13 @@ class Telegram extends Component $this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications; $this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications; - $this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id; - $this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id; - $this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id; - $this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id; - $this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id; - $this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id; - $this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id; - $this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id; - $this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id; - $this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id; - $this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id; - $this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id; - $this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id; - $this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id; } } public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -225,6 +235,7 @@ class Telegram extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -282,6 +293,8 @@ class Telegram extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); $this->dispatch('success', 'Settings saved.'); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index ee07694767..fb537fc7d9 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -34,6 +34,9 @@ class Webhook extends Component #[Validate(['boolean'])] public bool $statusChangeWebhookNotifications = false; + #[Validate(['boolean'])] + public bool $restartLimitReachedWebhookNotifications = true; + #[Validate(['boolean'])] public bool $backupSuccessWebhookNotifications = false; @@ -79,17 +82,17 @@ class Webhook extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - $this->authorize('update', $this->settings); $this->settings->webhook_enabled = $this->webhookEnabled; $this->settings->webhook_url = $this->webhookUrl; $this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications; $this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications; $this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications; + $this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications; $this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications; $this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications; $this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications; @@ -113,6 +116,7 @@ class Webhook extends Component $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; $this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications; + $this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications; $this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications; $this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications; $this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications; @@ -171,6 +175,7 @@ class Webhook extends Component public function instantSave() { try { + $this->authorize('update', $this->settings); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -181,6 +186,7 @@ class Webhook extends Component { try { $this->resetErrorBag(); + $this->authorize('update', $this->settings); $this->syncData(true); $this->saveModel(); } catch (\Throwable $e) { @@ -190,6 +196,8 @@ class Webhook extends Component public function saveModel() { + $this->authorize('update', $this->settings); + $this->syncData(true); refreshSession(); diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index ae5d9b3ecd..69f27b0e55 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -47,7 +47,7 @@ class Index extends Component $avatarStorage->store(Auth::user(), $this->avatar); $this->reset('avatar'); - $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); + $this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh())); $this->dispatch('success', 'Profile picture updated.'); return true; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index bf84f385dd..45e284c5dc 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -99,7 +99,7 @@ class Advanced extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index b60f543ba5..3abc2da73c 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -104,7 +104,6 @@ class DeploymentNavbar extends Component $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); - try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 45a76a4c33..9f1e7fc176 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2,14 +2,18 @@ namespace App\Livewire\Project\Application; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Application; use App\Models\Server; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; class Domains extends Component @@ -54,7 +58,7 @@ class Domains extends Component public ?string $editingService = null; - /** @var array */ + /** @var array */ public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ @@ -67,6 +71,14 @@ class Domains extends Component public bool $showDomainConflictModal = false; + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + public bool $forceSaveDomains = false; public bool $forceSaveDns = false; @@ -141,6 +153,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->application); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(string $domain, string|bool $indexing): void { $this->authorize('update', $this->application); @@ -449,40 +494,127 @@ class Domains extends Component /** * @param array $stored - * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool} + * @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool} */ protected function domainRowFromStored(string $url, ?string $service, array $stored): array { $key = $this->domainDnsStatusKey($url, $service); $entry = $stored[$key] ?? null; + $port = $this->effectiveDomainInternalPort($url); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'url' => $url, - 'service' => $service, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'url' => $url, 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->application->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + protected function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + protected function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->application->portRequiresConfirmation($port); + } + + protected function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; } /** @@ -533,6 +665,8 @@ class Domains extends Component || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $reason = ! $this->dnsValidationEnabled @@ -548,7 +682,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -575,45 +713,50 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; + } + + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + + // Keep suggested-row copy short after DNS checks (no role badge). + if ($this->domainRows[$index]['is_suggested'] ?? false) { + $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); + $serviceName = $this->domainRows[$index]['service'] ?? null; + $meta = $this->suggestedDomainMeta( + $isWww, + $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) + ); + $this->domainRows[$index]['dns_message'] = $meta['pending_message']; + $this->domainRows[$index]['suggestion_label'] = null; + $this->domainRows[$index]['suggestion_role'] = $meta['role']; } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; - } - // Keep suggested-row copy short after DNS checks (no role badge). - if ($this->domainRows[$index]['is_suggested'] ?? false) { - $isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); - $serviceName = $this->domainRows[$index]['service'] ?? null; - $meta = $this->suggestedDomainMeta( - $isWww, - $this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null) - ); - $this->domainRows[$index]['dns_message'] = $meta['pending_message']; - $this->domainRows[$index]['suggestion_label'] = null; - $this->domainRows[$index]['suggestion_role'] = $meta['role']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; } - - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); } /** @@ -647,11 +790,34 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } + DB::transaction(function () use (&$statuses): void { + $application = Application::query()->lockForUpdate()->findOrFail($this->application->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; - $this->application->save(); } protected function pruneDomainDnsStatusesToCurrentDomains(): void @@ -752,6 +918,31 @@ class Domains extends Component $this->addDomain(); } + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + /** * Clear pending conflict state when the modal is dismissed without confirmation. * confirmDomainUsage sets forceSaveDomains before closing the modal. @@ -776,7 +967,7 @@ class Domains extends Component return; } - if ($this->newDomainPartsChanged) { + if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) { $this->newDomain = DomainUrlParts::compose(...$this->newDomainParts); } $this->validateOnly('newDomain'); @@ -795,23 +986,22 @@ class Domains extends Component ->values() ->all(); $current = $this->currentDomainList($this->newDomainService); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured."); return; } } - if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); - return; - } + return; } $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); @@ -822,17 +1012,114 @@ class Domains extends Component $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $serviceForCheck = $this->newDomainService; $this->resetAddDomainForm(); $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck)) + ->map(fn (string $url, string $statusKey) => [ + 'status_key' => $statusKey, + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + $this->persistDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $this->application, + $dnsCheck['status_key'], + $dnsCheck['url'], + $this->application->destination?->server, + $this->serverIp, + $dnsCheck['check_id'], + $this->application->additional_servers->count() > 0, + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $service, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ($service !== null && ($row['service'] ?? null) !== $service) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + + /** + * @param array $urls + * @return array + */ + protected function dnsEntriesForUrls(array $urls, ?string $service = null): array + { + $entries = []; + + foreach ($urls as $url) { + $entries[$this->domainDnsStatusKey($url, $service)] = $url; + } + + return $entries; + } + /** * Run a first-time DNS check for newly added/updated domain URLs and persist results. * @@ -849,6 +1136,7 @@ class Domains extends Component $skipDns = ! $this->dnsValidationEnabled || ! $server || $this->application->additional_servers->count() > 0; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; @@ -875,7 +1163,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistDomainDnsStatuses(); @@ -909,15 +1201,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } @@ -951,6 +1239,11 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingService = $this->domainRows[$index]['service']; $this->resetEditDomainDnsGate(); @@ -1068,7 +1361,7 @@ class Domains extends Component return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1085,13 +1378,29 @@ class Domains extends Component $service = $this->editingService; $wasNoindexed = $this->application->isDomainNoindexed($oldUrl); + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $this->application->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $this->application->domain_port_overrides = $portOverrides ?: null; + } + $current = $this->currentDomainList($service); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured."); return; } + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) { $dnsFailure = $this->findDnsFailureMessage([$newUrl]); if ($dnsFailure !== null) { @@ -1119,6 +1428,7 @@ class Domains extends Component $this->forceSaveDomains = false; $this->pendingAction = null; + $this->forceUseUnknownPort = false; $this->cancelEdit(); $this->dispatch('edit-domain-saved'); $this->dispatch('success', 'Domain updated.'); @@ -1164,6 +1474,28 @@ class Domains extends Component } } + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + /** + * @param array{url: string, service?: ?string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.($row['service'] ?? '')); + } + public function generateDomain(?string $serviceName = null): void { try { @@ -1642,6 +1974,8 @@ class Domains extends Component } } + $intendedComposeOverrides = null; + if ($this->isCompose) { if (blank($serviceName)) { $this->dispatch('error', 'A service is required for compose domains.'); @@ -1657,6 +1991,15 @@ class Domains extends Component $allDomains = []; } + $previousServiceUrls = $this->currentDomainList($serviceName); + $normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides); + $domainString = $normalizedPorts['fqdn']; + $intendedComposeOverrides = $this->mergeComposeDomainPortOverrides( + $previousServiceUrls, + $domainString, + $normalizedPorts['overrides'] ?? null, + ); + $existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : []; // Preserve stored redirect only — pending Direction dropdown values must not // persist until setServiceRedirect() runs. @@ -1665,6 +2008,7 @@ class Domains extends Component ]); $this->application->docker_compose_domains = json_encode($allDomains); + $this->application->domain_port_overrides = $intendedComposeOverrides; $this->application->fqdn = null; } else { $this->application->fqdn = $domainString; @@ -1691,12 +2035,47 @@ class Domains extends Component } $this->application->save(); + + if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) { + $this->application->domain_port_overrides = $intendedComposeOverrides; + $this->application->save(); + } + $this->resetDefaultLabels(); $this->dispatch('configurationChanged'); return true; } + /** + * @param Collection $previousServiceUrls + * @param array|null $incomingOverrides + * @return array|null + */ + protected function mergeComposeDomainPortOverrides( + Collection $previousServiceUrls, + ?string $newDomainString, + ?array $incomingOverrides, + ): ?array { + $merged = $this->application->domain_port_overrides ?? []; + $newCanonical = collect($this->splitDomains($newDomainString)) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->all(); + + foreach ($previousServiceUrls as $url) { + $canonical = DomainPortOverrides::withoutPort($url); + if (! in_array($canonical, $newCanonical, true)) { + unset($merged[$canonical]); + } + } + + foreach ($incomingOverrides ?? [] as $url => $port) { + $merged[$url] = (int) $port; + } + + return $merged ?: null; + } + protected function resetDefaultLabels(): void { try { diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 34283cd47f..8f0bb2385d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; use App\Jobs\ApplicationDeploymentJob; +use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; @@ -320,17 +321,6 @@ class General extends Component } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; - if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - // Only load compose file if user has update permission - try { - $this->authorize('update', $this->application); - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); - } catch (AuthorizationException $e) { - // User doesn't have update permission, skip loading compose file - } - } - if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } @@ -340,7 +330,7 @@ class General extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -530,7 +520,7 @@ class General extends Component $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); $this->dispatch('refreshEnvs'); } catch (\Throwable $e) { // Refresh model to get restored values from Application::loadComposeFile @@ -607,14 +597,9 @@ class General extends Component $this->resetDefaultLabels(false); } if ($this->buildPack === 'dockercompose') { - // Only update if user has permission - try { - $this->authorize('update', $this->application); - $this->fqdn = null; - $this->application->fqdn = null; - $this->application->settings->save(); - } catch (AuthorizationException $e) { - // User doesn't have update permission, just continue without saving + if (blank($this->dockerComposeLocation)) { + $this->dockerComposeLocation = '/docker-compose.yaml'; + $this->application->docker_compose_location = $this->dockerComposeLocation; } } if ($this->buildPack === 'static') { @@ -666,6 +651,8 @@ class General extends Component public function resetDefaultLabels($manualReset = false) { + $this->authorize('update', $this->application); + try { if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) { return; diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 6c75cd7a61..830a4eace8 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -156,6 +156,11 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); StopApplication::dispatch($this->application, false, $this->docker_cleanup); + auditLog('ui.application.stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php new file mode 100644 index 0000000000..21296978f7 --- /dev/null +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -0,0 +1,609 @@ + 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?string $newDomainService = null; + + public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; + + public ?int $editingIndex = null; + + public bool $showPortWarningModal = false; + + public bool $forceUseUnknownPort = false; + + public ?int $unrecognizedPort = null; + + public ?string $pendingPortAction = null; + + public function mount(): void + { + $this->refreshDomains(); + if ($this->preview->application->build_pack === 'dockercompose') { + $this->newDomainService = $this->composeServices()[0] ?? null; + } + } + + public function render() + { + return view('livewire.project.application.preview-domains', [ + 'isCompose' => $this->preview->application->build_pack === 'dockercompose', + 'composeServices' => $this->composeServices(), + ]); + } + + public function addDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->preview->application->build_pack === 'dockercompose' + && ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) { + $this->addError('newDomainService', 'Select a valid Compose service.'); + + return; + } + $domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host'); + if ($domain === null) { + return; + } + $canonicalDomain = DomainPortOverrides::withoutPort($domain); + if (collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain + && $row['service'] === $this->newDomainService + )) { + $this->addError('newDomainParts.host', 'This domain is already configured.'); + + return; + } + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); + + return; + } + $this->domainRows[] = $this->makeRow($domain, $this->newDomainService); + $index = array_key_last($this->domainRows); + $checkId = new_public_id(); + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + if (! $this->persistDomains()) { + return; + } + $domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain); + $this->newDomainParts = DomainUrlParts::empty(); + $this->newDomainService = $this->preview->application->build_pack === 'dockercompose' + ? ($this->composeServices()[0] ?? null) + : null; + $this->forceUseUnknownPort = false; + $this->dispatch('close-modal'); + + try { + $server = $this->preview->application->destination?->server; + CheckDomainDnsJob::dispatch( + $this->preview, + $this->statusKey($domain, $this->domainRows[$index]['service']), + $domain, + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $checkId, + $this->preview->application->additional_servers->count() > 0, + ); + $this->dispatch('success', 'Domain added. DNS check started.'); + } catch (\Throwable) { + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['check_id'] = null; + $this->persistDnsStatuses(); + $this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.'); + } + } + + public function generateDomain(): void + { + $this->authorize('update', $this->preview->application); + $this->preview->refresh(); + if ($this->preview->application->build_pack === 'dockercompose') { + if ($this->newDomainService === null && $this->domainRows === []) { + $this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true); + } else { + $service = $this->newDomainService ?? data_get($this->domainRows, '0.service'); + foreach ($this->generateComposeDomains((string) $service) as $domain) { + $alreadyExists = collect($this->domainRows)->contains( + fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain) + && $row['service'] === $service + ); + if (! $alreadyExists) { + $this->domainRows[] = $this->makeRow($domain, $service); + } + } + + if (! $this->persistDomains()) { + return; + } + } + } else { + $this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true); + } + $this->refreshDomains(); + $this->dispatch('success', 'Domain generated.'); + } + + public function startEdit(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $this->editingIndex = $index; + $this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']); + $canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']); + $savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } + $this->dispatch('open-preview-domain-edit'); + } + + public function updateDomain(): void + { + $this->authorize('update', $this->preview->application); + if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) { + return; + } + $domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host'); + if ($domain === null) { + return; + } + $oldUrl = $this->domainRows[$this->editingIndex]['url']; + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); + + return; + } + if (blank(DomainUrlParts::split($domain)['port'] ?? null)) { + $portOverrides = $this->preview->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($domain)]); + $this->preview->domain_port_overrides = $portOverrides ?: null; + } + $this->domainRows[$this->editingIndex]['url'] = $domain; + $this->domainRows[$this->editingIndex]['dns_status'] = 'pending'; + $this->domainRows[$this->editingIndex]['dns_message'] = 'DNS has not been checked yet.'; + $index = $this->editingIndex; + $this->editingIndex = null; + if (! $this->persistDomains()) { + return; + } + $this->forceUseUnknownPort = false; + $this->dispatch('close-preview-domain-edit'); + $this->dispatch('success', 'Domain updated.'); + $this->checkDomainDns($index); + } + + public function confirmUseUnknownPort(): void + { + $this->authorize('update', $this->preview->application); + $this->forceUseUnknownPort = true; + $this->showPortWarningModal = false; + $action = $this->pendingPortAction; + $this->pendingPortAction = null; + + if ($action === 'update') { + $this->updateDomain(); + + return; + } + + $this->addDomain(); + } + + public function cancelUseUnknownPort(): void + { + $this->showPortWarningModal = false; + $this->forceUseUnknownPort = false; + $this->unrecognizedPort = null; + $this->pendingPortAction = null; + } + + public function removeDomain(int $index): void + { + $this->authorize('update', $this->preview->application); + if (! isset($this->domainRows[$index])) { + return; + } + unset($this->domainRows[$index]); + $this->domainRows = array_values($this->domainRows); + if (! $this->persistDomains()) { + return; + } + $this->dispatch('success', 'Domain removed.'); + } + + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service'])) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + public function checkAllDns(): void + { + $this->authorize('update', $this->preview->application); + foreach (array_keys($this->domainRows) as $index) { + $this->applyDnsCheck($index); + } + $this->persistDnsStatuses(); + } + + public function checkDomainDns(int $index): void + { + $this->authorize('update', $this->preview->application); + $this->applyDnsCheck($index); + $this->persistDnsStatuses(); + } + + public function pollDnsChecks(): void + { + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && ($row['service'] ?? null) === ($checkingRow['service'] ?? null)); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + private function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + + private function applyDnsCheck(int $index): void + { + if (! isset($this->domainRows[$index])) { + return; + } + $result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index); + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + } + + private function checkUrlDns(string $url, string $key = 'domain'): array + { + $server = $this->preview->application->destination?->server; + + return CheckDomainDns::run( + [$key => $url], + $server, + $server ? serverDnsTargetIp($server) ?? $server->ip : null, + $this->preview->application->additional_servers->count() > 0, + )[$key]; + } + + private function refreshDomains(): void + { + $this->preview->refresh(); + $statuses = $this->preview->domain_dns_statuses ?? []; + $rows = []; + if ($this->preview->application->build_pack === 'dockercompose') { + foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) { + foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) { + $rows[] = $this->makeRow($url, (string) $service, $statuses); + } + } + } else { + foreach ($this->splitDomains($this->preview->fqdn) as $url) { + $rows[] = $this->makeRow($url, null, $statuses); + } + } + $this->domainRows = $rows; + } + + private function persistDomains(): bool + { + if ($this->preview->application->build_pack === 'dockercompose') { + try { + $composeServices = $this->composeServices(failOnError: true); + } catch (\Throwable) { + $this->refreshDomains(); + $this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.'); + + return false; + } + $domains = collect($composeServices) + ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) + ->all(); + $validRows = collect($this->domainRows) + ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); + foreach ($validRows->groupBy('service') as $service => $rows) { + $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + } + $this->preview->docker_compose_domains = json_encode($domains); + $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; + } else { + $this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null; + } + $normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides); + $this->preview->fqdn = $normalized['fqdn']; + $this->preview->domain_port_overrides = $normalized['overrides']; + if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) { + foreach ($domains as $service => $entry) { + $serviceDomains = $this->splitDomains(composeDomainEntryString($entry)); + $domains[$service]['domain'] = collect($serviceDomains) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)) + ->implode(','); + } + $this->preview->docker_compose_domains = json_encode($domains); + } + foreach ($this->domainRows as $index => $row) { + $this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']); + } + $this->preview->save(); + $this->persistDnsStatuses(); + $this->refreshDomains(); + $this->dispatch('update_links'); + $this->dispatch('previewDomainsChanged'); + + return true; + } + + private function persistDnsStatuses(): void + { + $statuses = []; + foreach ($this->domainRows as $row) { + $statuses[$this->statusKey($row['url'], $row['service'])] = [ + 'status' => $row['dns_status'], + 'message' => $row['dns_message'], + 'check_id' => $row['check_id'] ?? null, + ]; + } + + DB::transaction(function () use (&$statuses): void { + $preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id); + $storedStatuses = $preview->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $storedStatus = $storedStatuses[$key] ?? null; + if (! is_array($storedStatus)) { + continue; + } + + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatus['check_id'] ?? null; + + if (($storedCheckId !== null && $localCheckId !== $storedCheckId) + || ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) { + $statuses[$key] = $storedStatus; + } + } + + $preview->domain_dns_statuses = $statuses ?: null; + $preview->save(); + }); + + $this->preview->domain_dns_statuses = $statuses ?: null; + } + + private function validatedDomain(array $parts, string $errorKey): ?string + { + $domain = DomainUrlParts::compose(...$parts); + $validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]); + if ($validator->fails()) { + $this->addError($errorKey, $validator->errors()->first('domain')); + + return null; + } + + return ValidationPatterns::normalizeApplicationDomains($domain); + } + + private function makeRow(string $url, ?string $service, array $statuses = []): array + { + $status = $statuses[$this->statusKey($url, $service)] ?? []; + $port = $this->effectiveDomainInternalPort($url); + + return [ + 'url' => $url, + 'service' => $service, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], + 'dns_status' => $status['status'] ?? 'pending', + 'dns_message' => $status['message'] ?? 'DNS has not been checked yet.', + 'check_id' => $status['check_id'] ?? null, + ]; + } + + /** + * @param array{scheme: string, host: string, port: string, path: string} $parts + */ + private function portFromParts(array $parts): ?int + { + $port = trim((string) ($parts['port'] ?? '')); + if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) { + return null; + } + + return (int) $port; + } + + private function currentRowPort(string $url): ?int + { + $canonical = DomainPortOverrides::withoutPort($url); + $override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($override) && (int) $override > 0) { + return (int) $override; + } + + $legacy = DomainUrlParts::split($url)['port'] ?? ''; + + return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; + } + + private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + { + if ($this->forceUseUnknownPort || $port === null) { + return false; + } + if ($currentPort !== null && $port === $currentPort) { + return false; + } + + return $this->preview->application->portRequiresConfirmation($port); + } + + private function openPortWarning(?int $port, string $action): void + { + $this->unrecognizedPort = $port; + $this->pendingPortAction = $action; + $this->showPortWarningModal = true; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + private function effectiveDomainInternalPort(string $url): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $this->preview->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + $hasMapEntry = array_key_exists($canonical, $overrides); + + if ($hasMapEntry) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + + $exposed = $this->preview->application->ports_exposes_array; + $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 + ? (int) $exposed[0] + : null; + + return [ + 'internal_port' => $defaultPort, + 'has_port_override' => false, + ]; + } + + private function statusKey(string $url, ?string $service): string + { + return hash('sha256', $url.'|'.($service ?? '')); + } + + private function splitDomains(?string $domains): array + { + return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all(); + } + + private function generateComposeDomains(string $service): array + { + $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; + $domainString = getComposeServiceDomainString($applicationDomains, $service); + + if (empty($domainString)) { + $domainString = generateUrl( + server: $this->preview->application->destination->server, + random: str($service)->slug().'-'.$this->preview->application->uuid, + ); + } + + return collect($this->splitDomains($domainString))->map(function (string $domain): string { + $generated = $this->preview->generatedPreviewDomain($domain); + if (filled($generated['port'])) { + $overrides = $this->preview->domain_port_overrides ?? []; + $overrides[$generated['url']] = $generated['port']; + $this->preview->domain_port_overrides = $overrides; + } + + return $generated['url']; + })->all(); + } + + private function composeServices(bool $failOnError = false): array + { + try { + $parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); + $services = data_get($parsedCompose, 'services', []); + if (! is_iterable($services)) { + return []; + } + + $previewSuffix = '-pr-'.$this->preview->pull_request_id; + $serviceNames = []; + foreach ($services as $serviceName => $service) { + if (isDatabaseImage(data_get($service, 'image'))) { + continue; + } + + $serviceName = (string) $serviceName; + if (str_ends_with($serviceName, $previewSuffix)) { + $serviceName = substr($serviceName, 0, -strlen($previewSuffix)); + } + $serviceNames[] = $serviceName; + } + + return array_values(array_unique($serviceNames)); + } catch (\Throwable $exception) { + if ($failOnError) { + throw $exception; + } + + return []; + } + } +} diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index e07a985b40..14d9bcdb8d 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -7,7 +7,6 @@ use App\Events\ServiceStatusChanged; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; -use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; @@ -16,6 +15,8 @@ class Previews extends Component { use AuthorizesRequests; + protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains']; + public Application $application; public string $deployment_uuid; @@ -26,16 +27,6 @@ class Previews extends Component public int $rate_limit_remaining; - public $domainConflicts = []; - - public $showDomainConflictModal = false; - - public $forceSaveDomains = false; - - public $pendingPreviewId = null; - - public array $previewFqdns = []; - public array $previewDockerTags = []; public ?int $manualPullRequestId = null; @@ -43,7 +34,6 @@ class Previews extends Component public ?string $manualDockerTag = null; protected $rules = [ - 'previewFqdns.*' => 'string|nullable', 'previewDockerTags.*' => 'string|nullable', 'manualPullRequestId' => 'integer|min:1|nullable', 'manualDockerTag' => 'string|nullable', @@ -53,31 +43,23 @@ class Previews extends Component { $this->pull_requests = collect(); $this->parameters = get_route_parameters(); - $this->syncData(false); + $this->syncDockerTags(); } - private function syncData(bool $toModel = false): void + private function syncDockerTags(): void { - if ($toModel) { - foreach ($this->previewFqdns as $key => $fqdn) { - $preview = $this->application->previews->get($key); - if ($preview) { - $preview->fqdn = $fqdn; - if ($this->application->build_pack === 'dockerimage') { - $preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null; - } - } - } - } else { - $this->previewFqdns = []; - $this->previewDockerTags = []; - foreach ($this->application->previews as $key => $preview) { - $this->previewFqdns[$key] = $preview->fqdn; - $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; - } + $this->previewDockerTags = []; + foreach ($this->application->previews as $key => $preview) { + $this->previewDockerTags[$key] = $preview->docker_registry_image_tag; } } + public function refreshPreviewDomains(): void + { + $this->application->refresh(); + $this->syncDockerTags(); + } + public function load_prs() { try { @@ -92,103 +74,28 @@ class Previews extends Component } } - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - if ($this->pendingPreviewId) { - $this->save_preview($this->pendingPreviewId); - $this->pendingPreviewId = null; - } - } - public function save_preview($preview_id) { try { $this->authorize('update', $this->application); - $success = true; $preview = $this->application->previews->find($preview_id); if (! $preview) { throw new \Exception('Preview not found'); } - // Find the key for this preview in the collection $previewKey = $this->application->previews->search(function ($item) use ($preview_id) { return $item->id == $preview_id; }); - if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { - $this->validate([ - "previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(), - ]); - - $fqdn = $this->previewFqdns[$previewKey]; - - if (! empty($fqdn)) { - $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn); - $this->previewFqdns[$previewKey] = $fqdn; - - if (! validateDNSEntry($fqdn, $this->application->destination->server)) { - $server = $this->application->destination->server; - $target = serverDnsTargetIp($server) ?? $server->ip; - $guidance = dnsMismatchGuidanceMessage($target, $target); - $this->dispatch('error', 'Validating DNS failed.', "{$guidance}

Check this documentation for further help."); - $success = false; - } - - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application, domain: $fqdn); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - $this->pendingPreviewId = $preview_id; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - } + if ($previewKey === false) { + throw new \Exception('Preview not found'); } - if ($success) { - $this->syncData(true); - $preview->save(); - $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); - } - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate_preview($preview_id) - { - try { - $this->authorize('update', $this->application); - - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); - - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('success', 'Domain generated.'); - - return; - } - - $preview->generate_preview_fqdn(); - $this->application->refresh(); - $this->syncData(false); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); + $this->validateOnly("previewDockerTags.{$previewKey}"); + $preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null; + $preview->save(); + $this->dispatch('success', 'Preview saved.

Do not forget to redeploy the preview to apply the changes.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -211,7 +118,7 @@ class Previews extends Component } $found->generate_preview_fqdn_compose(); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); } else { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -227,9 +134,9 @@ class Previews extends Component $found->docker_registry_image_tag = $docker_registry_image_tag; $found->save(); } - $found->generate_preview_fqdn(); + $found->generate_preview_fqdn(generateWithoutApplicationDomain: true); $this->application->refresh(); - $this->syncData(false); + $this->syncDockerTags(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview added.'); } @@ -377,6 +284,12 @@ class Previews extends Component ApplicationPreview::where('application_id', $this->application->id) ->where('pull_request_id', $pull_request_id) ->update(['status' => 'exited']); + auditLog('ui.application.preview_stopped', [ + 'team_id' => $this->application->team()?->id, + 'application_uuid' => $this->application->uuid, + 'application_name' => $this->application->name, + 'pull_request_id' => $pull_request_id, + ]); ServiceStatusChanged::dispatch($this->application->environment->project->team->id); GetContainersStatus::run($server); diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php deleted file mode 100644 index 0fdcf46153..0000000000 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ /dev/null @@ -1,165 +0,0 @@ -domain = data_get($this->service, 'domain'); - } - - public function render() - { - return view('livewire.project.application.previews-compose'); - } - - public function save() - { - try { - $this->authorize('update', $this->preview->application); - $this->validate([ - 'domain' => ValidationPatterns::applicationDomainRules(), - ]); - - $this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain); - $this->persistPreviewDomain($this->domain); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function generate() - { - try { - $this->authorize('update', $this->preview->application); - - $applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []; - $domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName); - - // If no domain is set in the main application, generate a default domain - if (empty($domain_string)) { - $server = $this->preview->application->destination->server; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - // Generate a unique domain like main app services do - $generated_fqdn = generateUrl(server: $server, random: $random); - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; - } else { - foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) { - throw new \InvalidArgumentException($error); - } - - // Use the existing domain from the main application - // Handle multiple domains separated by commas - $domain_list = ValidationPatterns::applicationDomainList($domain_string); - $preview_fqdns = []; - $template = $this->preview->application->preview_url_template; - $random = new_public_id(); - - foreach ($domain_list as $single_domain) { - $single_domain = trim($single_domain); - if (empty($single_domain)) { - continue; - } - - $url = Url::fromString($single_domain); - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; - } - - $preview_fqdn = implode(',', $preview_fqdns); - } - - $this->domain = $preview_fqdn; - $this->persistPreviewDomain($this->domain); - - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function persistPreviewDomain(?string $domain): void - { - $docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []; - $serviceNames = $this->previewServiceNames($docker_compose_domains); - $storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames) - ?? (string) $this->serviceName; - - $docker_compose_domains = putComposeServiceDomain( - $docker_compose_domains, - $storageKey, - $domain, - $serviceNames, - ); - $docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames); - - $this->serviceName = $storageKey; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - } - - /** - * @param array $previewDomains - * @return list - */ - private function previewServiceNames(array $previewDomains): array - { - $parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id); - $fromCompose = collect(data_get($parsedServices, 'services', [])) - ->keys() - ->map(function ($serviceName) { - return str((string) $serviceName) - ->replaceLast('-pr-'.$this->preview->pull_request_id, '') - ->toString(); - }) - ->all(); - - $domainKeys = collect(array_keys($previewDomains)) - ->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [])) - ->map(fn ($name) => (string) $name); - $unmapped = $domainKeys - ->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null) - ->all(); - - return collect($fromCompose) - ->merge(preferredComposeServiceNamesFromDomainKeys( - $fromCompose === [] ? $domainKeys->all() : $unmapped - )) - ->unique() - ->values() - ->all(); - } -} diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 29f798d595..60a7955738 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -65,7 +65,7 @@ class Source extends Component $this->gitCommitSha = trim($this->gitCommitSha); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Application/Swarm.php b/app/Livewire/Project/Application/Swarm.php index 661578fb3d..ac867e69aa 100644 --- a/app/Livewire/Project/Application/Swarm.php +++ b/app/Livewire/Project/Application/Swarm.php @@ -31,7 +31,7 @@ class Swarm extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fff2b7fbf5..ad032779b3 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -102,6 +102,14 @@ class CloneMe extends Component if (! $selectedDestination) { throw new \Exception('Destination not found.'); } + auditLog('ui.project.clone_started', [ + 'team_id' => $this->project->team_id, + 'project_uuid' => $this->project->uuid, + 'project_name' => $this->project->name, + 'clone_type' => $type, + 'new_name' => $this->newName, + 'destination_uuid' => $selectedDestination->uuid, + ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); if ($foundProject) { diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 2c04f5ba9b..1d223b6967 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -128,7 +128,7 @@ class BackupEdit extends Component $this->status = $database->status; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->backup->enabled = $this->backupEnabled; @@ -207,19 +207,27 @@ class BackupEdit extends Component } } + $database = $this->backup->database; + $backupUuid = $this->backup->uuid; $this->backup->delete(); + auditLog('ui.database.backup_schedule_deleted', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $backupUuid, + ]); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; + if ($database->getMorphClass() === ServiceDatabase::class) { + $serviceDatabase = $database; - return redirect()->route('project.service.database.backups', [ + return redirectRoute($this, 'project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'service_uuid' => $serviceDatabase->service->uuid, 'stack_service_uuid' => $serviceDatabase->uuid, ]); } else { - return redirect()->route('project.database.backup.index', [ + return redirectRoute($this, 'project.database.backup.index', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'database_uuid' => $this->parameters['database_uuid'], @@ -238,9 +246,14 @@ class BackupEdit extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); - $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); if ($database instanceof ServiceDatabase) { return redirect()->route('project.service.database.backup.executions', [ diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 73877a945e..2786a45c3d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class BackupExecutions extends Component @@ -37,12 +36,12 @@ class BackupExecutions extends Component public $delete_backup_sftp = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); + $teamId = currentTeam()->id; return [ - "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', + "echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions', ]; } diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..e45c797d1e 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -18,6 +18,13 @@ class BackupNow extends Component $this->authorize('manageBackups', $this->backup->database); DatabaseBackupJob::dispatch($this->backup); + $database = $this->backup->database; + auditLog('ui.database.backup_started', [ + 'team_id' => $database->team()?->id, + 'database_uuid' => $database->uuid, + 'database_name' => $database->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ad5e45b3fe..1d8354a4fb 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -121,7 +121,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -199,6 +199,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 2f5b844845..a8bde2f007 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -115,7 +115,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -191,6 +191,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 943f227021..993200b578 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -35,6 +35,12 @@ class Heading extends Component public function activityFinished() { + if (auth()->user()->cannot('update', $this->database)) { + $this->dispatch('refresh'); + + return; + } + try { // Only set started_at if database is actually running if ($this->database->isRunning()) { @@ -83,6 +89,7 @@ class Heading extends Component $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); + $this->auditDatabaseAction('ui.database.stopped'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } @@ -94,6 +101,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = RestartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -107,6 +115,7 @@ class Heading extends Component $this->authorize('manage', $this->database); $activity = StartDatabase::run($this->database); + $this->auditDatabaseAction('ui.database.started'); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } catch (\Throwable $e) { @@ -122,4 +131,13 @@ class Heading extends Component ], ]); } + + private function auditDatabaseAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->database->team()?->id, + 'database_uuid' => $this->database->uuid, + 'database_name' => $this->database->name, + ]); + } } diff --git a/app/Livewire/Project/Database/Health.php b/app/Livewire/Project/Database/Health.php index 8943e6316e..07373bdba2 100644 --- a/app/Livewire/Project/Database/Health.php +++ b/app/Livewire/Project/Database/Health.php @@ -34,7 +34,7 @@ class Health extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php index ccd3435106..87c9328eb3 100644 --- a/app/Livewire/Project/Database/ImportForm.php +++ b/app/Livewire/Project/Database/ImportForm.php @@ -510,6 +510,12 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.import_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 'file', + ]); } } catch (\Throwable $e) { handleError($e, $this); @@ -768,6 +774,13 @@ EOD; // Dispatch activity to the monitor and open slide-over $this->dispatch('activityMonitor', $activity->id); $this->dispatch('databaserestore'); + auditLog('ui.database.restore_started', [ + 'team_id' => $this->resource->team()?->id, + 'database_uuid' => $this->resource->uuid, + 'database_name' => $this->resource->name, + 'source' => 's3', + 'storage_id' => $this->s3StorageId, + ]); $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...'); } catch (\Throwable $e) { $this->importRunning = false; @@ -796,6 +809,8 @@ EOD; * * Hardened against bypasses: * - decompresses gzip backups before scanning, + * - converts custom-format (PGDMP) archives to SQL with pg_restore + * before scanning, and rejects archives that cannot be inspected, * - strips `--` line comments and flattens newlines so multi-line and * comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught, * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects. @@ -817,8 +832,30 @@ EOD; $escapedSqlPattern = escapeshellarg($sqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern); $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; + $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; + $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); + $sqlScan = $scan($contents); + $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; + $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; - return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; + return << "\$inspect"; then + {$blockedInspect} + fi + if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then + {$blockedInspect} + fi + if {$customScan}; then + {$blockedProgram} + fi +elif {$sqlScan}; then + {$blockedProgram} +fi +SH; } private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void @@ -883,7 +920,7 @@ EOD; case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { - $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; + $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi"; } else { $restoreCommand .= " {$escapedTmpPath}"; } diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php index 7074c235d5..eba1c4d8f7 100644 --- a/app/Livewire/Project/Database/InitScript.php +++ b/app/Livewire/Project/Database/InitScript.php @@ -22,6 +22,9 @@ class InitScript extends Component #[Locked] public int $index; + #[Locked] + public string $originalFilename; + #[Validate(['nullable', 'string'])] public ?string $filename = null; @@ -33,6 +36,7 @@ class InitScript extends Component try { $this->index = data_get($this->script, 'index'); $this->filename = data_get($this->script, 'filename'); + $this->originalFilename = (string) data_get($this->script, 'filename'); $this->content = data_get($this->script, 'content'); } catch (Exception $e) { return handleError($e, $this); @@ -47,7 +51,7 @@ class InitScript extends Component $this->script['index'] = $this->index; $this->script['content'] = $this->content; $this->script['filename'] = $this->filename; - $this->dispatch('save_init_script', $this->script); + $this->dispatch('save_init_script', $this->script, $this->originalFilename); } catch (Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index b2d9bce91b..0398362bbb 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -118,7 +118,7 @@ class General extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -196,6 +196,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 61280a34b5..4d2dd9d8c2 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -136,7 +136,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index f68ba82c7d..d3545564ee 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -128,7 +128,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -237,6 +237,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 1adfe2ea79..ce7fc01ecd 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -136,7 +136,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -244,6 +244,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 051fb515d9..3d0406956f 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -149,7 +149,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -240,6 +240,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -247,16 +248,16 @@ class General extends Component } } - public function save_init_script($script) + public function save_init_script($script, string $originalFilename) { $this->authorize('update', $this->database); $initScripts = collect($this->initScripts ?? []); $existingScript = $initScripts->firstWhere('filename', $script['filename']); - $oldScript = $initScripts->firstWhere('index', $script['index']); + $oldScript = $initScripts->firstWhere('filename', $originalFilename); - if ($existingScript && $existingScript['index'] !== $script['index']) { + if ($existingScript && $script['filename'] !== $originalFilename) { $this->dispatch('error', 'A script with this filename already exists.'); return; @@ -285,11 +286,10 @@ class General extends Component } } - $index = $initScripts->search(function ($item) use ($script) { - return $item['index'] === $script['index']; - }); + $index = $initScripts->search(fn ($item) => $item['filename'] === $originalFilename); if ($index !== false) { + $script['index'] = $oldScript['index']; $initScripts[$index] = $script; } else { $initScripts->push($script); diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index d431b15064..7c6313c8da 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -127,7 +127,7 @@ class General extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -235,6 +235,7 @@ class General extends Component } $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { + $this->authorize('update', $this->database); $this->isPublic = ! $this->isPublic; $this->syncData(true); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 91b0444f51..0d42c71e94 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -77,7 +77,7 @@ class Edit extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index 9b9a3670db..35db3167d3 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -48,7 +48,7 @@ class EnvironmentEdit extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 2b472a1a20..f43b7542e6 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -53,10 +53,7 @@ class Index extends Component 'uuid' => $project->uuid, 'name' => $project->name, 'description' => $project->description, - 'iconUrl' => $project->icon_path ? route('project.icon', [ - 'project_uuid' => $project->uuid, - 'v' => $project->updated_at->timestamp, - ]) : null, + 'iconUrl' => $project->icon_path ? project_icon_url($project) : null, 'href' => $project->navigateTo(), 'environmentCount' => $project->environments->count(), 'resourceCount' => $resourceCount, diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4cffff8001..ba03041e2b 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -111,38 +111,14 @@ class Select extends Component $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { - $default_logo = 'svgs/default.webp'; - $logo = data_get($service, 'logo'); - - if (is_string($logo) && str_starts_with($logo, 'svg/')) { - $normalizedLogo = 'svgs/'.str($logo)->after('svg/'); - if (file_exists(public_path($normalizedLogo))) { - $logo = $normalizedLogo; - } - } - - $hasLogo = is_string($logo) - && basename($logo) !== basename($default_logo) - && file_exists(public_path($logo)); - - if (! $hasLogo) { - $logo = $default_logo; - } - - $local_logo_path = public_path($logo); $serviceKey = (string) $key; return [ 'id' => $serviceKey, 'name' => str($serviceKey)->headline(), 'docsSlug' => str($serviceKey)->lower()->value(), - 'has_logo' => $hasLogo, - 'logo' => asset($logo), - 'logo_github_url' => file_exists($local_logo_path) - ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo - : asset($default_logo), 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null, - ] + (array) $service; + ] + service_logo_urls(data_get($service, 'logo')) + (array) $service; })->all(); // Extract unique categories from services diff --git a/app/Livewire/Project/Resource/Index.php b/app/Livewire/Project/Resource/Index.php index 93633246a8..7c375d0e03 100644 --- a/app/Livewire/Project/Resource/Index.php +++ b/app/Livewire/Project/Resource/Index.php @@ -187,6 +187,11 @@ class Index extends Component 'fqdn' => $item->fqdn ?? null, 'description' => $item->description ?? null, 'status' => $item->status ?? '', + 'restartLimitReached' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit(), + 'restartCount' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit() + ? max($item->restart_count ?? 0, $item->max_restart_count ?? 0) + : ($item->restart_count ?? 0), + 'maxRestartCount' => $item->max_restart_count ?? 0, 'server_status' => $item->server_status ?? null, 'hrefLink' => $item->hrefLink ?? '', 'destination' => [ diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php new file mode 100644 index 0000000000..87f24fb1da --- /dev/null +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -0,0 +1,124 @@ +id; + + return [ + 'modalClosed' => 'closeExecutionModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(Service $service): void + { + abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404); + $this->service = $service; + $this->authorize('view', $this->service); + } + + public function openExecution(string $executionUuid): void + { + $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); + abort_unless($this->selectedExecution, 404); + $this->executionModalOpen = true; + } + + public function closeExecutionModal(): void + { + $this->executionModalOpen = false; + $this->selectedExecution = null; + } + + public function render(): View + { + return view('livewire.project.service.backup-executions', [ + 'executions' => $this->executions(), + ]); + } + + private function executions(): Collection + { + $databaseScheduleIds = ScheduledDatabaseBackup::query() + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->pluck('id'); + + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with('scheduledDatabaseBackup.database') + ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) + ->latest() + ->limit(100) + ->get() + ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ + 'id' => 'database:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, + 'type' => 'Database', + 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.backup', $execution->id) + : null, + ]); + + $volumeSchedules = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->forService($this->service) + ->get() + ->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) + ->latest() + ->limit(100) + ->get() + ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { + $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); + + return [ + 'id' => 'storage:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $schedule->targetName(), + 'type' => $schedule->targetType(), + 'schedule' => $schedule->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.volume-backup', $execution->id) + : null, + ]; + }); + + return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + } +} diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index caa19042b8..f3ec3ba4df 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -26,10 +26,17 @@ class Configuration extends Component public array $parameters; - protected $listeners = [ - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; + public function getListeners(): array + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + 'configurationChanged' => 'refreshServices', + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices', + ]; + } public function render() { diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 90907abc6e..8535584dd8 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -22,8 +22,6 @@ class DatabaseBackups extends Component public array $query; - public bool $isImportSupported = false; - public ?ScheduledDatabaseBackup $backup = null; public string $section = 'index'; @@ -32,7 +30,7 @@ class DatabaseBackups extends Component protected $listeners = ['refreshScheduledBackups' => '$refresh']; - public function mount() + public function mount(): mixed { try { $this->parameters = array_filter( @@ -67,10 +65,13 @@ class DatabaseBackups extends Component return redirect()->route('project.service.index', $this->parameters); } - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + if (! request()->route('backup_uuid')) { + return redirect()->route('project.service.volume-backups.index', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } if (request()->route('backup_uuid')) { $this->backup = $this->serviceDatabase->scheduledBackups() @@ -85,6 +86,14 @@ class DatabaseBackups extends Component 'project.service.database.backup.danger' => 'danger', default => 'general', }; + + $routeParameters = [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]; + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 4690335d86..d5254e093a 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Service; +use App\Actions\Shared\CheckDomainDns; +use App\Jobs\CheckDomainDnsJob; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Project\Shared\ConfigurationChecker; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\DomainPortOverrides; use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -131,6 +134,39 @@ class Domains extends Component $this->loadDomainState(); } + public function pollDnsChecks(): void + { + $this->authorize('view', $this->service); + + $checkingRows = collect($this->domainRows) + ->where('dns_status', 'checking') + ->values(); + + $this->refreshDomains(); + + foreach ($checkingRows as $checkingRow) { + $row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url'] + && (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']); + + if (! is_array($row) || $row['dns_status'] === 'checking') { + continue; + } + + $this->dispatchDnsCheckNotification($row['url'], $row['dns_status']); + } + } + + protected function dispatchDnsCheckNotification(string $url, string $status): void + { + $host = parse_url($url, PHP_URL_HOST) ?: $url; + + match ($status) { + 'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."), + 'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."), + default => $this->dispatch('info', "DNS check skipped for {$host}."), + }; + } + public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void { $application = $this->service->applications()->findOrFail($serviceApplicationId); @@ -271,38 +307,67 @@ class Domains extends Component { $entry = $stored[$url] ?? null; $displayName = $app->human_name ?: $app->name; + $port = $this->effectiveDomainInternalPort($url, $app); - if (is_array($entry) && filled(data_get($entry, 'status'))) { - return [ - 'service_application_id' => $app->id, - 'service_name' => $displayName, - 'service_image' => $app->image, - 'url' => $url, - 'dns_status' => (string) data_get($entry, 'status', 'pending'), - 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), - 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, - 'checked_at' => data_get($entry, 'checked_at'), - 'is_suggested' => false, - 'suggested_for' => null, - 'suggestion_label' => null, - 'needs_force_add' => false, - ]; - } - - return [ + $row = [ 'service_application_id' => $app->id, 'service_name' => $displayName, 'service_image' => $app->image, 'url' => $url, + 'internal_port' => $port['internal_port'], + 'has_port_override' => $port['has_port_override'], 'dns_status' => 'pending', 'dns_message' => 'Not checked yet.', 'expected_ip' => $this->serverIp, 'checked_at' => null, + 'check_id' => null, 'is_suggested' => false, 'suggested_for' => null, 'suggestion_label' => null, 'needs_force_add' => false, ]; + + if (is_array($entry) && filled(data_get($entry, 'status'))) { + $row['dns_status'] = (string) data_get($entry, 'status', 'pending'); + $row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.'); + $row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp; + $row['checked_at'] = data_get($entry, 'checked_at'); + $row['check_id'] = data_get($entry, 'check_id'); + } + + return $row; + } + + /** + * @return array{internal_port: ?int, has_port_override: bool} + */ + protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array + { + $canonical = DomainPortOverrides::withoutPort($url); + $overrides = $app->domain_port_overrides ?? []; + $legacyPortPart = DomainUrlParts::split($url)['port'] ?? ''; + $legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null; + + if (array_key_exists($canonical, $overrides)) { + return [ + 'internal_port' => (int) $overrides[$canonical], + 'has_port_override' => true, + ]; + } + + if ($legacyPort !== null && $legacyPort > 0) { + return [ + 'internal_port' => $legacyPort, + 'has_port_override' => true, + ]; + } + + $requiredPort = $app->getRequiredPort(); + + return [ + 'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null, + 'has_port_override' => false, + ]; } /** @@ -404,6 +469,8 @@ class Domains extends Component $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; + foreach ($this->domainRows as $index => $row) { if ($skipDns) { $this->domainRows[$index]['dns_status'] = 'skipped'; @@ -415,7 +482,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $row['url'], $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -443,33 +514,37 @@ class Domains extends Component return; } - $this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); + $this->applyDnsStatus($index, $server); $this->persistAllDomainDnsStatuses(); } - protected function applyDnsStatus(int $index, string $url, Server $server): void + protected function applyDnsStatus(int $index, Server $server): void { - $target = $this->dnsTargetLabel(); + $this->applyDnsStatuses([$index], $server); + } - try { - $isValid = validateDNSEntry($url, $server); - if ($isValid) { - $this->domainRows[$index]['dns_status'] = 'ok'; - $this->domainRows[$index]['dns_message'] = $target - ? "DNS points to {$target} (or Cloudflare)." - : 'DNS looks correct.'; - } else { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - $this->domainRows[$index]['dns_status'] = 'failed'; - $this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.'; + /** + * @param array $indexes + */ + protected function applyDnsStatuses(array $indexes, Server $server): void + { + $entries = []; + + foreach ($indexes as $index) { + $entries[(string) $index] = $this->domainRows[$index]['url']; } - $this->domainRows[$index]['expected_ip'] = $this->serverIp; - $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); - $this->decorateSuggestedDomainAfterDnsCheck($index); + $results = CheckDomainDns::run($entries, $server, $this->serverIp); + + foreach ($results as $index => $result) { + $index = (int) $index; + $this->domainRows[$index]['dns_status'] = $result['status']; + $this->domainRows[$index]['dns_message'] = $result['message']; + $this->domainRows[$index]['expected_ip'] = $result['expected_ip']; + $this->domainRows[$index]['checked_at'] = $result['checked_at']; + $this->domainRows[$index]['check_id'] = null; + $this->decorateSuggestedDomainAfterDnsCheck($index); + } } /** @@ -516,6 +591,7 @@ class Domains extends Component 'message' => (string) ($row['dns_message'] ?? ''), 'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), + 'check_id' => $row['check_id'] ?? null, ]; } @@ -528,8 +604,30 @@ class Domains extends Component ->all(); $statuses = array_intersect_key($statuses, array_flip($currentUrls)); + DB::transaction(function () use ($app, &$statuses): void { + $application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id); + $storedStatuses = $application->domain_dns_statuses ?? []; + + foreach ($statuses as $key => $status) { + $localCheckId = $status['check_id'] ?? null; + $storedCheckId = $storedStatuses[$key]['check_id'] ?? null; + + if ($storedCheckId !== null && $localCheckId !== $storedCheckId) { + $statuses[$key] = $storedStatuses[$key]; + + continue; + } + + if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') { + $statuses[$key] = $storedStatuses[$key]; + } + } + + $application->domain_dns_statuses = $statuses === [] ? null : $statuses; + $application->save(); + }); + $app->domain_dns_statuses = $statuses === [] ? null : $statuses; - $app->save(); } $this->service->load('applications'); @@ -920,24 +1018,17 @@ class Domains extends Component ->all() : []; $current = collect($this->splitDomains($app->fqdn)); + $currentCanonicalDomains = $current->map( + fn (string $url): string => DomainPortOverrides::withoutPort($url) + ); foreach ($newUrls as $url) { - if ($current->contains($url)) { + if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) { $this->addError('newDomain', "Domain {$url} is already configured for this service."); return; } } - if (! $this->forceSaveDns && $this->shouldValidateDns()) { - $dnsFailure = $this->findDnsFailureMessage($newUrls); - if ($dnsFailure !== null) { - $this->addDomainDnsFailed = true; - $this->addDomainDnsMessage = $dnsFailure; - - return; - } - } - $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $this->pendingAction = 'add'; @@ -955,14 +1046,93 @@ class Domains extends Component $this->forceRemovePort = false; $this->pendingAction = null; $this->dispatch('close-modal'); - $this->dispatch('success', 'Domain added.'); $this->refreshDomains(); - $this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); + $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls))); + $serviceApplicationId = (int) $app->id; + $dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [ + 'url' => $url, + 'check_id' => new_public_id(), + ]); + + foreach ($dnsChecks as $dnsCheck) { + $this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + $this->persistAllDomainDnsStatuses(); + + $failedDnsChecks = 0; + foreach ($dnsChecks as $dnsCheck) { + try { + CheckDomainDnsJob::dispatch( + $app, + $dnsCheck['url'], + $dnsCheck['url'], + $this->service->server, + $this->serverIp, + $dnsCheck['check_id'], + ); + } catch (\Throwable) { + $failedDnsChecks++; + $this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']); + } + } + + if ($failedDnsChecks > 0) { + $this->persistAllDomainDnsStatuses(); + $this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.'); + } + + $this->dispatch('success', $failedDnsChecks === $dnsChecks->count() + ? 'Domain added.' + : 'Domain added. DNS check started.'); } catch (\Throwable $e) { handleError($e, $this); } } + /** + * @param array $urls + */ + protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $indexesToCheck = []; + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'checking'; + $this->domainRows[$index]['dns_message'] = 'Checking DNS...'; + $this->domainRows[$index]['check_id'] = $checkId; + } + } + + /** + * @param array $urls + */ + protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void + { + $this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId); + + foreach ($this->domainRows as $index => $row) { + if (! in_array($row['url'], $urls, true)) { + continue; + } + + if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) { + continue; + } + + $this->domainRows[$index]['dns_status'] = 'skipped'; + $this->domainRows[$index]['dns_message'] = 'DNS check could not be started.'; + $this->domainRows[$index]['checked_at'] = now()->toIso8601String(); + } + } + public function startEdit(int $index): void { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { @@ -972,6 +1142,12 @@ class Domains extends Component $this->editingIndex = $index; $this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomainParts = DomainUrlParts::split($this->editingDomain); + $app = $this->findServiceApp((int) $this->domainRows[$index]['service_application_id']); + $canonical = DomainPortOverrides::withoutPort($this->editingDomain); + $savedPort = ($app?->domain_port_overrides ?? [])[$canonical] ?? null; + if (filled($savedPort)) { + $this->editingDomainParts['port'] = (string) $savedPort; + } $this->editingDomainPartsChanged = false; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editDomainDnsFailed = false; @@ -1005,7 +1181,7 @@ class Domains extends Component return; } - if ($this->editingDomainPartsChanged) { + if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) { $this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts); } $this->validateOnly('editingDomain'); @@ -1027,7 +1203,17 @@ class Domains extends Component $current = collect($this->splitDomains($app->fqdn)); $wasNoindexed = $app->isDomainNoindexed($oldUrl); - if ($newUrl !== $oldUrl && $current->contains($newUrl)) { + if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) { + $portOverrides = $app->domain_port_overrides ?? []; + unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]); + unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]); + $app->domain_port_overrides = $portOverrides ?: null; + } + + $otherCanonicalDomains = $current + ->reject(fn (string $url): bool => $url === $oldUrl) + ->map(fn (string $url): string => DomainPortOverrides::withoutPort($url)); + if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) { $this->addError('editingDomain', "Domain {$newUrl} is already configured for this service."); return; @@ -1104,6 +1290,28 @@ class Domains extends Component } } + public function removeDomainByKey(string $domainKey): void + { + $index = collect($this->domainRows)->search( + fn (array $row): bool => ! ($row['is_suggested'] ?? false) + && hash_equals($domainKey, $this->domainRowKey($row)) + ); + + if ($index === false) { + return; + } + + $this->removeDomain((int) $index); + } + + /** + * @param array{url: string, service_application_id: int|string} $row + */ + private function domainRowKey(array $row): string + { + return hash('sha256', $row['url'].'|'.$row['service_application_id']); + } + public function addSuggestedDomain(int $index): void { try { @@ -1237,8 +1445,9 @@ class Domains extends Component if (! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { + $previousFqdn = $app->getOriginal('fqdn'); foreach ($this->splitDomains($domainString) as $fqdn) { - if (ServiceApplication::extractPortFromUrl($fqdn) === null) { + if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) { $this->requiredPort = $requiredPort; $this->showPortWarningModal = true; $app->refresh(); @@ -1286,6 +1495,7 @@ class Domains extends Component $urlSet = array_fill_keys($urls, true); $server = $this->service->server; $skipDns = ! $this->dnsValidationEnabled || ! $server; + $indexesToCheck = []; foreach ($this->domainRows as $index => $row) { $url = $row['url'] ?? null; @@ -1309,7 +1519,11 @@ class Domains extends Component continue; } - $this->applyDnsStatus($index, $url, $server); + $indexesToCheck[] = $index; + } + + if ($server && $indexesToCheck !== []) { + $this->applyDnsStatuses($indexesToCheck, $server); } $this->persistAllDomainDnsStatuses(); @@ -1330,15 +1544,11 @@ class Domains extends Component return null; } - $target = $this->dnsTargetLabel() ?? $server->ip; + $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp); - foreach ($urls as $url) { - try { - if (! validateDNSEntry($url, $server)) { - return dnsMismatchGuidanceMessage($target, $this->serverIp); - } - } catch (\Throwable) { - return 'Could not validate DNS for this domain.'; + foreach ($results as $result) { + if ($result['status'] === 'failed') { + return $result['message']; } } diff --git a/app/Livewire/Project/Service/EditCompose.php b/app/Livewire/Project/Service/EditCompose.php index 46a8ecdc89..2feafe41a2 100644 --- a/app/Livewire/Project/Service/EditCompose.php +++ b/app/Livewire/Project/Service/EditCompose.php @@ -78,7 +78,7 @@ class EditCompose extends Component try { $this->authorize('update', $this->service); $this->dispatch('saveCompose', $this->dockerComposeRaw); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 96fe6a62c3..f099891534 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -46,18 +46,18 @@ class EditDomain extends Component $this->syncData(); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); // Sync to model - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); $this->application->save(); } else { // Sync from model - $this->fqdn = $this->application->fqdn; + $this->fqdn = $this->application->url; } } @@ -84,6 +84,10 @@ class EditDomain extends Component public function submit() { try { + $persistedApplication = $this->application->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->application); $this->validate(); @@ -93,7 +97,7 @@ class EditDomain extends Component $this->dispatch('warning', __('warning.sslipdomain')); } // Sync to model for domain conflict check (without validation) - $this->application->fqdn = $this->fqdn; + $this->application->setEditableUrls($this->fqdn); // Check for domain conflicts if not forcing save if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->application); @@ -113,29 +117,21 @@ class EditDomain extends Component $requiredPort = $this->application->getRequiredPort(); if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->application->fqdn = $previousFqdn; + $this->application->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { // Reset the force flag after using it diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 84a0daec8a..d6ab2ac151 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -120,7 +120,7 @@ class FileStorage extends Component : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { if ($this->fileStorage->is_too_large) { @@ -160,7 +160,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -179,7 +179,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -207,7 +207,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } } @@ -242,7 +242,7 @@ class FileStorage extends Component } catch (\Throwable $e) { return handleError($e, $this); } finally { - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(Storage::class); } return true; diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 34bb46ff19..d692a71546 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -5,8 +5,11 @@ namespace App\Livewire\Project\Service; use App\Actions\Docker\GetContainersStatus; use App\Actions\Service\StartService; use App\Actions\Service\StopService; +use App\Actions\Service\StopServiceApplication; use App\Enums\ProcessStatus; use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -113,6 +116,7 @@ class Heading extends Component try { $this->authorizeService('deploy'); $activity = StartService::run($this->service, pullLatestImages: true); + $this->auditServiceAction('ui.service.started'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -146,6 +150,7 @@ class Heading extends Component try { $this->authorizeService('stop'); StopService::dispatch($this->service, false, $this->docker_cleanup); + $this->auditServiceAction('ui.service.stopped'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -162,6 +167,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -169,6 +175,29 @@ class Heading extends Component } } + public function removeSelectedResourceContainer(): void + { + $resource = $this->selectedResource(); + if (! $resource) { + return; + } + + $this->authorize('update', $resource); + StopServiceApplication::run($resource, true, true); + $this->dispatch('success', 'Container removed.'); + } + + private function selectedResource(): ServiceApplication|ServiceDatabase|null + { + $uuid = data_get($this->parameters, 'stack_service_uuid'); + if (! $uuid) { + return null; + } + + return $this->service->applications()->whereUuid($uuid)->first() + ?? $this->service->databases()->whereUuid($uuid)->first(); + } + public function pullAndRestartEvent() { try { @@ -180,6 +209,7 @@ class Heading extends Component return; } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->auditServiceAction('ui.service.restarted'); $this->js("window.dispatchEvent(new CustomEvent('startservice'))"); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -196,6 +226,15 @@ class Heading extends Component $this->authorize($ability, $this->service); } + private function auditServiceAction(string $event): void + { + auditLog($event, [ + 'team_id' => $this->service->team()?->id, + 'service_uuid' => $this->service->uuid, + 'service_name' => $this->service->name, + ]); + } + public function render() { return view('livewire.project.service.heading', [ diff --git a/app/Livewire/Project/Service/ImportBackup.php b/app/Livewire/Project/Service/ImportBackup.php new file mode 100644 index 0000000000..29e8d9f359 --- /dev/null +++ b/app/Livewire/Project/Service/ImportBackup.php @@ -0,0 +1,80 @@ +parameters = get_route_parameters(); + $project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('update', $this->service); + + $this->databases = $this->service->databases + ->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) + ->values(); + + $databaseUuid = request()->route('stack_service_uuid'); + if ($databaseUuid) { + $selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid); + abort_unless($selectedDatabase instanceof ServiceDatabase, 404); + $this->authorize('update', $selectedDatabase); + $this->selectedDatabase = $selectedDatabase; + $this->selectedDatabaseUuid = $selectedDatabase->uuid; + + if (request()->routeIs('project.service.database.import')) { + return redirect()->route('project.service.import-backup.database', $this->parameters); + } + } elseif ($this->databases->count() === 1) { + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $this->databases->first()->uuid, + ]); + } + + return null; + } + + public function updatedSelectedDatabaseUuid(): mixed + { + $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid); + abort_unless($database instanceof ServiceDatabase, 404); + $this->authorize('update', $database); + + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $database->uuid, + ]); + } + + public function render(): View + { + return view('livewire.project.service.import-backup'); + } + + private function supportsImport(ServiceDatabase $database): bool + { + return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index d93ed7c026..7980e07056 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -59,8 +59,6 @@ class Index extends Component public bool $isLogDrainEnabled = false; - public bool $isImportSupported = false; - // Application-specific properties public $docker_cleanup = true; @@ -101,10 +99,27 @@ class Index extends Component 'isStripprefixEnabled' => 'nullable|boolean', ]; - public function mount() + public function mount(?ServiceApplication $serviceApplication = null) { try { $this->services = collect([]); + if ($serviceApplication) { + $this->service = $serviceApplication->service; + $this->authorize('view', $this->service); + $this->parameters = [ + 'project_uuid' => $this->service->environment->project->uuid, + 'environment_uuid' => $this->service->environment->uuid, + 'service_uuid' => $this->service->uuid, + 'stack_service_uuid' => $serviceApplication->uuid, + ]; + $this->query = request()->query(); + $this->serviceApplication = $serviceApplication; + $this->resourceType = 'application'; + $this->initializeApplicationProperties(); + $this->s3s = currentTeam()->s3s; + + return; + } $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->currentRoute = request()->route()->getName(); @@ -153,10 +168,6 @@ class Index extends Component $this->refreshFileStorages(); $this->syncDatabaseData(false); - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); } private function syncDatabaseData(bool $toModel = false): void @@ -356,7 +367,7 @@ class Index extends Component if ($toModel) { $this->serviceApplication->human_name = $this->humanName; $this->serviceApplication->description = $this->description; - $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->setEditableUrls($this->fqdn); $this->serviceApplication->image = $this->image; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; @@ -365,7 +376,7 @@ class Index extends Component } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; - $this->fqdn = $this->serviceApplication->fqdn; + $this->fqdn = $this->serviceApplication->url; $this->image = $this->serviceApplication->image; $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); @@ -428,7 +439,7 @@ class Index extends Component $this->serviceApplication->delete(); $this->dispatch('success', 'Application deleted.'); - return redirect()->route('project.service.configuration', $this->parameters); + return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } @@ -462,7 +473,7 @@ class Index extends Component $serviceApplication->delete(); }); - return redirect()->route('project.service.configuration', $redirectParams); + return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } @@ -491,6 +502,10 @@ class Index extends Component public function submitApplication() { try { + $persistedApplication = $this->serviceApplication->fresh(); + $previousEditableUrls = $persistedApplication->url; + $previousFqdn = $persistedApplication->fqdn; + $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), @@ -520,28 +535,21 @@ class Index extends Component $requiredPort = $this->serviceApplication->getRequiredPort(); if ($requiredPort !== null) { - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { + foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { + $fqdn = trim((string) $fqdn); + if ($fqdn === '') { continue; } - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; + if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + $this->serviceApplication->fqdn = $previousFqdn; + $this->serviceApplication->domain_port_overrides = $previousPortOverrides; + + return; } } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } } } else { $this->forceRemovePort = false; diff --git a/app/Livewire/Project/Service/Status.php b/app/Livewire/Project/Service/Status.php index 192d7ca804..27919f1ae7 100644 --- a/app/Livewire/Project/Service/Status.php +++ b/app/Livewire/Project/Service/Status.php @@ -10,6 +10,13 @@ class Status extends Component { public Service $service; + public ?string $selectedResourceUuid = null; + + public function mount(): void + { + $this->selectedResourceUuid = request()->route('stack_service_uuid'); + } + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -27,6 +34,11 @@ class Status extends Component public function render(): View { - return view('livewire.project.service.status'); + $selectedResource = $this->selectedResourceUuid + ? $this->service->applications->firstWhere('uuid', $this->selectedResourceUuid) + ?? $this->service->databases->firstWhere('uuid', $this->selectedResourceUuid) + : null; + + return view('livewire.project.service.status', compact('selectedResource')); } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6880b5ab09..10079276f2 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Service; +use App\Livewire\Project\Shared\Storages\All as StorageList; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -51,7 +52,7 @@ class Storage extends Component return [ "echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent', - 'refreshStorages', + 'storageCountsChanged' => 'refreshStorages', 'addNewVolume', ]; } @@ -88,11 +89,17 @@ class Storage extends Component public function refreshStorages() { + $hadVolumes = $this->cachedVolumeCount > 0; + // Avoid loading full volume models onto this parent (child All owns that snapshot). $this->resource->unsetRelation('persistentStorages'); $this->loadVolumeCount(); $this->loadFileStorageMetaCounts(); $this->loadFileStorageForActiveTab(); + + if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) { + $this->dispatch('refreshVolumeList')->to(StorageList::class); + } } public function setActiveTab(string $tab): void @@ -222,7 +229,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Volume added successfully'); $this->dispatch('closeStorageModal', 'volume'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -257,7 +263,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'File mount added successfully'); $this->dispatch('closeStorageModal', 'file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -292,7 +297,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Host file mount added successfully'); $this->dispatch('closeStorageModal', 'host-file'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -331,7 +335,6 @@ class Storage extends Component $this->dispatch('configurationChanged'); $this->dispatch('success', 'Directory mount added successfully'); $this->dispatch('closeStorageModal', 'directory'); - $this->dispatch('refreshStorages'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Service/VolumeBackup/Create.php b/app/Livewire/Project/Service/VolumeBackup/Create.php index adb1234e69..430e89a2c1 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Create.php +++ b/app/Livewire/Project/Service/VolumeBackup/Create.php @@ -99,8 +99,8 @@ class Create extends Component $label = str($resource->name)->headline(); $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ 'key' => 'volume:'.$volume->id, - 'type' => 'Volume · '.$label, - 'name' => $volume->name, + 'type' => $label, + 'name' => str($volume->name)->after($this->service->uuid.'_')->value(), ])); $targets->push(...$resource->fileStorages() ->where('is_directory', true) @@ -109,8 +109,8 @@ class Create extends Component ->get() ->map(fn (LocalFileVolume $directory): array => [ 'key' => 'directory:'.$directory->id, - 'type' => 'Directory · '.$label, - 'name' => $directory->fs_path, + 'type' => $label, + 'name' => $directory->fs_path.' (directory)', ])); } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 49da9e21f4..e856d1373a 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Service\VolumeBackup; +use App\Jobs\DatabaseBackupJob; +use App\Jobs\VolumeBackupJob; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -20,14 +23,70 @@ class Index extends Component public string $search = ''; - protected $listeners = ['refreshVolumeBackups' => '$refresh']; + public bool $scheduleModalOpen = false; - public function mount(): void + public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; + + public ?ScheduledVolumeBackup $selectedVolumeBackup = null; + + public ?Collection $s3s = null; + + public function getListeners(): array { - $this->service = $this->findService(); + $teamId = currentTeam()->id; + + return [ + 'refreshVolumeBackups' => '$refresh', + 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(?Service $service = null): void + { + $this->service = $service ?? $this->findService(); $this->authorize('view', $this->service); $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + + } + + public function openSchedule(string $backupUuid): void + { + $this->loadSelectedSchedule($backupUuid); + $this->s3s = currentTeam()->s3s; + $this->scheduleModalOpen = true; + } + + public function closeScheduleModal(): void + { + $this->scheduleModalOpen = false; + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + } + + public function backupNow(string $type, string $backupUuid): void + { + try { + if ($type === 'database') { + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedDatabaseBackup, 404); + $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); + } else { + abort_unless($type === 'storage', 404); + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedVolumeBackup, 404); + $this->authorize('update', $this->selectedVolumeBackup->targetResource()); + VolumeBackupJob::dispatch($this->selectedVolumeBackup); + } + + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + $this->dispatch('success', 'Backup queued.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render(): View @@ -68,4 +127,24 @@ class Index extends Component ->where('uuid', request()->route('service_uuid')) ->firstOrFail(); } + + private function loadSelectedSchedule(string $backupUuid): void + { + $this->selectedDatabaseBackup = ScheduledDatabaseBackup::query() + ->with('database') + ->whereUuid($backupUuid) + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->first(); + + if ($this->selectedDatabaseBackup) { + return; + } + + $this->selectedVolumeBackup = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->whereUuid($backupUuid) + ->forService($this->service) + ->firstOrFail(); + } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php index eec60497f7..10abeb3bf4 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Show.php +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -20,7 +20,7 @@ class Show extends Component public string $section = 'general'; - public function mount(): void + public function mount(): mixed { $project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); @@ -43,6 +43,10 @@ class Show extends Component 'project.service.volume-backups.danger' => 'danger', default => 'general', }; + + $routeParameters = collect($this->parameters)->except('backup_uuid')->all(); + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } public function render(): View diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 7f0d3b173e..d2420a029f 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -106,14 +106,13 @@ class Danger extends Component try { $this->authorize('delete', $this->resource); - $this->resource->delete(); DeleteResourceJob::dispatch( $this->resource, $this->delete_volumes, $this->delete_connected_networks, $this->delete_configurations, $this->docker_cleanup - ); + )->afterResponse(); return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $this->projectUuid, diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 94fb4b4eb3..9262b9847e 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -64,6 +64,13 @@ class Destination extends Component $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); + auditLog('ui.application.destination_stopped', [ + 'team_id' => $this->resource->team()?->id, + 'application_uuid' => $this->resource->uuid, + 'application_name' => $this->resource->name, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); $this->refreshServers(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 1dcb7c7810..15b4410a5f 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -9,14 +9,27 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; class Add extends Component { - use AuthorizesRequests, EnvironmentVariableAnalyzer; + use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete; + + protected function secretManagerResource(): ?Model + { + if ($this->shared || ! $this->resource) { + return null; + } + + return $this->resource; + } + + public $resource; public $parameters; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index db80cff801..fea181395c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Events\ApplicationConfigurationChanged; use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; @@ -12,7 +13,9 @@ use App\Models\SharedEnvironmentVariable; use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use App\Traits\HasSecretManagerAutocomplete; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -21,7 +24,12 @@ class Show extends Component { public bool $showEnvironmentType = true; - use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; + + protected function secretManagerResource(): ?Model + { + return $this->isSharedVariable ? null : $this->env->resourceable; + } public $parameters; @@ -144,6 +152,8 @@ class Show extends Component */ public function loadValues(): void { + $this->authorize('update', $this->env); + if ($this->valuesLoaded) { return; } @@ -177,7 +187,8 @@ class Show extends Component ); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void + { if ($toModel) { $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); @@ -314,6 +325,10 @@ class Show extends Component $this->dispatch('success', 'Environment variable updated.'); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); + + if ($this->is_required && $this->resource instanceof Service) { + event(new ApplicationConfigurationChanged($this->resource->team()->id)); + } } catch (\Exception $e) { return handleError($e); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 67a040ef77..e1e5413719 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -17,12 +17,15 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component { + use AuthorizesRequests; + public const MAX_LOG_LINES = 50000; public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; @@ -82,6 +85,10 @@ class GetLogs extends Component public function instantSave() { if (! is_null($this->resource)) { + if (auth()->user()->cannot('update', $this->resource)) { + return; + } + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 6a128a1426..70633fe030 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -86,7 +86,7 @@ class HealthChecks extends Component } } - public function syncData(bool $toModel = false): void + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index dd00be25cc..61b4b2d2ed 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -86,6 +86,14 @@ class ResourceOperations extends Component if (! $server->canHostResources()) { return $this->addError('destination_id', 'The selected server cannot host resources.'); } + auditLog('ui.resource.clone_started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'resource_type' => class_basename($this->resource), + 'destination_uuid' => $new_destination->uuid, + 'environment_id' => $new_environment->id, + ]); if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 61bc6b0fbc..f170a0a6f0 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -102,7 +102,7 @@ class Add extends Component } } - public function saveScheduledTask() + private function saveScheduledTask(): mixed { try { $task = new ScheduledTask; diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 11df001531..c121f1b93b 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -87,7 +87,7 @@ class Show extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); @@ -169,9 +169,9 @@ class Show extends Component $this->task->delete(); if ($this->type === 'application') { - return redirect()->route('project.application.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters); } else { - return redirect()->route('project.service.scheduled-tasks.show', $this->parameters); + return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters); } } catch (\Exception $e) { return handleError($e); @@ -184,6 +184,13 @@ class Show extends Component $this->authorize('update', $this->resource); $this->authorize('update', $this->task); ScheduledTaskJob::dispatch($this->task); + auditLog('ui.scheduled_task.executed', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'scheduled_task_uuid' => $this->task->uuid, + 'scheduled_task_name' => $this->task->name, + ]); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/SecretManagerLinks.php b/app/Livewire/Project/Shared/SecretManagerLinks.php new file mode 100644 index 0000000000..c0641b56c5 --- /dev/null +++ b/app/Livewire/Project/Shared/SecretManagerLinks.php @@ -0,0 +1,290 @@ + Remote key names only — values are never stored. */ + public array $keys = []; + + public bool $keysLoaded = false; + + public string $search = ''; + + public function mount(): void + { + $this->loadData(); + } + + private function loadData(): void + { + $this->link = $this->resource->secretManagerLink()->with('integrationToken')->first(); + $this->availableTokens = IntegrationToken::ownedByCurrentTeam() + ->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS) + ->get() + ->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true)) + ->values(); + + if ($this->link) { + $this->integration_token_uuid = $this->link->integrationToken->uuid; + $this->settings = $this->link->settings ?? []; + } + } + + public function getSelectedTokenProperty(): ?IntegrationToken + { + if (blank($this->integration_token_uuid)) { + return null; + } + + return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid); + } + + protected function rules(): array + { + $rules = [ + 'integration_token_uuid' => ['required', 'string'], + ]; + + $rules += match ($this->selectedToken?->provider) { + 'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account' + ? [ + 'settings.project' => ['required', 'string'], + 'settings.config' => ['required', 'string'], + ] + : [], + 'infisical' => [ + 'settings.project_id' => ['required', 'string'], + 'settings.environment' => ['required', 'string'], + 'settings.secret_path' => ['nullable', 'string'], + ], + 'vault' => [ + 'settings.mount' => ['required', 'string'], + 'settings.path' => ['required', 'string'], + ], + default => [], + }; + + return $rules; + } + + /** + * Auto-save when a token is selected in the dropdown. Existing {{vault.*}} + * references are intentionally NOT re-checked — missing keys surface at + * the next deployment. + */ + public function updatedIntegrationTokenUuid(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->selectedToken; + + if (! $token) { + return; + } + + if ($this->link?->integrationToken?->provider !== $token->provider + || $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) { + $this->settings = []; + } + + $settings = array_filter($this->settings, fn ($value) => filled($value)); + + $this->resource->secretManagerLink()->updateOrCreate([], [ + 'integration_token_id' => $token->id, + 'settings' => $settings ?: null, + ]); + $this->auditSecretManagerAction('source_updated', [ + 'integration_token_uuid' => $token->uuid, + 'provider' => $token->provider, + ]); + + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Auto-save of the provider-specific settings fields (called on blur). + */ + public function saveSettings(): void + { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $validated = $this->validate(); + + try { + + $settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value)); + + $this->link->update(['settings' => $settings ?: null]); + $this->auditSecretManagerAction('settings_updated'); + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager settings saved.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function removeSource(): void + { + try { + $this->authorize('update', $this->resource); + $token = $this->link?->integrationToken; + $this->resource->secretManagerLink()->delete(); + $this->auditSecretManagerAction('source_removed', [ + 'integration_token_uuid' => $token?->uuid, + 'provider' => $token?->provider, + ]); + $this->link = null; + $this->integration_token_uuid = ''; + $this->settings = []; + $this->resetKeys(); + $this->loadData(); + $this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function loadKeys(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + // Values are fetched into memory, reduced to key names, and discarded. + $keys = array_keys($this->link->fetchSecrets()); + sort($keys); + $this->keys = $keys; + $this->keysLoaded = true; + $this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]); + } catch (\Throwable $e) { + $this->dispatch('error', 'Could not fetch keys: '.$e->getMessage()); + } + } + + public function addReference(string $key): void + { + try { + $this->authorize('update', $this->resource); + + if (! in_array($key, $this->keys, true)) { + return; + } + + if ($this->resource->environment_variables()->where('key', $key)->exists()) { + $this->dispatch('error', "A variable with the key {$key} already exists."); + + return; + } + + $this->resource->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $this->auditSecretManagerAction('reference_created', ['secret_key' => $key]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', "Added {$key} as {{vault.{$key}}}."); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function importAll(): void + { + try { + $this->authorize('update', $this->resource); + + if (! $this->link) { + return; + } + + $imported = $this->link->importMissingReferences(); + $this->auditSecretManagerAction('references_imported', [ + 'key_count' => count($imported), + 'secret_keys' => $imported, + ]); + + $this->dispatch('refreshEnvs'); + $this->dispatch('success', $imported === [] + ? 'All remote keys already exist as variables.' + : 'Imported '.count($imported).' keys as {{vault.KEY}} references.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function resetKeys(): void + { + $this->keys = []; + $this->keysLoaded = false; + $this->search = ''; + } + + /** @param array $context */ + private function auditSecretManagerAction(string $action, array $context = []): void + { + $resourceType = str(class_basename($this->resource))->snake()->value(); + + auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([ + 'team_id' => $this->resource->team()?->id, + "{$resourceType}_uuid" => $this->resource->uuid, + "{$resourceType}_name" => $this->resource->name, + ], $context)); + } + + public function getFilteredKeysProperty(): array + { + if (blank($this->search)) { + return $this->keys; + } + + return array_values(array_filter( + $this->keys, + fn (string $key) => stripos($key, $this->search) !== false, + )); + } + + public function render(): View + { + return view('livewire.project.shared.secret-manager-links', [ + 'selectedToken' => $this->selectedToken, + 'filteredKeys' => $this->filteredKeys, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index efe54a6a7d..3dadfb46f4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\Storages; +use App\Livewire\Project\Service\Storage as StorageComponent; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -44,7 +45,7 @@ class All extends Component public bool $deleteDockerVolume = false; - protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; + protected $listeners = ['refreshVolumeList' => 'refreshList', 'refreshVolumeBackups' => 'refreshList']; public function mount(): void { @@ -182,7 +183,7 @@ class All extends Component $storage->delete(); $this->refreshList(); - $this->dispatch('refreshStorages'); + $this->dispatch('storageCountsChanged')->to(StorageComponent::class); $this->dispatch('configurationChanged'); return true; diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php deleted file mode 100644 index 7e1e2dec1d..0000000000 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ /dev/null @@ -1,200 +0,0 @@ - 'name', - 'mountPath' => 'mount', - 'hostPath' => 'host', - ]; - - protected function rules(): array - { - return [ - 'name' => ValidationPatterns::volumeNameRules(), - 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], - 'isPreviewSuffixEnabled' => 'required|boolean', - ]; - } - - protected function messages(): array - { - return array_merge( - ValidationPatterns::volumeNameMessages(), - [ - 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', - 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.', - ] - ); - } - - /** - * Sync data between component properties and model - * - * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. - */ - private function syncData(bool $toModel = false): void - { - if ($toModel) { - // Sync TO model (before save) - $this->storage->name = $this->name; - $this->storage->mount_path = $this->mountPath; - $this->storage->host_path = $this->hostPath; - $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; - } else { - // Sync FROM model (on load/refresh) - $this->name = $this->storage->name; - $this->mountPath = $this->storage->mount_path; - $this->hostPath = $this->storage->host_path; - $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; - } - } - - public function mount(): void - { - $this->syncData(false); - $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); - // PR deployment volume suffixes only apply to git-based applications. - $this->supportsPreviewSuffix = $this->resource instanceof Application - && $this->resource->git_based() - && filled($this->resource->git_repository) - && ! $this->isService; - // Parent All batches badge/url; isolated embeds still hydrate themselves. - if (! $this->backupMetaHydrated) { - $this->refreshBackupStatus(); - } - } - - #[On('refreshVolumeBackups')] - public function refreshBackupStatus(): void - { - $backup = $this->storage->scheduledBackups()->first(); - - $this->hasEnabledBackup = $backup?->enabled ?? false; - $this->backupUrl = null; - - if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) { - return; - } - - $this->resource->loadMissing('environment.project'); - - $parameters = [ - 'project_uuid' => $this->resource->project()->uuid, - 'environment_uuid' => $this->resource->environment->uuid, - 'application_uuid' => $this->resource->uuid, - ]; - $hasOtherBackups = ScheduledVolumeBackup::query() - ->forApplication($this->resource) - ->where('id', '!=', $backup->id) - ->exists(); - - $this->backupUrl = $hasOtherBackups - ? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name]) - : route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]); - } - - public function openBackupModal(): void - { - $this->authorize('update', $this->resource); - $this->showBackupModal = true; - } - - #[On('modalClosed')] - public function onModalClosed(): void - { - // Drop the nested Create component from the DOM after close to free snapshot weight. - if ($this->showBackupModal) { - $this->showBackupModal = false; - } - } - - public function instantSave(): void - { - $this->authorize('update', $this->resource); - $this->validate(); - - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function submit() - { - $this->authorize('update', $this->resource); - - $this->validate(); - $this->syncData(true); - $this->storage->save(); - $this->dispatch('success', 'Storage updated successfully'); - } - - public function delete($password, $selectedActions = []) - { - $this->authorize('update', $this->resource); - - if (! verifyPasswordConfirmation($password, $this)) { - return 'The provided password is incorrect.'; - } - - if ($this->storage->scheduledBackups()->exists()) { - $this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.'); - - return false; - } - - $this->storage->delete(); - $this->dispatch('refreshStorages'); - $this->dispatch('configurationChanged'); - - return true; - } -} diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..ef7b36ff72 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -204,6 +204,12 @@ class VolumeBackups extends Component } VolumeBackupJob::dispatch($this->backup); + auditLog('ui.volume_backup.started', [ + 'team_id' => $this->resource->team()?->id, + 'resource_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource->name, + 'backup_uuid' => $this->backup->uuid, + ]); $this->dispatch('success', 'Storage backup queued.'); return redirect()->route($this->routeName('executions'), $this->routeParameters()); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 5a978ac84f..a1cc4db19f 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -140,6 +140,12 @@ class ApiTokens extends Component ]); $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null; $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt); + auditLog('ui.api_token.created', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $this->description, + 'abilities' => array_values($this->permissions), + 'expires_at' => $expiresAt?->toIso8601String(), + ]); $this->getTokens(); // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. session()->flash('token', $token->plainTextToken); @@ -156,7 +162,12 @@ class ApiTokens extends Component ->where('id', $id) ->firstOrFail(); $this->authorize('delete', $token); + $tokenName = $token->name; $token->delete(); + auditLog('ui.api_token.revoked', [ + 'team_id' => currentTeam()->id, + 'api_token_name' => $tokenName, + ]); $this->getTokens(); } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index b6d448e903..0d26d1d669 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -28,6 +28,8 @@ class CloudInitScripts extends Component public function loadScripts() { + $this->authorize('viewAny', CloudInitScript::class); + CloudInitScript::ownedByCurrentTeam() ->whereNull('uuid') ->get() diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ba2655b434..2c31d22035 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -94,6 +94,7 @@ class CloudProviderTokenForm extends Component public function addToken() { + $this->authorize('create', CloudProviderToken::class); $this->validate(); try { diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 453a7e8ae8..2a33591822 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component public array $capabilities = []; + public array $metadata = []; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component $this->name = $this->integrationToken->name; $this->capabilities = $this->integrationToken->capabilities; + $this->metadata = $this->integrationToken->metadata ?? []; } protected function rules(): array { - return [ + $allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ 'name' => ['required', 'string', 'max:255'], 'newToken' => ['nullable', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->integrationToken->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->integrationToken->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component ]; } - public function save(CloudflareTokenValidator $validator): void + public function save(IntegrationTokenValidator $validator): void { $this->authorize('update', $this->integrationToken); $validated = $this->validate(); + $provider = $this->integrationToken->provider; $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + $metadataChanged = $metadata != ($this->integrationToken->metadata ?? []); try { - if ((filled($validated['newToken']) || $capabilitiesChanged) - && ! $validator->validate($token, $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged) + && ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($provider)); return; } @@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component $updates = [ 'name' => $validated['name'], 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, ]; if (filled($validated['newToken'])) { @@ -100,8 +121,25 @@ class IntegrationTokenEditor extends Component public function delete(string $password = ''): void { $this->authorize('delete', $this->integrationToken); + + if ($this->integrationToken->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + + $uuid = $this->integrationToken->uuid; + $name = $this->integrationToken->name; + $provider = $this->integrationToken->provider; $this->integrationToken->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $uuid, + 'integration_token_name' => $name, + 'provider' => $provider, + ]); + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); $this->dispatch('close-modal'); $this->dispatch('success', 'Integration token deleted successfully.'); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 7a7637bf5e..26ecce1e14 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -3,7 +3,7 @@ namespace App\Livewire\Security; use App\Models\IntegrationToken; -use App\Services\CloudflareTokenValidator; +use App\Services\IntegrationTokenValidator; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component public array $capabilities = ['dns']; + public array $metadata = []; + public function mount(): void { $this->authorize('create', IntegrationToken::class); } + public function updatedProvider(): void + { + if ($this->provider === 'cloudflare') { + $this->capabilities = ['dns']; + $this->metadata = []; + } else { + $this->capabilities = ['secrets']; + $this->metadata = $this->provider === 'infisical' + ? ['base_url' => 'https://app.infisical.com'] + : []; + } + } + protected function rules(): array { - return [ - 'provider' => ['required', 'in:cloudflare'], + $allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets'; + + $rules = [ + 'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))], 'name' => ['required', 'string', 'max:255'], 'token' => ['required', 'string'], 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], + 'capabilities.*' => ['required', 'in:'.$allowedCapability], ]; + + if ($this->provider === 'infisical') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.client_id'] = ['required', 'string']; + } + + if ($this->provider === 'doppler') { + $rules['token'][] = 'regex:/^dp\.(st|sa)\./'; + } + + if ($this->provider === 'vault') { + $rules['metadata.base_url'] = ['required', 'url:http,https']; + $rules['metadata.namespace'] = ['nullable', 'string']; + } + + return $rules; } protected function messages(): array @@ -42,25 +75,38 @@ class IntegrationTokenForm extends Component return [ 'capabilities.required' => 'Select at least one capability.', 'capabilities.min' => 'Select at least one capability.', + 'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).', ]; } - public function addToken(CloudflareTokenValidator $validator): void + public function addToken(IntegrationTokenValidator $validator): void { $validated = $this->validate(); + $metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value)); try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { + $this->dispatch('error', $validator->errorMessage($validated['provider'])); return; } - IntegrationToken::query()->create([ - ...$validated, + $integrationToken = IntegrationToken::query()->create([ + 'provider' => $validated['provider'], + 'name' => $validated['name'], + 'token' => $validated['token'], + 'capabilities' => $validated['capabilities'], + 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id, ]); + auditLog('ui.integration_token.created', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $integrationToken->uuid, + 'integration_token_name' => $integrationToken->name, + 'provider' => $integrationToken->provider, + ]); + $this->reset(['name', 'token']); $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php index 39db135b38..71805fb841 100644 --- a/app/Livewire/Security/IntegrationTokens.php +++ b/app/Livewire/Security/IntegrationTokens.php @@ -29,7 +29,23 @@ class IntegrationTokens extends Component { $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); + + if ($token->secretManagerLinks()->exists()) { + $this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.'); + + return; + } + + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $provider = $token->provider; $token->delete(); + auditLog('ui.integration_token.deleted', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $tokenUuid, + 'integration_token_name' => $tokenName, + 'provider' => $provider, + ]); $this->loadTokens(); $this->dispatch('success', 'Integration token deleted successfully.'); } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 8b170e6ae0..9a7ff4e979 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,13 +10,38 @@ class Index extends Component { use AuthorizesRequests; + public ?string $selectedPrivateKeyUuid = null; + public function getListeners(): array { return [ 'securityResourceChanged' => '$refresh', + 'privateKeyCreated' => 'refreshResources', + 'privateKeyDeleted' => 'refreshResources', + 'privateKeyUpdated' => 'refreshResources', + 'modalClosed' => 'closeEditor', ]; } + public function openEditor(string $privateKeyUuid): void + { + $privateKey = PrivateKey::ownedByCurrentTeam()->whereUuid($privateKeyUuid)->firstOrFail(); + $this->authorize('view', $privateKey); + + $this->selectedPrivateKeyUuid = $privateKey->uuid; + } + + public function closeEditor(): void + { + $this->selectedPrivateKeyUuid = null; + } + + public function refreshResources(): void + { + $this->closeEditor(); + $this->dispatch('close-modal'); + } + public function generatePrivateKey(string $type) { try { diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 7fa2300031..1b8f26ff28 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -76,7 +76,9 @@ class Show extends Component // Sync FROM model (on load/refresh) $this->name = $this->private_key->name; $this->description = $this->private_key->description; - $this->privateKeyValue = $this->private_key->private_key; + $this->privateKeyValue = auth()->user()->can('update', $this->private_key) + ? $this->private_key->private_key + : ''; $this->isGitRelated = $this->private_key->is_git_related; } } @@ -92,6 +94,7 @@ class Show extends Component $this->syncData(false); $this->isInUse = $this->private_key->isInUse(); + $this->public_key = $this->private_key->getPublicKey(); } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { @@ -99,14 +102,6 @@ class Show extends Component } } - public function loadPublicKey() - { - $this->public_key = $this->private_key->getPublicKey(); - if ($this->public_key === 'Error loading private key') { - $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); - } - } - public function delete() { try { @@ -123,8 +118,7 @@ class Show extends Component currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); if ($this->modalMode) { - $this->dispatch('securityResourceChanged'); - $this->dispatch('close-modal'); + $this->dispatch('privateKeyDeleted'); return null; } @@ -150,10 +144,12 @@ class Show extends Component ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); - $this->dispatch('securityResourceChanged'); if ($this->modalMode) { - $this->dispatch('close-modal'); + $this->dispatch('privateKeyUpdated'); + + return null; } + $this->dispatch('securityResourceChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index a94881b12b..895ce34e79 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -42,10 +42,9 @@ class Advanced extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; @@ -67,6 +66,7 @@ class Advanced extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -81,6 +81,7 @@ class Advanced extends Component $this->serverDiskUsageCheckFrequency = $this->server->settings->getOriginal('server_disk_usage_check_frequency'); throw new \Exception('Invalid Cron / Human expression for Disk Usage Check Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 12d111d219..d0a8d8ca9d 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -97,10 +97,9 @@ class DockerCleanup extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->force_docker_cleanup = $this->forceDockerCleanup; $this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency; @@ -122,6 +121,7 @@ class DockerCleanup extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { @@ -134,6 +134,13 @@ class DockerCleanup extends Component try { $this->authorize('update', $this->server); DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); + auditLog('ui.server.docker_cleanup_started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'delete_unused_volumes' => $this->deleteUnusedVolumes, + 'delete_unused_networks' => $this->deleteUnusedNetworks, + ]); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -147,6 +154,7 @@ class DockerCleanup extends Component $this->dockerCleanupFrequency = $this->server->settings->getOriginal('docker_cleanup_frequency'); throw new \Exception('Invalid Cron / Human expression for Docker Cleanup Frequency.'); } + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ae53488bd5..9319c856c0 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -52,7 +52,7 @@ class LogDrains extends Component } } - public function syncDataNewRelic(bool $toModel = false) + private function syncDataNewRelic(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_newrelic_enabled = $this->isLogDrainNewRelicEnabled; @@ -65,7 +65,7 @@ class LogDrains extends Component } } - public function syncDataAxiom(bool $toModel = false) + private function syncDataAxiom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled; @@ -78,7 +78,7 @@ class LogDrains extends Component } } - public function syncDataCustom(bool $toModel = false) + private function syncDataCustom(bool $toModel = false): void { if ($toModel) { $this->server->settings->is_logdrain_custom_enabled = $this->isLogDrainCustomEnabled; @@ -91,7 +91,7 @@ class LogDrains extends Component } } - public function syncData(bool $toModel = false, ?string $type = null) + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { $this->customValidation(); diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..242b0971ec 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -101,6 +101,11 @@ class Navbar extends Component // Always use background job for all servers RestartProxyJob::dispatch($this->server); + auditLog('ui.proxy.restarted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); } catch (\Throwable $e) { $this->restartInitiated = false; @@ -125,6 +130,11 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); $activity = StartProxy::run($this->server, force: true); + auditLog('ui.proxy.started', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { return handleError($e, $this); @@ -136,6 +146,12 @@ class Navbar extends Component try { $this->authorize('manageProxy', $this->server); StopProxy::dispatch($this->server, $forceStop); + auditLog('ui.proxy.stopped', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'force' => $forceStop, + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 811a01eb19..68cb52a926 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -57,6 +57,7 @@ class Proxy extends Component $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); $this->loadProxyConfiguration(); + $this->clearAppliedTraefikBranchWarning(); } private function syncData(bool $toModel = false): void @@ -276,6 +277,8 @@ class Proxy extends Component return null; } + $configuredBranch = $this->getConfiguredTraefikBranch(); + // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); @@ -283,9 +286,15 @@ class Proxy extends Component if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { - return str_starts_with($outdatedInfo['upgrade_target'], 'v') + $upgradeTarget = str_starts_with($outdatedInfo['upgrade_target'], 'v') ? $outdatedInfo['upgrade_target'] : "v{$outdatedInfo['upgrade_target']}"; + + if ($configuredBranch && version_compare($configuredBranch, ltrim($upgradeTarget, 'v'), '>=')) { + return null; + } + + return $upgradeTarget; } } @@ -315,9 +324,53 @@ class Proxy extends Component } } - return $newestBranch ? "v{$newestBranch}" : null; + if (! $newestBranch || ($configuredBranch && version_compare($configuredBranch, $newestBranch, '>='))) { + return null; + } + + return "v{$newestBranch}"; } catch (\Throwable $e) { return null; } } + + private function getConfiguredTraefikBranch(): ?string + { + if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) { + return null; + } + + if (! is_string($this->proxySettings)) { + return null; + } + + if (! preg_match('/^\s*image:\s*[\'\"]?traefik:v?(\d+\.\d+)(?:\.\d+)?[\'\"]?\s*$/mi', $this->proxySettings, $matches)) { + return null; + } + + return $matches[1]; + } + + private function clearAppliedTraefikBranchWarning(): void + { + $outdatedInfo = $this->server->traefik_outdated_info; + + if (data_get($outdatedInfo, 'type') !== 'minor_upgrade') { + return; + } + + $configuredBranch = $this->getConfiguredTraefikBranch(); + $upgradeTarget = ltrim((string) data_get($outdatedInfo, 'upgrade_target'), 'v'); + + if (! $configuredBranch || ! $upgradeTarget || version_compare($configuredBranch, $upgradeTarget, '<')) { + return; + } + + Server::query() + ->whereKey($this->server->id) + ->where('traefik_outdated_info->type', data_get($outdatedInfo, 'type')) + ->where('traefik_outdated_info->current', data_get($outdatedInfo, 'current')) + ->where('traefik_outdated_info->upgrade_target', data_get($outdatedInfo, 'upgrade_target')) + ->update(['traefik_outdated_info' => null]); + } } diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php index b4b99a3e7c..999482dcff 100644 --- a/app/Livewire/Server/Security/TerminalAccess.php +++ b/app/Livewire/Server/Security/TerminalAccess.php @@ -62,10 +62,9 @@ class TerminalAccess extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); // No other fields to sync for terminal access } else { diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index e189254837..d467380ba3 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -77,10 +77,9 @@ class Sentinel extends Component $this->syncData(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->validate(); $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; @@ -204,6 +203,7 @@ class Sentinel extends Component public function submit() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { @@ -214,6 +214,7 @@ class Sentinel extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->restartSentinel(); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 017beb3719..38bbe24e7c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -230,12 +230,10 @@ class Show extends Component ->toArray(); } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); - - $this->authorize('update', $this->server); $foundServer = Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) ->first(); @@ -363,6 +361,7 @@ class Show extends Component public function checkLocalhostConnection() { try { + $this->authorize('update', $this->server); $this->syncData(true); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if ($uptime) { @@ -479,6 +478,7 @@ class Show extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); } catch (\Throwable $e) { return handleError($e, $this); @@ -694,6 +694,7 @@ class Show extends Component public function submit() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Server settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Swarm.php b/app/Livewire/Server/Swarm.php index e3e441ea0e..af785a8c20 100644 --- a/app/Livewire/Server/Swarm.php +++ b/app/Livewire/Server/Swarm.php @@ -29,10 +29,9 @@ class Swarm extends Component } } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { - $this->authorize('update', $this->server); $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $this->server->settings->save(); @@ -45,6 +44,7 @@ class Swarm extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); $this->dispatch('success', 'Swarm settings updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php index db8999c268..9fe37c10ca 100644 --- a/app/Livewire/Server/TransferImport.php +++ b/app/Livewire/Server/TransferImport.php @@ -123,6 +123,15 @@ class TransferImport extends Component $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + if (! $dryRun) { + auditLog('ui.server.imported', [ + 'team_id' => $teamId, + 'server_uuid' => $this->importedServerUuid, + 'claimed' => (bool) data_get($result, 'claimed'), + 'adopt_mode' => $this->adoptMode, + ]); + } + if ($dryRun) { $this->dispatch('success', 'Dry run completed — nothing was written.'); } elseif (data_get($result, 'claimed')) { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index c39f868baf..db62bff2db 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -53,6 +53,8 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + $this->authorize('update', $this->server); + if (! $this->server->canBeValidated()) { $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; $this->server->update([ @@ -160,6 +162,8 @@ class ValidateAndInstall extends Component public function validateOS() { + $this->authorize('update', $this->server); + $this->supported_os_type = $this->server->validateOS(); if (! $this->supported_os_type) { $this->error = 'Server OS type is not supported. Please install Docker manually before continuing: documentation.'; @@ -174,6 +178,8 @@ class ValidateAndInstall extends Component public function validatePrerequisites() { + $this->authorize('update', $this->server); + $validationResult = $this->server->validatePrerequisites(); $this->prerequisites_installed = $validationResult['success']; if (! $validationResult['success']) { @@ -212,6 +218,8 @@ class ValidateAndInstall extends Component public function validateDockerEngine() { + $this->authorize('update', $this->server); + $this->docker_installed = $this->server->validateDockerEngine(); $this->docker_compose_installed = $this->server->validateDockerCompose(); if (! $this->docker_installed || ! $this->docker_compose_installed) { @@ -248,6 +256,8 @@ class ValidateAndInstall extends Component public function validateDockerVersion() { + $this->authorize('update', $this->server); + if ($this->server->isSwarm()) { $swarmInstalled = $this->server->validateDockerSwarm(); if ($swarmInstalled) { diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 40705617f1..829fbda800 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -212,7 +212,7 @@ class Index extends Component return; } - $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); + $imageRef = escapeshellarg(coolifyHelperImage().":{$version}"); $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $activity = remote_process( diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 1426f61f02..975ce9a241 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -74,7 +74,7 @@ class SettingsEmail extends Component $this->testEmailAddress = auth()->user()->email; } - public function syncData(bool $toModel = false) + private function syncData(bool $toModel = false): void { if ($toModel) { $this->validate(); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 2570c3a1b5..2dadd73661 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -122,13 +122,6 @@ class Change extends Component } } - public function boot() - { - if ($this->github_app) { - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); - } - } - /** * Sync data between component properties and model * @@ -170,8 +163,9 @@ class Change extends Component $this->appId = $this->github_app->app_id; $this->installationId = $this->github_app->installation_id; $this->clientId = $this->github_app->client_id; - $this->clientSecret = $this->github_app->client_secret; - $this->webhookSecret = $this->github_app->webhook_secret; + $canUpdate = auth()->user()->can('update', $this->github_app); + $this->clientSecret = $canUpdate ? $this->github_app->client_secret : null; + $this->webhookSecret = $canUpdate ? $this->github_app->webhook_secret : null; $this->isSystemWide = $this->github_app->is_system_wide; $this->privateKeyId = $this->github_app->private_key_id; $this->contents = $this->github_app->contents; @@ -231,7 +225,7 @@ class Change extends Component syncGithubAppName($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app); - $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); + $this->github_app->refresh(); $this->syncData(false); $this->isConnected = $this->github_app->isConnected(); $this->name = str($this->github_app->name)->kebab(); @@ -305,7 +299,7 @@ class Change extends Component try { $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); - $this->github_app->makeVisible(['client_secret', 'webhook_secret']); + $this->authorize('view', $this->github_app); $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->applications = $this->github_app->applications; @@ -420,7 +414,6 @@ class Change extends Component try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->organization = normalizeGithubOrganization($this->organization); $this->apiUrl = filled($this->apiUrl) ? $this->apiUrl @@ -442,7 +435,6 @@ class Change extends Component { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->app_id = 1234567890; $this->github_app->installation_id = 1234567890; $this->github_app->save(); @@ -457,8 +449,6 @@ class Change extends Component try { $this->authorize('update', $this->github_app); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->syncData(true); $this->github_app->save(); $this->isConnected = $this->github_app->isConnected(); @@ -475,7 +465,6 @@ class Change extends Component if ($this->github_app->applications->isNotEmpty()) { $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); - $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); return; } @@ -484,7 +473,7 @@ class Change extends Component // @can and canGate checks against a deleted model (null team_id TypeError). $this->github_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index dd0284582b..29374105b2 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -338,7 +338,7 @@ class Change extends Component // @can and canGate checks against a deleted model (null team_id TypeError). $this->gitlab_app = null; - return redirect()->route('source.all'); + return redirectRoute($this, 'source.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index 89782d686c..17abd19e51 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -43,7 +43,7 @@ class Show extends Component $this->storage->delete(); - return redirect()->route('storage.index'); + return redirectRoute($this, 'storage.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Team/AuditLog.php b/app/Livewire/Team/AuditLog.php new file mode 100644 index 0000000000..53cb203eff --- /dev/null +++ b/app/Livewire/Team/AuditLog.php @@ -0,0 +1,73 @@ +user()->isAdminOfTeam(currentTeam()->id), 403); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedAction(): void + { + $this->resetPage(); + } + + public function updatedSource(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(10, min(100, $this->perPage)); + $this->resetPage(); + } + + public function render(): View + { + $search = trim($this->search); + $teamId = currentTeam()->id; + $canViewInstanceEvents = $teamId === 0 && isInstanceAdmin(); + $visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents); + $actionOptions = [ + ['value' => 'all', 'label' => 'All actions'], + ...$visibleEvents->clone() + ->select('action') + ->distinct() + ->orderBy('action') + ->pluck('action') + ->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)]) + ->all(), + ]; + $events = AuditEvent::query() + ->visibleToTeam($teamId, $canViewInstanceEvents) + ->filtered($search, $this->action, $this->source) + ->latestFirst() + ->paginate($this->perPage); + + return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]); + } +} diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 8ecafc417c..b66c49ac9e 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -22,6 +22,8 @@ class Invitations extends Component $this->authorize('manageInvitations', currentTeam()); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); + $invitationEmail = $invitation->email; + $invitationUuid = $invitation->uuid; DB::transaction(function () use ($invitation): void { $user = User::whereEmail($invitation->email)->first(); if (filled($user)) { @@ -30,6 +32,11 @@ class Invitations extends Component $invitation->delete(); }); + auditLog('ui.team_invitation.revoked', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitationUuid, + 'invitation_email' => $invitationEmail, + ]); $this->refreshInvitations(); $this->dispatch('success', 'Invitation revoked.'); } catch (\Exception) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index a93bf8dd92..d6ea836075 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -103,6 +103,13 @@ class InviteLink extends Component 'link' => $link, 'via' => $sendEmail ? 'email' : 'link', ]); + auditLog('ui.team_invitation.created', [ + 'team_id' => currentTeam()->id, + 'invitation_uuid' => $invitation->uuid, + 'invitation_email' => $invitation->email, + 'role' => $invitation->role, + 'via' => $invitation->via, + ]); if ($sendEmail) { $mail = new MailMessage; $mail->view('emails.invitation-link', [ diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 38c932c39d..d99fd2eb1b 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -30,6 +30,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::ADMIN); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -50,6 +51,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::OWNER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -70,6 +72,7 @@ class Member extends Component $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + $this->auditRoleUpdate($teamId, Role::MEMBER); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -90,6 +93,12 @@ class Member extends Component $this->member->teams()->detach($teamId); RevokeUserTeamTokens::forUserTeam($this->member, $teamId); }); + auditLog('ui.team_member.removed', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + ]); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); @@ -103,4 +112,15 @@ class Member extends Component { return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role; } + + private function auditRoleUpdate(int $teamId, Role $role): void + { + auditLog('ui.team_member.role_updated', [ + 'team_id' => $teamId, + 'member_id' => $this->member->id, + 'member_name' => $this->member->name, + 'member_email' => $this->member->email, + 'role' => $role->value, + ]); + } } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 6bb4c5e908..116db1eed1 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -47,7 +47,7 @@ class Index extends Component return [ 'name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'), - 'uuid' => data_get($container, 'Names'), + 'uuid' => $server->uuid.':'.data_get($container, 'Names'), 'status' => data_get_str($container, 'State')->lower(), 'server' => $server, 'server_uuid' => $server->uuid, diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..e7c2c1d90e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -7,11 +7,16 @@ use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; use App\Services\DeploymentConfiguration\ConfigurationDiffer; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; +use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; use App\Traits\HasNoindexDomains; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Database\Factories\ApplicationFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -121,10 +126,8 @@ use Symfony\Component\Yaml\Yaml; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; - /** @use HasFactory */ - use HasFactory; + use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes; public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; @@ -135,6 +138,7 @@ class Application extends BaseModel 'description', 'fqdn', 'noindex_domains', + 'domain_port_overrides', 'git_repository', 'git_branch', 'git_commit_sha', @@ -213,6 +217,8 @@ class Application extends BaseModel 'last_online_at', 'restart_count', 'max_restart_count', + 'restart_limit_reached', + 'container_present', 'last_restart_at', 'last_restart_type', 'uuid', @@ -244,6 +250,7 @@ class Application extends BaseModel 'docker_compose_raw', 'custom_labels', 'domain_dns_statuses', + 'domain_port_overrides', ]; protected function casts(): array @@ -256,8 +263,11 @@ class Application extends BaseModel 'manual_webhook_secret_gitea' => 'encrypted', 'noindex_domains' => 'array', 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'restart_count' => 'integer', 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'container_present' => 'boolean', 'last_restart_at' => 'datetime', ]; } @@ -282,6 +292,9 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -382,6 +395,7 @@ class Application extends BaseModel $application->persistentStorages()->delete(); $application->environment_variables()->delete(); $application->environment_variables_preview()->delete(); + $application->secretManagerLink()->delete(); foreach ($application->scheduled_tasks as $task) { $task->delete(); } @@ -605,10 +619,8 @@ class Application extends BaseModel public function stoppedAfterRestartLimit(): bool { return str($this->status)->startsWith('exited') - && ($this->restart_count ?? 0) > 0 - && ($this->max_restart_count ?? 0) > 0 - && $this->restart_count >= $this->max_restart_count - && $this->last_restart_type === 'crash'; + && $this->container_present === true + && $this->restart_limit_reached === true; } public function taskLink($task_uuid) @@ -739,24 +751,20 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/commit/{$link}"; } - if (str($this->git_repository)->contains('bitbucket')) { - $git_repository = str_replace('.git', '', $this->git_repository); - $url = Url::fromString($git_repository); - $url = $url->withUserInfo(''); - $url = $url->withPath($url->getPath().'/commits/'.$link); - return $url->__toString(); - } + $git_repository = $this->git_repository; if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - if (data_get($this, 'source.html_url')) { - return "{$this->source->html_url}/{$git_repository}/commit/{$link}"; - } - - return "{$git_repository}/commit/{$link}"; + $git_repository = preg_replace('/^git@([^:]+):/', 'https://$1/', $git_repository); + } elseif (str($this->git_repository)->startsWith('ssh://')) { + $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } - return $this->git_repository; + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); + $url = $url->withUserInfo(''); + $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; + $url = $url->withPath(Str::finish($url->getPath(), '/').$commitPath.'/'.$link); + + return $url->__toString(); } public function dockerfileLocation(): Attribute @@ -976,6 +984,46 @@ class Application extends BaseModel return $this->settings->is_static ? [80] : $this->ports_exposes_array; } + /** + * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * + * @return list + */ + public function availableInternalPorts(): array + { + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) + ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) + ->map(fn (mixed $port): int => (int) $port); + + foreach ($this->domain_port_overrides ?? [] as $port) { + if (is_numeric($port) && (int) $port > 0) { + $ports->push((int) $port); + } + } + + foreach (explode(',', (string) $this->fqdn) as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + $legacyPort = DomainUrlParts::split($url)['port'] ?? ''; + if ($legacyPort !== '' && is_numeric($legacyPort) && (int) $legacyPort > 0) { + $ports->push((int) $legacyPort); + } + } + + return $ports->unique()->sort()->values()->all(); + } + + public function portRequiresConfirmation(?int $port): bool + { + if ($port === null || $port <= 0) { + return false; + } + + return ! in_array($port, $this->availableInternalPorts(), true); + } + public function detectPortFromEnvironment(?bool $isPreview = false): ?int { $envVars = $isPreview diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index ee190532c4..f16f7f8f96 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\EncryptedArrayCast; +use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -44,6 +45,44 @@ use OpenApi\Attributes as OA; )] class ApplicationDeploymentQueue extends Model { + protected static function booted(): void + { + static::created(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() || ! $deployment->rollback) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.application.rollback", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + 'commit' => $deployment->commit, + ]); + }); + + static::updated(function (ApplicationDeploymentQueue $deployment): void { + if (! auth()->check() + || ! $deployment->wasChanged('status') + || $deployment->status !== ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + return; + } + + $application = $deployment->application; + $source = $deployment->is_api ? 'api' : 'ui'; + + auditLog("{$source}.deployment.cancelled", [ + 'team_id' => $application?->team()?->id, + 'application_uuid' => $application?->uuid, + 'application_name' => $application?->name, + 'deployment_uuid' => $deployment->deployment_uuid, + ]); + }); + } + protected $fillable = [ 'application_id', 'deployment_uuid', diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 6998211eea..bffbdab621 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -2,13 +2,16 @@ namespace App\Models; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\SoftDeletes; +use RuntimeException; use Spatie\Url\Url; class ApplicationPreview extends BaseModel { - use SoftDeletes; + use HasRestartLimit, SoftDeletes; protected $fillable = [ 'uuid', @@ -22,15 +25,23 @@ class ApplicationPreview extends BaseModel 'docker_compose_domains', 'docker_registry_image_tag', 'last_online_at', + 'domain_dns_statuses', + 'domain_port_overrides', + ]; + + protected $hidden = [ + 'domain_port_overrides', ]; protected $casts = [ 'pull_request_id' => 'integer', + 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', ]; - protected static function booted() + protected static function booted(): void { - static::forceDeleting(function ($preview) { + static::forceDeleting(function (ApplicationPreview $preview): void { $server = $preview->application->destination->server; $application = $preview->application; @@ -57,10 +68,19 @@ class ApplicationPreview extends BaseModel }); } else { // Regular application volume cleanup - $persistentStorages = $preview->persistentStorages()->get() ?? collect(); - if ($persistentStorages->count() > 0) { - foreach ($persistentStorages as $storage) { - instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); + $persistentStorages = $application->persistentStorages() + ->get() + ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path) + && $storage->is_preview_suffix_enabled); + + foreach ($persistentStorages as $storage) { + $volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id); + try { + instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server); + } catch (RuntimeException $exception) { + if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) { + throw $exception; + } } } } @@ -72,6 +92,14 @@ class ApplicationPreview extends BaseModel if ($preview->isDirty('status')) { $preview->last_online_at = now(); } + if ($preview->isDirty('fqdn')) { + if ($preview->fqdn === '') { + $preview->fqdn = null; + } + $normalized = DomainPortOverrides::normalize($preview->fqdn, $preview->domain_port_overrides); + $preview->fqdn = $normalized['fqdn']; + $preview->domain_port_overrides = $normalized['overrides']; + } }); } @@ -90,39 +118,42 @@ class ApplicationPreview extends BaseModel return $this->belongsTo(Application::class); } + public function restartLimitMaximum(): int + { + return $this->application->max_restart_count ?? $this->max_restart_count ?? 0; + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); } - public function generate_preview_fqdn() + public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false) { - if ($this->application->fqdn) { - if (str($this->application->fqdn)->contains(',')) { - $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); - } else { - $url = Url::fromString($this->application->fqdn); - } - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $this->fqdn = $preview_fqdn; + $applicationFqdn = $this->application->fqdn; + if (! $applicationFqdn && $generateWithoutApplicationDomain) { + $applicationFqdn = generateUrl( + server: $this->application->destination->server, + random: $this->application->uuid, + ); + } + + if ($applicationFqdn) { + $sourceDomain = str($applicationFqdn)->contains(',') + ? str($applicationFqdn)->explode(',')[0] + : $applicationFqdn; + $generated = $this->generatedPreviewDomain((string) $sourceDomain); + $this->fqdn = $generated['url']; + $this->domain_port_overrides = filled($generated['port']) + ? [$generated['url'] => $generated['port']] + : null; $this->save(); } return $this; } - public function generate_preview_fqdn_compose() + public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false) { $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; @@ -161,11 +192,19 @@ class ApplicationPreview extends BaseModel ->all(); $docker_compose_domains = []; + $previewPortOverrides = []; foreach ($serviceNames as $service_name) { $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); - // If domain string is empty or null, don't auto-generate domain - // Only generate domains when main app already has domains set + if (empty($domain_string)) { + if ($generateWithoutApplicationDomain) { + $domain_string = generateUrl( + server: $this->application->destination->server, + random: str($service_name)->slug().'-'.$this->application->uuid, + ); + } + } + if (empty($domain_string)) { $docker_compose_domains = putComposeServiceDomain( $docker_compose_domains, @@ -185,20 +224,11 @@ class ApplicationPreview extends BaseModel continue; } - $url = Url::fromString($domain); - $template = $this->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $urlPath = $url->getPath(); - $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; - $preview_domains[] = $preview_fqdn; + $generated = $this->generatedPreviewDomain((string) $domain); + $preview_domains[] = $generated['url']; + if (filled($generated['port'])) { + $previewPortOverrides[$generated['url']] = $generated['port']; + } } $docker_compose_domains = putComposeServiceDomain( @@ -222,10 +252,36 @@ class ApplicationPreview extends BaseModel ->implode(','); $this->fqdn = ! empty($allDomains) ? $allDomains : null; + $this->domain_port_overrides = $previewPortOverrides ?: null; $this->save(); } + /** + * @return array{url: string, port: ?int} + */ + public function generatedPreviewDomain(string $sourceDomain): array + { + $url = Url::fromString($sourceDomain); + $template = $this->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; + $random = new_public_id(); + $previewFqdn = str_replace('{{random}}', $random, $template); + $previewFqdn = str_replace('{{domain}}', $host, $previewFqdn); + $previewFqdn = str_replace('{{pr_id}}', (string) $this->pull_request_id, $previewFqdn); + $previewUrl = "{$schema}://{$previewFqdn}{$path}"; + $sourceCanonical = DomainPortOverrides::withoutPort($sourceDomain); + $port = $url->getPort() ?? ($this->application->domain_port_overrides[$sourceCanonical] ?? null); + + return [ + 'url' => $previewUrl, + 'port' => $port !== null ? (int) $port : null, + ]; + } + /** * Original compose service names for this preview (PR suffix stripped), excluding database images. * diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php new file mode 100644 index 0000000000..2383dee267 --- /dev/null +++ b/app/Models/AuditEvent.php @@ -0,0 +1,211 @@ + 'array', + 'created_at' => 'datetime', + ]; + } + + public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder + { + return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void { + $query->where('team_id', $teamId) + ->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id')); + }); + } + + public function scopeFiltered( + Builder $query, + string $search = '', + string $action = 'all', + string $source = 'all', + bool $searchSensitiveFields = true, + ): Builder { + return $query + ->when($action !== 'all', fn (Builder $query) => $query->where('action', $action)) + ->when($source !== 'all', fn (Builder $query) => $query->where('source', $source)) + ->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where(function (Builder $query) use ($search, $searchSensitiveFields): void { + $query->where('event', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%") + ->orWhere('resource_name', 'like', "%{$search}%") + ->orWhere('actor_name', 'like', "%{$search}%") + ->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%")); + }); + }); + } + + public function scopeLatestFirst(Builder $query): Builder + { + return $query->latest('created_at')->latest('id'); + } + + /** + * @param array $context + */ + public static function record(string $event, array $context = []): void + { + try { + $attributes = self::attributesFor($event, $context); + + DB::afterCommit(function () use ($attributes): void { + defer(function () use ($attributes): void { + try { + self::query()->create($attributes); + } catch (Throwable $exception) { + Log::warning('Audit event persistence failed', [ + 'event' => $attributes['event'], + 'exception' => $exception::class, + ]); + } + })->always(); + }); + } catch (Throwable $exception) { + Log::warning('Audit event preparation failed', [ + 'event' => $event, + 'exception' => $exception::class, + ]); + } + } + + /** + * @param array $context + * @return array + */ + private static function attributesFor(string $event, array $context): array + { + $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') + ?? data_get($context, 'team_id') + ?? currentTeam()?->id + ?? self::teamIdFromContext($context); + + $parts = explode('.', $event); + $source = $parts[0] ?? 'system'; + $resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null); + $action = data_get($context, 'action') ?? (end($parts) ?: 'event'); + $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); + $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $actorType = match (true) { + in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, + $token !== null => 'api_token', + $user !== null => 'user', + default => 'system', + }; + + return [ + 'team_id' => $teamId, + 'event' => $event, + 'source' => $source, + 'action' => $action, + 'actor_type' => $actorType, + 'actor_id' => $user?->id, + 'actor_name' => $user?->name, + 'actor_email' => $user?->email, + 'actor_token_id' => $token?->id, + 'actor_token_name' => $token?->name, + 'resource_type' => $resourceType, + 'resource_uuid' => $resourceUuid, + 'resource_name' => $resourceName, + 'description' => data_get($context, 'audit_description') + ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), + 'metadata' => self::redact($context), + 'ip_address' => app()->bound('request') ? request()->ip() : null, + 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, + ]; + } + + /** + * @param array $context + */ + private static function teamIdFromContext(array $context): ?int + { + $applicationUuid = data_get($context, 'application_uuid'); + if (! is_string($applicationUuid) || $applicationUuid === '') { + return null; + } + + return Application::query() + ->where('uuid', $applicationUuid) + ->first()?->team()?->id; + } + + public static function pruneExpired(): int + { + return self::query() + ->where('created_at', '<', now()->subDays(90)) + ->delete(); + } + + /** + * @param array $context + */ + private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed + { + if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { + return data_get($context, $preferredKey); + } + + $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); + + return $key ? data_get($context, $key) : null; + } + + private static function redact(mixed $value, ?string $key = null): mixed + { + if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { + return '[REDACTED]'; + } + + if (! is_array($value)) { + return $value; + } + + return collect($value) + ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ + $itemKey => self::redact($item, (string) $itemKey), + ]) + ->all(); + } +} diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index 135c921f61..48d5b5d293 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -20,6 +20,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications', 'deployment_failure_discord_notifications', 'status_change_discord_notifications', + 'restart_limit_reached_discord_notifications', 'backup_success_discord_notifications', 'backup_failure_discord_notifications', 'scheduled_task_success_discord_notifications', @@ -45,6 +46,7 @@ class DiscordNotificationSettings extends Model 'deployment_success_discord_notifications' => 'boolean', 'deployment_failure_discord_notifications' => 'boolean', 'status_change_discord_notifications' => 'boolean', + 'restart_limit_reached_discord_notifications' => 'boolean', 'backup_success_discord_notifications' => 'boolean', 'backup_failure_discord_notifications' => 'boolean', 'scheduled_task_success_discord_notifications' => 'boolean', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 814d053395..3b04b482af 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -31,6 +31,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', 'status_change_email_notifications', + 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', 'backup_failure_email_notifications', 'scheduled_task_success_email_notifications', @@ -73,6 +74,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', + 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', 'backup_failure_email_notifications' => 'boolean', 'scheduled_task_success_email_notifications' => 'boolean', diff --git a/app/Models/Environment.php b/app/Models/Environment.php index 1364d874a1..e98f13d21f 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -21,8 +22,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..e7dd8564bc 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -34,6 +35,8 @@ use OpenApi\Attributes as OA; )] class EnvironmentVariable extends BaseModel { + use Auditable; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; protected $attributes = [ @@ -249,17 +252,21 @@ class EnvironmentVariable extends BaseModel protected function isShared(): Attribute { return Attribute::make( - get: function () { - $type = str($this->value)->after('{{')->before('.')->value; - if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) { - return true; - } - - return false; - } + get: fn () => $this->isSharedReference(), ); } + private function isSharedReference(): bool + { + if (blank($this->value)) { + return false; + } + + $types = implode('|', SHARED_VARIABLE_TYPES); + + return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1; + } + public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource, $server); @@ -406,8 +413,6 @@ class EnvironmentVariable extends BaseModel protected function updateIsShared(): void { - $type = str($this->value)->after('{{')->before('.')->value; - $isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}'); - $this->is_shared = $isShared; + $this->is_shared = $this->isSharedReference(); } } diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 564fbcf6a4..96c7a2d39d 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\DB; class GithubApp extends BaseModel { + use Auditable; + public function delete(): ?bool { return DB::transaction(fn () => parent::delete()); diff --git a/app/Models/GitlabApp.php b/app/Models/GitlabApp.php index c6c2b84095..727ec77cd1 100644 --- a/app/Models/GitlabApp.php +++ b/app/Models/GitlabApp.php @@ -2,12 +2,15 @@ namespace App\Models; +use App\Traits\Auditable; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Facades\Crypt; class GitlabApp extends BaseModel { + use Auditable; + protected $fillable = [ 'name', 'organization', diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 20541f6139..53b4dd6f4a 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -3,15 +3,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class IntegrationToken extends BaseModel { + public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault']; + + public const PROVIDER_NAMES = [ + 'cloudflare' => 'Cloudflare', + 'doppler' => 'Doppler', + 'infisical' => 'Infisical', + 'vault' => 'HashiCorp Vault', + ]; + protected $fillable = [ 'team_id', 'provider', 'name', 'token', 'capabilities', + 'metadata', ]; protected $hidden = [ @@ -23,6 +34,7 @@ class IntegrationToken extends BaseModel return [ 'token' => 'encrypted', 'capabilities' => 'array', + 'metadata' => 'array', ]; } @@ -31,6 +43,34 @@ class IntegrationToken extends BaseModel return $this->belongsTo(Team::class); } + public function secretManagerLinks(): HasMany + { + return $this->hasMany(SecretManagerLink::class); + } + + public function isSecretManager(): bool + { + return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true); + } + + public function providerName(): string + { + return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider); + } + + public function dopplerTokenType(): ?string + { + if ($this->provider !== 'doppler') { + return null; + } + + return match (true) { + str_starts_with($this->token, 'dp.st.') => 'service', + str_starts_with($this->token, 'dp.sa.') => 'service_account', + default => null, + }; + } + public static function ownedByCurrentTeam() { return self::query()->where('team_id', currentTeam()->id); diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 3f72642a57..43aa310cbc 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -31,7 +32,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use HasFactory, HasSafeStringAttribute, WithRateLimiting; + use Auditable, HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 57dbf823ce..65c21c1e78 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,8 +21,8 @@ use OpenApi\Attributes as OA; )] class Project extends BaseModel { + use Auditable, HasFactory; use ClearsGlobalSearchCache; - use HasFactory; use HasSafeStringAttribute; protected $fillable = [ @@ -63,7 +64,9 @@ class Project extends BaseModel ]); }); static::deleting(function ($project) { - $project->environments()->delete(); + foreach ($project->environments()->get() as $environment) { + $environment->delete(); + } $project->settings()->delete(); $shared_variables = $project->environment_variables(); foreach ($shared_variables as $shared_variable) { diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index dd0d81cc0e..2ab6693142 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -21,6 +21,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications', 'deployment_failure_pushover_notifications', 'status_change_pushover_notifications', + 'restart_limit_reached_pushover_notifications', 'backup_success_pushover_notifications', 'backup_failure_pushover_notifications', 'scheduled_task_success_pushover_notifications', @@ -47,6 +48,7 @@ class PushoverNotificationSettings extends Model 'deployment_success_pushover_notifications' => 'boolean', 'deployment_failure_pushover_notifications' => 'boolean', 'status_change_pushover_notifications' => 'boolean', + 'restart_limit_reached_pushover_notifications' => 'boolean', 'backup_success_pushover_notifications' => 'boolean', 'backup_failure_pushover_notifications' => 'boolean', 'scheduled_task_success_pushover_notifications' => 'boolean', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e4b1e2fd68..3c0d9e7e95 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Rules\SafeWebhookUrl; use App\Rules\ValidS3BucketName; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Validator; class S3Storage extends BaseModel { - use HasFactory, HasSafeStringAttribute; + use Auditable, HasFactory, HasSafeStringAttribute; private const CONNECTION_TIMEOUT_SECONDS = 15; diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php new file mode 100644 index 0000000000..34e4e90d12 --- /dev/null +++ b/app/Models/SecretManagerLink.php @@ -0,0 +1,122 @@ + 'array', + ]; + } + + public function resourceable(): MorphTo + { + return $this->morphTo(); + } + + public function integrationToken(): BelongsTo + { + return $this->belongsTo(IntegrationToken::class); + } + + /** + * Fetch the secrets from the remote manager. Values live only in memory. + * + * @return array + */ + public function fetchSecrets(): array + { + $token = $this->integrationToken; + $settings = $this->settings ?? []; + $metadata = $token->metadata ?? []; + + return match ($token->provider) { + 'doppler' => (new DopplerService($token->token))->fetchSecrets( + data_get($settings, 'project'), + data_get($settings, 'config'), + ), + 'infisical' => (new InfisicalService( + data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token->token, + ))->fetchSecrets( + (string) data_get($settings, 'project_id'), + (string) data_get($settings, 'environment'), + (string) data_get($settings, 'secret_path', '/'), + ), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token->token, + data_get($metadata, 'namespace'), + ))->fetchSecrets( + (string) data_get($settings, 'mount', 'secret'), + (string) data_get($settings, 'path'), + ), + default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."), + }; + } + + /** + * Create one {{vault.KEY}} reference variable per remote key that has no + * variable with that key yet. Only key names touch the database. + * + * @return list The keys that were imported + */ + public function importMissingReferences(): array + { + $keys = array_keys($this->fetchSecrets()); + sort($keys); + + $existing = $this->resourceable->environment_variables()->pluck('key')->flip(); + $imported = []; + + foreach ($keys as $key) { + if (isset($existing[$key])) { + continue; + } + + $this->resourceable->environment_variables()->create([ + 'key' => $key, + 'value' => '{{vault.'.$key.'}}', + ]); + $imported[] = $key; + } + + return $imported; + } + + /** Short human-readable description of the remote source for the UI. */ + public function sourceSummary(): string + { + $settings = $this->settings ?? []; + + return match ($this->integrationToken->provider) { + 'doppler' => trim(implode('/', array_filter([ + data_get($settings, 'project'), + data_get($settings, 'config'), + ])), '/') ?: 'token scope', + 'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'), + 'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'), + default => '', + }; + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 31f906ec16..912f79fb4b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -21,6 +21,7 @@ use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; @@ -111,7 +112,7 @@ use Symfony\Component\Yaml\Yaml; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet diff --git a/app/Models/Service.php b/app/Models/Service.php index 0da97b301a..11756f3c7e 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -4,8 +4,12 @@ namespace App\Models; use App\Enums\ProcessStatus; use App\Services\ContainerStatusAggregator; +use App\Support\DomainPortOverrides; +use App\Traits\Auditable; + use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -43,7 +47,7 @@ use Symfony\Component\Yaml\Yaml; )] class Service extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes; private static $parserVersion = '5'; @@ -92,11 +96,18 @@ class Service extends BaseModel public function isConfigurationChanged(bool $save = false) { - $domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray(); + $applications = $this->applications()->get(); + $domains = $applications->pluck('fqdn')->sort()->toArray(); $domains = implode(',', $domains); - $noindexDomains = $this->applications()->get()->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $noindexDomains = $applications->pluck('noindex_domains')->flatten()->filter()->sort()->implode(','); + $domainPortOverrides = $applications + ->mapWithKeys(fn (ServiceApplication $application): array => [ + $application->id => DomainPortOverrides::sorted($application->domain_port_overrides), + ]) + ->sortKeys() + ->all(); - $applicationImages = $this->applications()->get()->pluck('image')->sort(); + $applicationImages = $applications->pluck('image')->sort(); $databaseImages = $this->databases()->get()->pluck('image')->sort(); $images = $applicationImages->merge($databaseImages); $images = implode(',', $images->toArray()); @@ -105,7 +116,7 @@ class Service extends BaseModel $databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id'); $storages = $applicationStorages->merge($databaseStorages)->implode('updated_at'); - $newConfigHash = $images.$domains.$images.$storages.$noindexDomains; + $newConfigHash = $images.$domains.$images.$storages.$noindexDomains.json_encode($domainPortOverrides); $newConfigHash .= json_encode($this->environment_variables()->get('value')->makeVisible('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -1496,7 +1507,7 @@ class Service extends BaseModel { try { $services = get_service_templates(); - $serviceName = str($this->name)->beforeLast('-')->value(); + $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); @@ -1631,7 +1642,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$env->real_value}"); + $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); } if ($envs->count() === 0) { $commands[] = 'touch .env'; diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 9763fa894b..cf0faef5bd 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -2,7 +2,10 @@ namespace App\Models; +use App\Support\DomainPortOverrides; +use App\Support\DomainUrlParts; use App\Traits\HasNoindexDomains; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -10,7 +13,9 @@ use Symfony\Component\Yaml\Yaml; class ServiceApplication extends BaseModel { - use HasFactory, HasNoindexDomains, SoftDeletes; + use HasFactory, HasNoindexDomains, HasRestartLimit, SoftDeletes; + + protected $appends = ['url']; protected $fillable = [ 'service_id', @@ -21,6 +26,7 @@ class ServiceApplication extends BaseModel 'noindex_domains', 'redirect', 'domain_dns_statuses', + 'domain_port_overrides', 'ports', 'exposes', 'status', @@ -43,6 +49,7 @@ class ServiceApplication extends BaseModel */ protected $hidden = [ 'domain_dns_statuses', + 'domain_port_overrides', ]; protected $attributes = [ @@ -53,6 +60,7 @@ class ServiceApplication extends BaseModel { return [ 'domain_dns_statuses' => 'array', + 'domain_port_overrides' => 'array', 'noindex_domains' => 'array', 'is_force_https_enabled' => 'boolean', ]; @@ -70,6 +78,7 @@ class ServiceApplication extends BaseModel $service->last_online_at = now(); } if ($service->isDirty('fqdn')) { + $service->normalizeDomainPortOverrides(); $service->syncNoindexDomains(); } }); @@ -191,6 +200,45 @@ class ServiceApplication extends BaseModel ); } + /** + * Return the public URLs with their persisted internal port overrides. + */ + protected function url(): Attribute + { + return Attribute::make( + get: function (): ?string { + if (blank($this->fqdn)) { + return null; + } + + $overrides = $this->domain_port_overrides ?? []; + + return collect(explode(',', $this->fqdn)) + ->map(function (string $url) use ($overrides): string { + $url = trim($url); + $canonical = DomainPortOverrides::withoutPort($url); + $port = $overrides[$canonical] ?? null; + + if ($port === null) { + return $canonical; + } + + $parts = DomainUrlParts::split($canonical); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], (string) $port, $parts['path']); + }) + ->implode(','); + }, + ); + } + + public function setEditableUrls(?string $urls): void + { + $normalized = DomainPortOverrides::normalize($urls, null); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Extract port number from a given FQDN URL. * Returns null if no port is specified. @@ -212,6 +260,58 @@ class ServiceApplication extends BaseModel } } + /** + * True when saving this URL should confirm that it does not use the required template port. + */ + public function portRequiresConfirmation(string $fqdn, ?int $requiredPort, ?string $previousFqdn = null): bool + { + if ($requiredPort === null) { + return false; + } + + $fqdn = trim($fqdn); + if ($fqdn === '') { + return false; + } + + $canonical = DomainPortOverrides::withoutPort($fqdn); + $explicit = self::extractPortFromUrl($fqdn); + + if ($explicit === $requiredPort) { + return false; + } + + if ($explicit === null) { + $previous = collect(explode(',', (string) $previousFqdn)) + ->filter(); + $previousUrl = $previous->first( + fn (string $url): bool => DomainPortOverrides::withoutPort(trim($url)) === $canonical + ); + + if (is_string($previousUrl) && self::extractPortFromUrl($previousUrl) !== null) { + return true; + } + + return $previousUrl === null; + } + + $existingOverride = $this->domain_port_overrides[$canonical] ?? null; + + return (int) $existingOverride !== $explicit; + } + + public static function withoutPort(string $url): string + { + return DomainPortOverrides::withoutPort($url); + } + + protected function normalizeDomainPortOverrides(): void + { + $normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + } + /** * Check if all FQDNs have a port specified. */ @@ -276,6 +376,7 @@ class ServiceApplication extends BaseModel // Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment // (not variables that are merely referenced with ${VAR} syntax) $portFound = null; + $declaresHttpUrl = false; foreach ($environment as $key => $value) { if (is_int($key) && is_string($value)) { // List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value" @@ -284,6 +385,7 @@ class ServiceApplication extends BaseModel // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -298,6 +400,7 @@ class ServiceApplication extends BaseModel // Only process direct declarations if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) { + $declaresHttpUrl = true; // Parse to check if it has a port suffix $parsed = parseServiceEnvironmentVariable($envVarName->value()); if ($parsed['has_port'] && $parsed['port']) { @@ -314,8 +417,12 @@ class ServiceApplication extends BaseModel return $portFound; } - // No port-specific variables found for this service, return null - // (DO NOT fall back to service-level port, as that applies to all services) + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT + // suffix), such as WordPress, inherit the one-click template `# port:`. + if ($declaresHttpUrl) { + return $this->service->getRequiredPort(); + } + return null; } catch (\Throwable $e) { return null; diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 603d11a7f3..c932791a78 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -2,12 +2,13 @@ namespace App\Models; +use App\Traits\HasRestartLimit; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class ServiceDatabase extends BaseModel { - use HasFactory, SoftDeletes; + use HasFactory, HasRestartLimit, SoftDeletes; protected $fillable = [ 'service_id', diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index c70bf9f08a..086cc33e50 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -3,11 +3,14 @@ namespace App\Models; use App\Support\ValidationPatterns; +use App\Traits\Auditable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model { + use Auditable; + protected $fillable = [ // Core identification 'key', diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index 62603685e9..648869bafe 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -20,6 +20,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications', 'deployment_failure_slack_notifications', 'status_change_slack_notifications', + 'restart_limit_reached_slack_notifications', 'backup_success_slack_notifications', 'backup_failure_slack_notifications', 'scheduled_task_success_slack_notifications', @@ -44,6 +45,7 @@ class SlackNotificationSettings extends Model 'deployment_success_slack_notifications' => 'boolean', 'deployment_failure_slack_notifications' => 'boolean', 'status_change_slack_notifications' => 'boolean', + 'restart_limit_reached_slack_notifications' => 'boolean', 'backup_success_slack_notifications' => 'boolean', 'backup_failure_slack_notifications' => 'boolean', 'scheduled_task_success_slack_notifications' => 'boolean', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 7ca45cc3b7..a627d00aa6 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,17 +2,24 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 604a245fc7..e7e0a8c108 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -43,14 +43,20 @@ class StandaloneDocker extends BaseModel } $server = $newStandaloneDocker->server; - $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ - "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --driver overlay --attachable {$safeNetwork} >/dev/null", + $newStandaloneDocker->networkCreateCommand(), ], $server, false); ConnectProxyToNetworksJob::dispatchSync($server); }); } + public function networkCreateCommand(): string + { + $safeNetwork = escapeshellarg($this->network); + + return "docker network inspect {$safeNetwork} >/dev/null 2>&1 || docker network create --attachable {$safeNetwork} >/dev/null"; + } + public function setNetworkAttribute(string $value): void { if (! ValidationPatterns::isValidDockerNetwork($value)) { diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 769d9f00c4..8371bd0493 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,17 +2,22 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 15a1fe2f82..bbe55a4f5a 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,17 +2,22 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 378d36395d..d35509ce99 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,10 +2,13 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -13,7 +16,9 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 1010ca5f37..faf54e0e71 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,17 +2,22 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 90828bf012..5a1ecb8425 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,17 +2,22 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index e7db812858..e91539d55b 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,17 +2,22 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + protected $fillable = [ 'uuid', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 3262611903..674f7867fd 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,17 +2,24 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; +use App\Traits\HasRestartLimit; use App\Traits\HasSafeStringAttribute; +use App\Traits\HasSecretManager; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + + use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasRestartLimit, HasSafeStringAttribute, HasSecretManager, SoftDeletes; + + protected array $auditExclude = ['last_online_at']; + protected $fillable = [ 'uuid', diff --git a/app/Models/Tag.php b/app/Models/Tag.php index d5cccabd8f..30844b2bb6 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\Auditable; use App\Traits\HasSafeStringAttribute; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -18,7 +19,7 @@ use OpenApi\Attributes as OA; )] class Tag extends BaseModel { - use HasSafeStringAttribute; + use Auditable, HasSafeStringAttribute; protected $fillable = [ 'name', diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..6ec79f2046 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; +use App\Traits\Auditable; use App\Traits\HasNotificationSettings; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -39,7 +40,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; + use Auditable, HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $fillable = [ 'name', @@ -86,8 +87,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen } // Transfer instance-wide sources to root team so they remain available - GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); - GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]); + $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() + ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); + foreach ($systemWideSources as $source) { + $source->update(['team_id' => 0]); + } // Delete non-instance-wide sources owned by this team $teamSources = GithubApp::where('team_id', $team->id)->get() @@ -275,13 +279,22 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(TeamInvitation::class); } - public function isEmpty() + /** + * @return array + */ + public function deletionBlockers(): array { - if ($this->projects()->count() === 0 && $this->servers()->count() === 0 && $this->privateKeys()->count() === 0 && $this->sources()->count() === 0) { - return true; - } + return array_filter([ + 'projects' => $this->projects()->count(), + 'servers' => $this->servers()->count(), + 'sources' => GithubApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count() + + GitlabApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count(), + ]); + } - return false; + public function isEmpty(): bool + { + return $this->deletionBlockers() === []; } public function projects() diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 8c644f9bcf..3376e239d6 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -21,6 +21,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications', 'deployment_failure_telegram_notifications', 'status_change_telegram_notifications', + 'restart_limit_reached_telegram_notifications', 'backup_success_telegram_notifications', 'backup_failure_telegram_notifications', 'scheduled_task_success_telegram_notifications', @@ -36,6 +37,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -55,6 +57,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id', 'telegram_notifications_deployment_failure_thread_id', 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_restart_limit_reached_thread_id', 'telegram_notifications_backup_success_thread_id', 'telegram_notifications_backup_failure_thread_id', 'telegram_notifications_scheduled_task_success_thread_id', @@ -76,6 +79,7 @@ class TelegramNotificationSettings extends Model 'deployment_success_telegram_notifications' => 'boolean', 'deployment_failure_telegram_notifications' => 'boolean', 'status_change_telegram_notifications' => 'boolean', + 'restart_limit_reached_telegram_notifications' => 'boolean', 'backup_success_telegram_notifications' => 'boolean', 'backup_failure_telegram_notifications' => 'boolean', 'scheduled_task_success_telegram_notifications' => 'boolean', @@ -90,6 +94,7 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_deployment_success_thread_id' => 'encrypted', 'telegram_notifications_deployment_failure_thread_id' => 'encrypted', 'telegram_notifications_status_change_thread_id' => 'encrypted', + 'telegram_notifications_restart_limit_reached_thread_id' => 'encrypted', 'telegram_notifications_backup_success_thread_id' => 'encrypted', 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index c6a81b50a8..7ffd20a8c3 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -20,6 +20,7 @@ class WebhookNotificationSettings extends Model 'deployment_success_webhook_notifications', 'deployment_failure_webhook_notifications', 'status_change_webhook_notifications', + 'restart_limit_reached_webhook_notifications', 'backup_success_webhook_notifications', 'backup_failure_webhook_notifications', 'scheduled_task_success_webhook_notifications', @@ -46,6 +47,7 @@ class WebhookNotificationSettings extends Model 'deployment_success_webhook_notifications' => 'boolean', 'deployment_failure_webhook_notifications' => 'boolean', 'status_change_webhook_notifications' => 'boolean', + 'restart_limit_reached_webhook_notifications' => 'boolean', 'backup_success_webhook_notifications' => 'boolean', 'backup_failure_webhook_notifications' => 'boolean', 'scheduled_task_success_webhook_notifications' => 'boolean', diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 635dfdbdce..687fd30867 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -2,7 +2,8 @@ namespace App\Notifications\Application; -use App\Models\Application; +use App\Models\ApplicationPreview; +use App\Models\BaseModel; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; @@ -27,26 +28,40 @@ class RestartLimitReached extends CustomEmailNotification public int $max_restart_count; - public function __construct(public Application $resource) + public function __construct(public BaseModel $resource) { $this->onQueue('high'); $this->afterCommit(); - $this->resource_name = data_get($resource, 'name'); - $this->project_uuid = data_get($resource, 'environment.project.uuid'); - $this->environment_uuid = data_get($resource, 'environment.uuid'); - $this->environment_name = data_get($resource, 'environment.name'); + $environment = data_get($resource, 'environment') + ?? data_get($resource, 'application.environment') + ?? data_get($resource, 'service.environment'); + $this->resource_name = $resource instanceof ApplicationPreview + ? data_get($resource, 'application.name').' PR #'.$resource->pull_request_id + : data_get($resource, 'name'); + $this->project_uuid = data_get($environment, 'project.uuid'); + $this->environment_uuid = data_get($environment, 'uuid'); + $this->environment_name = data_get($environment, 'name'); $this->fqdn = data_get($resource, 'fqdn', null); $this->restart_count = $resource->restart_count; - $this->max_restart_count = $resource->max_restart_count; + $this->max_restart_count = method_exists($resource, 'restartLimitMaximum') + ? $resource->restartLimitMaximum() + : $resource->max_restart_count; if (str($this->fqdn)->explode(',')->count() > 1) { $this->fqdn = str($this->fqdn)->explode(',')->first(); } - $this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}"; + $service = data_get($resource, 'service'); + $this->resource_url = match (true) { + method_exists($this->resource, 'link') => $this->resource->link(), + $resource instanceof ApplicationPreview => $resource->application->link(), + is_object($service) && method_exists($service, 'link') => $service->link(), + default => null, + }; + $this->resource_url ??= base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}"; } public function via(object $notifiable): array { - return $notifiable->getEnabledChannels('status_change'); + return $notifiable->getEnabledChannels('restart_limit_reached'); } public function toMail(): MailMessage @@ -68,7 +83,7 @@ class RestartLimitReached extends CustomEmailNotification { return new DiscordMessage( title: ':warning: Restart limit reached', - description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})", + description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Resource in Coolify]({$this->resource_url})", color: DiscordMessage::errorColor(), isCritical: true, ); @@ -82,7 +97,7 @@ class RestartLimitReached extends CustomEmailNotification 'message' => $message, 'buttons' => [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -99,7 +114,7 @@ class RestartLimitReached extends CustomEmailNotification message: $message, buttons: [ [ - 'text' => 'Open Application in Coolify', + 'text' => 'Open Resource in Coolify', 'url' => $this->resource_url, ], ], @@ -110,10 +125,13 @@ class RestartLimitReached extends CustomEmailNotification { $title = 'Restart limit reached'; $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})"; + $environment = data_get($this->resource, 'environment') + ?? data_get($this->resource, 'application.environment') + ?? data_get($this->resource, 'service.environment'); - $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name'); + $description .= "\n\n*Project:* ".data_get($environment, 'project.name'); $description .= "\n*Environment:* {$this->environment_name}"; - $description .= "\n*Application URL:* {$this->resource_url}"; + $description .= "\n*Resource URL:* {$this->resource_url}"; return new SlackMessage( title: $title, @@ -130,6 +148,8 @@ class RestartLimitReached extends CustomEmailNotification 'event' => 'restart_limit_reached', 'application_name' => $this->resource_name, 'application_uuid' => $this->resource->uuid, + 'resource_name' => $this->resource_name, + 'resource_uuid' => $this->resource->uuid, 'restart_count' => $this->restart_count, 'max_restart_count' => $this->max_restart_count, 'url' => $this->resource_url, diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index c2fa3ff10d..4f311bf681 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -3,6 +3,21 @@ namespace App\Notifications\Channels; use App\Jobs\SendMessageToTelegramJob; +use App\Notifications\Application\DeploymentFailed; +use App\Notifications\Application\DeploymentSuccess; +use App\Notifications\Application\RestartLimitReached; +use App\Notifications\Application\StatusChanged; +use App\Notifications\Container\ContainerRestarted; +use App\Notifications\Database\BackupFailed; +use App\Notifications\Database\BackupSuccess; +use App\Notifications\ScheduledTask\TaskFailed; +use App\Notifications\ScheduledTask\TaskSuccess; +use App\Notifications\Server\DockerCleanupFailed; +use App\Notifications\Server\DockerCleanupSuccess; +use App\Notifications\Server\HighDiskUsage; +use App\Notifications\Server\Reachable; +use App\Notifications\Server\ServerPatchCheck; +use App\Notifications\Server\Unreachable; class TelegramChannel { @@ -17,24 +32,24 @@ class TelegramChannel $chatId = $settings->telegram_chat_id; $threadId = match (get_class($notification)) { - \App\Notifications\Application\DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, - \App\Notifications\Application\DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, - \App\Notifications\Application\StatusChanged::class, - \App\Notifications\Container\ContainerRestarted::class, - \App\Notifications\Container\ContainerStopped::class => $settings->telegram_notifications_status_change_thread_id, + DeploymentSuccess::class => $settings->telegram_notifications_deployment_success_thread_id, + DeploymentFailed::class => $settings->telegram_notifications_deployment_failure_thread_id, + StatusChanged::class, + ContainerRestarted::class => $settings->telegram_notifications_status_change_thread_id, + RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id, - \App\Notifications\Database\BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, - \App\Notifications\Database\BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, + BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, + BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, - \App\Notifications\ScheduledTask\TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, - \App\Notifications\ScheduledTask\TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, + TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, + TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, - \App\Notifications\Server\DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, - \App\Notifications\Server\DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, - \App\Notifications\Server\HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, - \App\Notifications\Server\Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, - \App\Notifications\Server\Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, - \App\Notifications\Server\ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, + DockerCleanupSuccess::class => $settings->telegram_notifications_docker_cleanup_success_thread_id, + DockerCleanupFailed::class => $settings->telegram_notifications_docker_cleanup_failure_thread_id, + HighDiskUsage::class => $settings->telegram_notifications_server_disk_usage_thread_id, + Unreachable::class => $settings->telegram_notifications_server_unreachable_thread_id, + Reachable::class => $settings->telegram_notifications_server_reachable_thread_id, + ServerPatchCheck::class => $settings->telegram_notifications_server_patch_thread_id, default => null, }; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php deleted file mode 100644 index f518cd2fdd..0000000000 --- a/app/Notifications/Container/ContainerStopped.php +++ /dev/null @@ -1,123 +0,0 @@ -onQueue('high'); - } - - public function via(object $notifiable): array - { - return $notifiable->getEnabledChannels('status_change'); - } - - public function toMail(): MailMessage - { - $mail = new MailMessage; - $mail->subject("Coolify: A resource has been stopped unexpectedly on {$this->server->name}"); - $mail->view('emails.container-stopped', [ - 'containerName' => $this->name, - 'serverName' => $this->server->name, - 'url' => $this->url, - ]); - - return $mail; - } - - public function toDiscord(): DiscordMessage - { - $message = new DiscordMessage( - title: ':cross_mark: Resource stopped', - description: "{$this->name} has been stopped unexpectedly on {$this->server->name}.", - color: DiscordMessage::errorColor(), - ); - - if ($this->url) { - $message->addField('Resource', '[Link]('.$this->url.')'); - } - - return $message; - } - - public function toTelegram(): array - { - $message = "Coolify: A resource ($this->name) has been stopped unexpectedly on {$this->server->name}"; - $payload = [ - 'message' => $message, - ]; - if ($this->url) { - $payload['buttons'] = [ - [ - [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ], - ], - ]; - } - - return $payload; - } - - public function toPushover(): PushoverMessage - { - $buttons = []; - if ($this->url) { - $buttons[] = [ - 'text' => 'Open Application in Coolify', - 'url' => $this->url, - ]; - } - - return new PushoverMessage( - title: 'Resource stopped', - level: 'error', - message: "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}", - buttons: $buttons, - ); - } - - public function toSlack(): SlackMessage - { - $title = 'Resource stopped'; - $description = "A resource ({$this->name}) has been stopped unexpectedly on {$this->server->name}"; - - if ($this->url) { - $description .= "\n*Resource URL:* {$this->url}"; - } - - return new SlackMessage( - title: $title, - description: $description, - color: SlackMessage::errorColor() - ); - } - - public function toWebhook(): array - { - $data = [ - 'success' => false, - 'message' => 'Resource stopped unexpectedly', - 'event' => 'container_stopped', - 'container_name' => $this->name, - 'server_name' => $this->server->name, - 'server_uuid' => $this->server->uuid, - ]; - - if ($this->url) { - $data['url'] = $this->url; - } - - return $data; - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index dfa3bb3314..b5ca1922eb 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -9,10 +9,8 @@ use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; use App\Models\TeamInvitation; use App\Models\User; -use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Fortify\Contracts\RegisterResponse; use Laravel\Fortify\Fortify; @@ -122,45 +120,5 @@ class FortifyServiceProvider extends ServiceProvider Fortify::twoFactorChallengeView(function () { return view('auth.two-factor-challenge'); }); - - RateLimiter::for('force-password-reset', function (Request $request) { - return Limit::perMinute(15)->by($request->user()->id); - }); - - RateLimiter::for('forgot-password', function (Request $request) { - // Use real client IP (not spoofable forwarded headers) - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - $limits = [ - Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), - ]; - - $emailIdentity = normalize_email_identity($request->input('email')); - if ($emailIdentity !== null) { - $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); - } - - return $limits; - }); - - RateLimiter::for('login', function (Request $request) { - $email = (string) $request->email; - // Use email + real client IP (not spoofable forwarded headers) - // server('REMOTE_ADDR') gives the actual connecting IP before proxy headers - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - return Limit::perMinute(5)->by($email.'|'.$realIp); - }); - - RateLimiter::for('magic-link', function (Request $request) { - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - $token = (string) $request->input('token'); - - return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp)); - }); - - RateLimiter::for('two-factor', function (Request $request) { - return Limit::perMinute(5)->by($request->session()->get('login.id')); - }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 4068572c81..79139c6b93 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -58,5 +58,34 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('feedback', function (Request $request) { return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by((string) $request->email.'|'.auth_rate_limit_ip($request)); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + + RateLimiter::for('forgot-password', function (Request $request) { + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; + }); + + RateLimiter::for('magic-link', function (Request $request) { + return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.auth_rate_limit_ip($request))); + }); + + RateLimiter::for('force-password-reset', function (Request $request) { + return Limit::perMinute(15)->by($request->user()->id); + }); } } diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 8859a99809..3a59ad58fa 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -18,14 +18,13 @@ use Illuminate\Support\Facades\Log; * State Priority (highest to lowest): * 1. Degraded (from sub-resources) → degraded:unhealthy * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) - * 3. Crash Loop (exited with restarts) → degraded:unhealthy - * 4. Mixed (running + exited) → degraded:unhealthy - * 5. Mixed (running + starting) → starting:unknown - * 6. Running → running:healthy/unhealthy/unknown - * 7. Dead/Removing → degraded:unhealthy - * 8. Paused → paused:unknown - * 9. Starting/Created → starting:unknown - * 10. Exited → exited + * 3. Mixed (running + exited) → degraded:unhealthy + * 4. Mixed (running + starting) → starting:unknown + * 5. Running → running:healthy/unhealthy/unknown + * 6. Dead/Removing → degraded:unhealthy + * 7. Paused → paused:unknown + * 8. Starting/Created → starting:unknown + * 9. Exited → exited * * The $preserveRestarting parameter controls whether "restarting" containers should be * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). @@ -228,23 +227,18 @@ class ContainerStatusAggregator return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; } - // Priority 3: Crash loop detection (exited with restart count > 0) - if ($hasExited && $maxRestartCount > 0) { - return 'degraded:unhealthy'; - } - - // Priority 4: Mixed state (some running, some exited = degraded) + // Priority 3: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 5: Mixed state (some running, some starting = still starting) + // Priority 4: Mixed state (some running, some starting = still starting) // If any component is still starting, the entire service stack is not fully ready if ($hasRunning && $hasStarting) { return 'starting:unknown'; } - // Priority 6: Running containers (check health status) + // Priority 5: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -255,22 +249,22 @@ class ContainerStatusAggregator } } - // Priority 7: Dead or removing containers + // Priority 6: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 8: Paused containers + // Priority 7: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 9: Starting/created containers + // Priority 8: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 10: All containers exited (no restart count = truly stopped) + // Priority 9: All containers exited return 'exited'; } } diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php new file mode 100644 index 0000000000..dab3599101 --- /dev/null +++ b/app/Services/DatabaseStartCommandExecutor.php @@ -0,0 +1,77 @@ +destination->server; + if ($server->isNonRoot()) { + $commands = parseCommandsByLineForSudo(collect($commands), $server)->all(); + } + + $secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction') + ? $database->resolvedSecretManagerValuesForRedaction() + : []; + $remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)); + + $activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]); + $activity->save(); + + $process = Process::timeout(config('constants.ssh.command_timeout')) + ->idleTimeout(3600) + ->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void { + $this->appendOutput($activity, $type, $this->redact($output, $secrets)); + }); + + $result = $process->wait(); + $status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR; + $activity->properties = $activity->properties->merge([ + 'status' => $status->value, + 'exitCode' => $result->exitCode(), + ]); + $activity->save(); + + if (! $result->successful()) { + throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode()); + } + + return $activity; + } + + private function redact(string $value, array $secrets): string + { + foreach ($secrets as $secret) { + if (is_string($secret) && $secret !== '') { + $value = str_replace($secret, REDACTED, $value); + } + } + + return sanitize_utf8_text(remove_iip($value)); + } + + private function appendOutput(Activity $activity, string $type, string $output): void + { + if ($output === '') { + return; + } + + $entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR); + $entries[] = [ + 'type' => $type, + 'output' => $output, + 'timestamp' => hrtime(true), + 'batch' => 1, + 'order' => count($entries) + 1, + ]; + $activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + $activity->save(); + } +} diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index 386bdd5bb9..e3ba77163d 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -7,6 +7,7 @@ use App\Models\EnvironmentVariable; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText; +use App\Support\DomainPortOverrides; use Illuminate\Support\Arr; class ApplicationConfigurationSnapshot @@ -194,6 +195,7 @@ class ApplicationConfigurationSnapshot { return [ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('domain_port_overrides', 'Domain port overrides', DomainPortOverrides::sorted($this->application->domain_port_overrides), 'redeploy'), $this->item('noindex_domains', 'Search engine indexing', $this->application->noindexDomains()->all(), 'redeploy'), $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), diff --git a/app/Services/DopplerService.php b/app/Services/DopplerService.php new file mode 100644 index 0000000000..2513a4f7d8 --- /dev/null +++ b/app/Services/DopplerService.php @@ -0,0 +1,57 @@ +client()->get($this->baseUrl.'/v3/me')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Download all secrets for a config. Project and config are not needed for + * service tokens (the token itself is pinned to one config). + * + * @return array + */ + public function fetchSecrets(?string $project = null, ?string $config = null): array + { + $query = ['format' => 'json']; + if (filled($project)) { + $query['project'] = $project; + } + if (filled($config)) { + $query['config'] = $config; + } + + $response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query); + + if (! $response->successful()) { + throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json()) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + return Http::withToken($this->token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php new file mode 100644 index 0000000000..06f1e5d49f --- /dev/null +++ b/app/Services/InfisicalService.php @@ -0,0 +1,89 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + $this->login(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @return array + */ + public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array + { + $client = $this->client()->withToken($this->login()); + $secretPath = $secretPath ?: '/'; + + $response = $client->get($this->baseUrl.'/api/v4/secrets', [ + 'projectId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + + // Older self-hosted instances only expose the v3 endpoint. + if ($response->status() === 404) { + $response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [ + 'workspaceId' => $projectId, + 'environment' => $environment, + 'secretPath' => $secretPath, + ]); + } + + if (! $response->successful()) { + throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('secrets', [])) + ->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')]) + ->all(); + } + + private function login(): string + { + $response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + ]); + + $accessToken = $response->json('accessToken'); + if (! $response->successful() || blank($accessToken)) { + throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status())); + } + + return $accessToken; + } + + private function client(): PendingRequest + { + return Http::acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php new file mode 100644 index 0000000000..6033ce98f7 --- /dev/null +++ b/app/Services/IntegrationTokenValidator.php @@ -0,0 +1,39 @@ + app(CloudflareTokenValidator::class)->validate($token, $capabilities), + 'doppler' => (new DopplerService($token))->validate(), + 'infisical' => (new InfisicalService( + (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), + (string) data_get($metadata, 'client_id'), + $token, + ))->validate(), + 'vault' => (new VaultService( + (string) data_get($metadata, 'base_url'), + $token, + data_get($metadata, 'namespace'), + ))->validate(), + default => false, + }; + } + + public function errorMessage(string $provider): string + { + return match ($provider) { + 'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.', + 'doppler' => 'The Doppler token could not be verified. Check the token and its access.', + 'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.', + 'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.', + default => 'The token could not be verified.', + }; + } +} diff --git a/app/Services/RestartCountTracker.php b/app/Services/RestartCountTracker.php new file mode 100644 index 0000000000..e67deacb3c --- /dev/null +++ b/app/Services/RestartCountTracker.php @@ -0,0 +1,31 @@ + $previousRestartCount; + $restartCountChanged = $newGeneration || $restartCountIncreased; + + $restartLimitReached = $maxRestartCount > 0 + && $observedRestartCount >= $maxRestartCount; + + return [ + 'restart_count' => $restartCountChanged ? $observedRestartCount : $previousRestartCount, + 'restart_count_changed' => $restartCountChanged, + 'restart_limit_reached' => $restartLimitReached, + 'new_generation' => $newGeneration, + ]; + } +} diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php new file mode 100644 index 0000000000..e41652cd54 --- /dev/null +++ b/app/Services/VaultService.php @@ -0,0 +1,68 @@ + */ + private array $httpClientOptions; + + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + { + $this->baseUrl = rtrim($baseUrl, '/'); + Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); + $this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl); + } + + public function validate(): bool + { + try { + return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful(); + } catch (\Throwable) { + return false; + } + } + + /** + * Read a KV v2 secret. Non-string values are stored as JSON strings. + * + * @return array + */ + public function fetchSecrets(string $mount, string $path): array + { + $mount = trim($mount, '/'); + $path = trim($path, '/'); + + $response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}"); + + if (! $response->successful()) { + throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status())); + } + + return collect($response->json('data.data', [])) + ->map(fn ($value) => is_string($value) ? $value : json_encode($value)) + ->all(); + } + + private function client(): PendingRequest + { + $client = Http::withHeaders(['X-Vault-Token' => $this->token]) + ->acceptJson() + ->withOptions($this->httpClientOptions) + ->connectTimeout(5) + ->timeout(10); + + if (filled($this->namespace)) { + $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); + } + + return $client; + } +} diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 2c1de948ba..84e629fe1a 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,8 +90,11 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { + $requireStatementBoundary = true; + if (str_starts_with($sql, 'PGDMP')) { - return false; + $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; + $requireStatementBoundary = false; } $withoutComments = self::stripSqlComments($sql); @@ -100,7 +103,9 @@ class DatabaseBackupFileValidator return true; } - return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; + + return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/Support/DomainPortOverrides.php b/app/Support/DomainPortOverrides.php new file mode 100644 index 0000000000..320540ad68 --- /dev/null +++ b/app/Support/DomainPortOverrides.php @@ -0,0 +1,91 @@ +|null $overrides + * @return array + */ + public static function sorted(?array $overrides): array + { + return collect($overrides ?? [])->sortKeys()->all(); + } + + public static function withoutPort(string $url): string + { + $parts = DomainUrlParts::split($url); + + return DomainUrlParts::compose($parts['scheme'], $parts['host'], path: $parts['path']); + } + + /** + * @param array|null $existing + * @return array{fqdn: ?string, overrides: ?array} + */ + public static function normalize(?string $fqdn, ?array $existing): array + { + if (blank($fqdn)) { + return ['fqdn' => null, 'overrides' => null]; + } + + $existingOverrides = $existing ?? []; + $normalizedDomains = collect(explode(',', $fqdn)) + ->map(fn (string $domain): string => trim($domain)) + ->filter() + ->map(function (string $domain) use ($existingOverrides): array { + $portlessDomain = self::withoutPort($domain); + $parts = DomainUrlParts::split($domain); + $port = $parts['port'] !== '' + ? (int) $parts['port'] + : ($existingOverrides[$portlessDomain] ?? null); + + return ['domain' => $portlessDomain, 'port' => $port]; + }) + ->keyBy('domain') + ->values(); + + $effectiveOverrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]); + + $normalizedDomains = $normalizedDomains->map(function (array $domain) use ($effectiveOverrides): array { + if (filled($domain['port'])) { + return $domain; + } + + $counterpart = self::wwwCounterpart($domain['domain']); + $domain['port'] = $counterpart === null ? null : $effectiveOverrides->get($counterpart); + + return $domain; + }); + + $normalizedFqdn = $normalizedDomains->pluck('domain')->implode(','); + $overrides = $normalizedDomains + ->filter(fn (array $domain): bool => filled($domain['port'])) + ->mapWithKeys(fn (array $domain): array => [$domain['domain'] => (int) $domain['port']]) + ->all(); + + return [ + 'fqdn' => $normalizedFqdn === '' ? null : $normalizedFqdn, + 'overrides' => $overrides ?: null, + ]; + } + + private static function wwwCounterpart(string $url): ?string + { + $parts = DomainUrlParts::split($url); + $host = $parts['host']; + + if ($host === '') { + return null; + } + + $counterpartHost = str_starts_with(strtolower($host), 'www.') + ? substr($host, 4) + : 'www.'.$host; + + return DomainUrlParts::compose($parts['scheme'], $counterpartHost, path: $parts['path']); + } +} diff --git a/app/Support/RemoteSecretReferences.php b/app/Support/RemoteSecretReferences.php new file mode 100644 index 0000000000..530c29a28c --- /dev/null +++ b/app/Support/RemoteSecretReferences.php @@ -0,0 +1,64 @@ + Referenced secret key names (unique, in order of appearance) + */ + public static function referencedKeys(?string $value): array + { + if (blank($value)) { + return []; + } + + preg_match_all(self::PATTERN, $value, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * Replace every reference with its value from the secrets map. + * Keys missing from the map are left as-is — collect them first with + * missingKeys() and fail before calling substitute(). + * + * @param array $secrets + */ + public static function substitute(string $value, array $secrets): string + { + return preg_replace_callback( + self::PATTERN, + fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0], + $value, + ); + } + + /** + * @param array $secrets + * @return list + */ + public static function missingKeys(?string $value, array $secrets): array + { + return array_values(array_filter( + self::referencedKeys($value), + fn (string $key) => ! array_key_exists($key, $secrets), + )); + } +} diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php index cdeb75e58d..5d3ded154a 100644 --- a/app/Support/ServiceComposeUrl.php +++ b/app/Support/ServiceComposeUrl.php @@ -25,15 +25,7 @@ class ServiceComposeUrl ->map(fn ($url) => trim((string) $url)) ->filter(); - foreach ($urls as $url) { - if (! filter_var($url, FILTER_VALIDATE_URL)) { - $errors[] = "Invalid URL: {$url}"; - } - $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; - if (! in_array(strtolower($scheme), ['http', 'https'], true)) { - $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; - } - } + $errors = ValidationPatterns::validateApplicationDomains($urls->implode(',')); $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 41b27f9ff9..4656406fc4 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -108,6 +108,11 @@ class ValidationPatterns */ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; + /** + * Pattern for environment variable keys written to shell-sourced files. + */ + public const SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_]*\z/u'; + /** * Characters that are valid in some URL positions but unsafe for values * that are later reused in shell assignment contexts. @@ -192,6 +197,43 @@ class ValidationPatterns return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; } + /** + * Make an environment variable key safe to show in deployment logs. + * + * Control characters are escaped and long values are truncated so an + * unexpected key cannot corrupt or overflow the deployment log output. + */ + public static function displayShellEnvironmentVariableKey(string $value, int $maxLength = 80): string + { + $printable = str($value) + ->replace(["\0", "\r", "\n", "\t"], ['\\0', '\\r', '\\n', '\\t']) + ->value(); + + $printable = preg_replace_callback( + '/[\x00-\x1F\x7F]/', + fn (array $matches): string => sprintf('\\x%02X', ord($matches[0])), + $printable, + ); + + if ($printable === '') { + return '(empty)'; + } + + return str($printable)->limit($maxLength)->value(); + } + + /** + * Validate an environment variable key before writing it to a shell-sourced file. + */ + public static function validatedShellEnvironmentVariableKey(string $value): string + { + if (preg_match(self::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) !== 1) { + throw new \InvalidArgumentException('Invalid environment variable name '.self::displayShellEnvironmentVariableKey($value).'. Names must start with a letter or underscore and contain only letters, numbers, and underscores.'); + } + + return $value; + } + /** * Check if a string is a valid S3 bucket name. */ @@ -570,8 +612,23 @@ class ValidationPatterns continue; } - if (blank(parse_url($url, PHP_URL_HOST))) { + $host = parse_url($url, PHP_URL_HOST); + if (blank($host)) { $errors[] = "Invalid URL: {$url}"; + + continue; + } + + $port = parse_url($url, PHP_URL_PORT); + if ($port !== null && ($port < 1 || $port > 65535)) { + $errors[] = "Invalid port for URL: {$url}. The port must be between 1 and 65535."; + + continue; + } + + $unwrappedHost = trim((string) $host, '[]'); + if (! str_contains($unwrappedHost, '.') && filter_var($unwrappedHost, FILTER_VALIDATE_IP) === false) { + $errors[] = "Invalid URL: {$url}. The hostname must be a fully qualified domain name."; } } diff --git a/app/Traits/Auditable.php b/app/Traits/Auditable.php new file mode 100644 index 0000000000..878d46c1e4 --- /dev/null +++ b/app/Traits/Auditable.php @@ -0,0 +1,102 @@ + $model->recordAuditMutation('created')); + static::updated(fn (Model $model) => $model->recordAuditMutation('updated')); + static::deleted(fn (Model $model) => $model->recordAuditMutation('deleted')); + } + + private function recordAuditMutation(string $action): void + { + if (! $this->auditLoggingEnabled || ! auth()->check()) { + return; + } + + $teamId = $this->auditTeamId(); + if ($teamId === null) { + return; + } + + $changedFields = $action === 'updated' + ? collect(array_keys($this->getChanges())) + ->reject(fn (string $field): bool => in_array($field, [ + 'updated_at', + 'order', + 'status', + ...($this->auditExclude ?? []), + ], true)) + ->values() + ->all() + : []; + + if ($action === 'updated' && $changedFields === []) { + return; + } + + $resourceType = Str::snake(class_basename($this)); + $source = auth()->user()?->currentAccessToken() instanceof PersonalAccessToken ? 'api' : 'ui'; + + auditLog("{$source}.{$resourceType}.{$action}", [ + 'team_id' => $teamId, + "{$resourceType}_uuid" => $this->getAttribute('uuid'), + "{$resourceType}_name" => $this->getAttribute('name') ?? $this->getAttribute('key'), + 'changed_fields' => $changedFields, + ]); + } + + public function withoutAuditLogging(Closure $callback): mixed + { + $wasAuditLoggingEnabled = $this->auditLoggingEnabled; + $this->auditLoggingEnabled = false; + + try { + return $callback(); + } finally { + $this->auditLoggingEnabled = $wasAuditLoggingEnabled; + } + } + + private function auditTeamId(): ?int + { + if ($this instanceof Team) { + return (int) $this->getKey(); + } + + if ($this->getAttribute('team_id') !== null) { + return (int) $this->getAttribute('team_id'); + } + + if ($this->getAttribute('project_id') !== null) { + return $this->project?->team_id; + } + + if ($this->getAttribute('environment_id') !== null) { + return $this->environment?->project?->team_id; + } + + if ($this->getAttribute('server_id') !== null) { + return $this->server?->team_id; + } + + if ($this->getAttribute('resourceable_id') !== null) { + return $this->resourceable?->team()?->id + ?? $this->resourceable?->team_id + ?? $this->resourceable?->environment?->project?->team_id; + } + + return null; + } +} diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index a2c3d06da9..b8ff5df14b 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,13 @@ trait ExecuteRemoteCommand ); } + if (isset($this->remote_secrets_cache)) { + $lockedVars = $lockedVars->merge(array_values(array_filter( + $this->remote_secrets_cache, + static fn (mixed $value): bool => is_string($value) && $value !== '' + ))); + } + foreach ($lockedVars as $key => $value) { $escapedValue = preg_quote($value, '/'); $text = preg_replace( diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php new file mode 100644 index 0000000000..d267a8b1b2 --- /dev/null +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -0,0 +1,19 @@ +execute($commands, $database, $activity); + } + + return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); + } +} diff --git a/app/Traits/HasNoindexDomains.php b/app/Traits/HasNoindexDomains.php index c3ba8a7d86..7afe0d4f25 100644 --- a/app/Traits/HasNoindexDomains.php +++ b/app/Traits/HasNoindexDomains.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Support\Collection; @@ -19,7 +20,7 @@ trait HasNoindexDomains { return collect($this->noindex_domains ?? []) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->unique() ->values(); } @@ -27,7 +28,7 @@ trait HasNoindexDomains public function isDomainNoindexed(string $domain): bool { return $this->noindexDomains()->contains( - ValidationPatterns::normalizeApplicationDomainUrl($domain) + $this->normalizeNoindexDomain($domain) ); } @@ -35,7 +36,7 @@ trait HasNoindexDomains { $this->noindex_domains = collect($domains) ->filter(fn ($domain) => is_string($domain) && filled($domain)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)) + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)) ->intersect($this->currentDomains()) ->unique() ->values() @@ -58,6 +59,13 @@ trait HasNoindexDomains private function currentDomains(): Collection { return collect(ValidationPatterns::applicationDomainList($this->fqdn)) - ->map(fn (string $domain) => ValidationPatterns::normalizeApplicationDomainUrl($domain)); + ->map(fn (string $domain) => $this->normalizeNoindexDomain($domain)); + } + + private function normalizeNoindexDomain(string $domain): string + { + return DomainPortOverrides::withoutPort( + ValidationPatterns::normalizeApplicationDomainUrl($domain) + ); } } diff --git a/app/Traits/HasRestartLimit.php b/app/Traits/HasRestartLimit.php new file mode 100644 index 0000000000..edb461cdcd --- /dev/null +++ b/app/Traits/HasRestartLimit.php @@ -0,0 +1,73 @@ +mergeFillable(['restart_count', 'max_restart_count', 'restart_limit_reached', 'last_restart_at', 'last_restart_type']); + $this->mergeCasts([ + 'restart_count' => 'integer', + 'max_restart_count' => 'integer', + 'restart_limit_reached' => 'boolean', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', + ]); + } + + public function stoppedAfterRestartLimit(): bool + { + return str($this->status)->startsWith('exited') && $this->restart_limit_reached === true; + } + + public function trackRestartCount(int $observedRestartCount): bool + { + $state = (new RestartCountTracker)->evaluate( + previousRestartCount: $this->restart_count ?? 0, + observedRestartCount: $observedRestartCount, + maxRestartCount: $this->restartLimitMaximum(), + ); + + if ($state['restart_count_changed']) { + $hasCrashRestarts = $state['restart_count'] > 0; + $this->update([ + 'restart_count' => $state['restart_count'], + 'last_restart_at' => $hasCrashRestarts ? now() : null, + 'last_restart_type' => $hasCrashRestarts ? 'crash' : null, + ]); + } + + if (! $state['restart_limit_reached']) { + return false; + } + + $claimed = $this->newQuery() + ->whereKey($this->getKey()) + ->where('restart_limit_reached', false) + ->update(['restart_limit_reached' => true]) === 1; + + if ($claimed) { + $this->restart_limit_reached = true; + } + + return $claimed; + } + + public function resetRestartLimit(): void + { + $this->update([ + 'restart_count' => 0, + 'restart_limit_reached' => false, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + } + + public function restartLimitMaximum(): int + { + return $this->max_restart_count ?? 0; + } +} diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php new file mode 100644 index 0000000000..8b3e50b7bd --- /dev/null +++ b/app/Traits/HasSecretManager.php @@ -0,0 +1,107 @@ +|null */ + private ?array $resolvedSecretManagerValues = null; + + public static function bootHasSecretManager(): void + { + static::deleting(fn ($resource) => $resource->secretManagerLink()->delete()); + } + + public function secretManagerLink(): MorphOne + { + return $this->morphOne(SecretManagerLink::class, 'resourceable'); + } + + public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + return $this->formatEnvironmentVariableValue($environmentVariable, $value); + } + + public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { + if ($value === null) { + return null; + } + + if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) { + return $value; + } + + return $environmentVariable->is_literal || $environmentVariable->is_multiline + ? "'{$value}'" + : escapeEnvVariables($value); + } + + public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolvedEnvironmentVariableValue($environmentVariable); + + if ($value === null) { + return null; + } + + if (RemoteSecretReferences::containsReference($value)) { + $secrets = $this->secretManagerValues(); + $missing = RemoteSecretReferences::missingKeys($value, $secrets); + + if ($missing !== []) { + throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + } + + $value = RemoteSecretReferences::substitute($value, $secrets); + } + + return $value; + } + + public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool + { + return RemoteSecretReferences::containsReference( + $this->resolvedEnvironmentVariableValue($environmentVariable), + ); + } + + private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string + { + return $environmentVariable->get_real_environment_variables_with_server( + $environmentVariable->value, + $this, + data_get($this, 'server'), + ); + } + + /** @return array */ + private function secretManagerValues(): array + { + if ($this->resolvedSecretManagerValues !== null) { + return $this->resolvedSecretManagerValues; + } + + $link = $this->secretManagerLink()->with('integrationToken')->first(); + + if (! $link) { + throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + } + + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } + + /** @return array */ + public function resolvedSecretManagerValuesForRedaction(): array + { + return $this->resolvedSecretManagerValues ?? []; + } +} diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php new file mode 100644 index 0000000000..1b46ca2dd5 --- /dev/null +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -0,0 +1,58 @@ +secretManagerLinkForAutocomplete() !== null; + } + + /** + * @return list + */ + public function fetchSecretManagerKeys(): array + { + $this->skipRender(); + + $link = $this->secretManagerLinkForAutocomplete(); + + if (! $link) { + return []; + } + + try { + $this->authorize('view', $link->resourceable); + $keys = array_keys($link->fetchSecrets()); + sort($keys); + + return $keys; + } catch (\Throwable) { + throw new \RuntimeException('Unable to fetch secret manager keys.'); + } + } + + private function secretManagerLinkForAutocomplete(): ?SecretManagerLink + { + $resource = $this->secretManagerResource(); + + if (! $resource || ! method_exists($resource, 'secretManagerLink')) { + return null; + } + + if (! $resource->relationLoaded('secretManagerLink')) { + $resource->load('secretManagerLink.integrationToken'); + } + + return $resource->secretManagerLink; + } +} diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index a3e6646fec..9ff5d72dc5 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -35,6 +35,7 @@ class EnvVarInput extends Component public mixed $canResource = null, public bool $autoDisable = true, public array $availableVars = [], + public bool $hasVaultSource = false, public ?string $projectUuid = null, public ?string $environmentUuid = null, public ?string $serverUuid = null, diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 7831c9f024..a1b01e2808 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -34,6 +34,8 @@ class Input extends Component public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public bool $loading = false, + public string $loadingText = 'Loading...', ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 339a0bcf7b..2fb0bb3f53 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -84,6 +84,15 @@ function queue_application_deployment(Application $application, string $deployme 'only_this_server' => $only_this_server, ]); + if (auth()->check() && ! $is_webhook && ! $is_api && ! $rollback) { + auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [ + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'force_rebuild' => $force_rebuild, + ]); + } + if ($no_questions_asked) { $deployment->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php index 8477450c4b..1a1ad0a994 100644 --- a/bootstrap/helpers/audit.php +++ b/bootstrap/helpers/audit.php @@ -1,13 +1,10 @@ $context Identifiers + outcome details. @@ -16,39 +13,15 @@ if (! function_exists('auditLog')) { function auditLog(string $event, array $context = [], string $level = 'info'): void { try { - $request = app()->bound('request') ? request() : null; - $user = auth()->check() ? auth()->user() : null; - $token = $user?->currentAccessToken(); - - $base = [ - 'event' => $event, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), - 'user_id' => $user?->id, - 'user_email' => $user?->email, - 'team_id' => $token ? data_get($token, 'team_id') : null, - 'token_id' => $token?->id ?? null, - 'token_name' => $token?->name ?? null, - 'method' => $request?->method(), - 'path' => $request?->path(), - ]; - - $payload = array_merge($base, $context); - - Log::channel('audit')->{$level}($event, $payload); - } catch (Throwable $e) { - // Audit logging must never break the request path. - try { - Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]); - } catch (Throwable) { - } + AuditEvent::record($event, $context); + } catch (Throwable) { } } } if (! function_exists('auditLogWebhookFailure')) { /** - * Record a webhook signature/auth verification failure to the `audit` channel. + * Record a webhook signature/auth verification failure. */ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void { @@ -58,10 +31,7 @@ if (! function_exists('auditLogWebhookFailure')) { $event = "webhook.{$provider}.signature_failed"; $base = [ - 'event' => $event, 'reason' => $reason, - 'ip' => $request?->ip(), - 'ua' => substr((string) $request?->userAgent(), 0, 200), 'method' => $request?->method(), 'path' => $request?->path(), 'event_header' => $request?->header('X-GitHub-Event') @@ -70,12 +40,8 @@ if (! function_exists('auditLogWebhookFailure')) { ?? $request?->header('X-Event-Key'), ]; - Log::channel('audit')->warning($event, array_merge($base, $context)); - } catch (Throwable $e) { - try { - Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]); - } catch (Throwable) { - } + auditLog($event, array_merge($base, $context), 'warning'); + } catch (Throwable) { } } } diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php new file mode 100644 index 0000000000..7580fc1e06 --- /dev/null +++ b/bootstrap/helpers/auth.php @@ -0,0 +1,14 @@ +header('CF-Connecting-IP'); + + if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) { + return $cloudflareIp; + } + + return (string) $request->ip(); +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index dd532bd758..1f8778cdfe 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -166,11 +166,13 @@ function format_docker_labels_to_json(string|array $rawOutput): Collection $outputArray = explode(',', $outputLine); return collect($outputArray) - ->map(function ($outputLine) { - return explode('=', $outputLine); - }) ->mapWithKeys(function ($outputLine) { - return [$outputLine[0] => $outputLine[1]]; + $label = explode('=', $outputLine, 2); + if (count($label) !== 2) { + return []; + } + + return [$label[0] => $label[1]]; }); })[0]; } @@ -267,7 +269,28 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string { $container = escapeShellValue($container); - $script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code"; + $script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + +function dockerRemoveCommand(string $container): string +{ + $command = 'docker rm -f '.escapeShellValue($container); + + return dockerCommandIgnoringError($command, 'No such container:'); +} + +function dockerNetworkRemoveCommand(string $network): string +{ + $command = 'docker network rm '.escapeShellValue($network); + + return dockerCommandIgnoringError($command, 'network .* not found'); +} + +function dockerCommandIgnoringError(string $command, string $ignoredError): string +{ + $script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; return 'bash -c '.escapeShellValue($script); } @@ -507,7 +530,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool ->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain)); } -function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false) +function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = []) { $labels = collect([]); if ($serviceLabels) { @@ -531,7 +554,8 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, if ($schema === 'https' && ! $is_force_https_enabled) { $siteAddress = "http://{$host}, https://{$host}"; } - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { $handle = 'handle'; @@ -589,7 +613,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains, return $labels->sort(); } -function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true) +function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) { $labels = collect([]); $labels->push('traefik.enable=true'); @@ -644,7 +668,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $host = $url->getHost(); $path = $url->getPath(); $schema = $url->getScheme(); - $port = $url->getPort(); + $portlessDomain = ServiceApplication::withoutPort($domain); + $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } @@ -887,6 +912,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -904,6 +930,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $application->domain_port_overrides ?? [], )); break; } @@ -921,6 +948,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $application->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -936,6 +964,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $application->domain_port_overrides ?? [], )); } } @@ -963,6 +992,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; case ProxyTypes::CADDY->value: @@ -979,6 +1009,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; } @@ -995,6 +1026,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, + domainPortOverrides: $preview->domain_port_overrides ?? [], )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, @@ -1009,6 +1041,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview http_basic_auth_password: $application->http_basic_auth_password, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), + domainPortOverrides: $preview->domain_port_overrides ?? [], )); } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index b47e570477..f65b626984 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1265,24 +1265,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $portInt = $url->getPort(); - $port = $portInt !== null ? ':'.$portInt : ''; - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn{$port}"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $preview->domain_port_overrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->save(); } } } @@ -1359,6 +1351,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $previewForPorts = $isPullRequest + ? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first()) + : null; + $domainPortOverrides = $isPullRequest + ? ($previewForPorts?->domain_port_overrides ?? []) + : ($originalResource->domain_port_overrides ?? []); + $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; + $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1374,8 +1374,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -1389,9 +1391,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -1405,8 +1409,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $labelNetwork, @@ -1418,9 +1424,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -1849,11 +1857,7 @@ function serviceParser(Service $resource): Collection // Only save fqdn to ServiceApplication, not ServiceDatabase if ($isServiceApplication && is_null($savedService->fqdn)) { // Save URL (with scheme) to database, not FQDN - if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) { - $savedService->fqdn = $urlWithPort; - } else { - $savedService->fqdn = $urlWithPort; - } + $savedService->fqdn = $url; $savedService->save(); } @@ -2636,6 +2640,9 @@ function serviceParser(Service $resource): Collection $redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($originalResource, 'redirect') : 'both'; + $onlyPort = $originalResource instanceof ServiceApplication + ? ($originalResource->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -2651,6 +2658,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2666,7 +2675,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2682,6 +2693,8 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); @@ -2695,7 +2708,9 @@ function serviceParser(Service $resource): Collection is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), service_name: $serviceName, image: $image, + onlyPort: $onlyPort, predefinedPort: $predefinedPort, + domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection )); diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 9d1a2aa198..c5c0c391d0 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -146,7 +146,7 @@ function connectProxyToNetworks(Server $server) } return collect([ - 'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', + 'for network in $(docker inspect $(docker ps -a --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do', ' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then', ' continue', ' fi', diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 982dda5511..241368d388 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -346,7 +346,7 @@ function remove_iip($text) $text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text); // GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh) - $text = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{36,})\b/', REDACTED, $text); + $text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text); // GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token) $text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 07fdeb086f..96257a6323 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -1,5 +1,30 @@ asset($defaultLogo), + 'logo_cdn_url' => asset($defaultLogo), + 'logo_default_url' => asset($defaultLogo), + ]; + } + + if (str_starts_with($logo, 'svg/')) { + $logo = 'svgs/'.str($logo)->after('svg/'); + } + + $logo = ltrim($logo, '/'); + + return [ + 'logo' => asset($logo), + 'logo_cdn_url' => 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo, + 'logo_default_url' => asset($defaultLogo), + ]; +} + use App\Models\Application; use App\Models\Service; use App\Models\ServiceApplication; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e82bb9b6df..615fd97aaf 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -12,6 +12,8 @@ use App\Models\GitlabApp; use App\Models\InstanceSettings; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; +use App\Models\Project; +use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -815,6 +817,54 @@ function firstDomainFromList(?string $fqdns): string { return trim((string) str($fqdns ?? '')->explode(',')->first()); } +function profile_avatar_url(User $user): string +{ + if ($user->avatar_storage_type === 's3') { + $url = s3_image_url($user->avatar_s3_storage_id, $user->avatar_path, $user->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('profile.avatar', ['v' => $user->updated_at->timestamp]); +} + +function project_icon_url(Project $project): string +{ + if ($project->icon_storage_type === 's3') { + $url = s3_image_url($project->icon_s3_storage_id, $project->icon_path, $project->updated_at->timestamp); + if ($url) { + return $url; + } + } + + return route('project.icon', [ + 'project_uuid' => $project->uuid, + 'v' => $project->updated_at->timestamp, + ]); +} + +function s3_image_url(?int $storageId, ?string $path, int $version): ?string +{ + if (! $storageId || blank($path)) { + return null; + } + + $storage = S3Storage::query() + ->whereKey($storageId) + ->whereTeamId(0) + ->where('is_usable', true) + ->first(); + + if (! $storage) { + return null; + } + + $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + + return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; +} + /** * If fqdn is set, return it, otherwise return public ip. */ @@ -3053,6 +3103,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true) ? data_get($savedService, 'redirect') : 'both'; + $domainPortOverrides = $savedService instanceof ServiceApplication + ? ($savedService->domain_port_overrides ?? []) + : []; + $onlyPort = $savedService instanceof ServiceApplication + ? ($savedService->getRequiredPort() ?? $predefinedPort) + : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3065,8 +3121,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; case ProxyTypes::CADDY->value: @@ -3080,8 +3138,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); break; } @@ -3095,8 +3156,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( network: $resource->destination->network, @@ -3108,8 +3171,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_stripprefix_enabled: $savedService->isStripprefixEnabled(), service_name: $serviceName, image: data_get($service, 'image'), + onlyPort: $onlyPort, + predefinedPort: $predefinedPort, noindex_domains: $noindexDomains, - redirect_direction: $redirectDirection + redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, )); } } @@ -3808,6 +3874,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = str($fqdns)->explode(','); if ($pull_request_id !== 0) { $preview = $resource->previews()->find($preview_id); + if (! $preview) { + try { + $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); + } catch (ModelNotFoundException) { + throw new RuntimeException('Preview not found.'); + } + } $docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []; if (count($docker_compose_domains) > 0) { $found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName); @@ -3817,22 +3890,20 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $fqdns = collect([]); } } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pull_request_id, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pull_request_id); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new_public_id(); - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); + $generatedDomains = $fqdns->map( + fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn) + ); + $fqdns = $generatedDomains->pluck('url'); + $preview->fqdn = $fqdns->implode(','); + $generatedOverrides = $generatedDomains + ->filter(fn (array $generated): bool => filled($generated['port'])) + ->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']]) + ->all(); + $preview->domain_port_overrides = array_replace( + $preview->domain_port_overrides ?? [], + $generatedOverrides, + ); + $preview->save(); } } $noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains(); @@ -3841,6 +3912,11 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true) ? $composeRedirect : 'both'; + $domainPortOverrides = $pull_request_id === 0 + ? ($resource->domain_port_overrides ?? []) + : ($preview?->domain_port_overrides ?? []); + $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; + $onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null; if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: @@ -3854,8 +3930,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3870,8 +3948,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); break; @@ -3887,8 +3967,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); $serviceLabels = $serviceLabels->merge( @@ -3901,8 +3983,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal is_force_https_enabled: $resource->isForceHttpsEnabled(), is_gzip_enabled: $resource->isGzipEnabled(), is_stripprefix_enabled: $resource->isStripprefixEnabled(), + onlyPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, + domainPortOverrides: $domainPortOverrides, ) ); } @@ -4152,11 +4236,12 @@ function coolifyHelperImage(): string function getHelperVersion(): string { - $settings = instanceSettings(); + if (isDev()) { + $devHelperVersion = InstanceSettings::query()->whereKey(0)->value('dev_helper_version'); - // In development mode, use the dev_helper_version if set, otherwise fallback to config - if (isDev() && ! empty($settings->dev_helper_version)) { - return $settings->dev_helper_version; + if (! empty($devHelperVersion)) { + return $devHelperVersion; + } } return config('constants.coolify.helper_version'); diff --git a/composer.json b/composer.json index c0ffc6f07f..4778cc91dd 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "laravel/mcp": "^0.6.7", "laravel/nightwatch": "^1.28.6", "laravel/pail": "^1.2.7", - "laravel/prompts": "^0.3.22|^0.3.22|^0.3.22", + "laravel/prompts": "^0.3.22", "laravel/sanctum": "^4.3.3", "laravel/socialite": "^5.29.0", "laravel/tinker": "^2.11.1", diff --git a/composer.lock b/composer.lock index b77aef46f5..55be2166b6 100644 --- a/composer.lock +++ b/composer.lock @@ -3644,16 +3644,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.3", + "version": "v3.8.7", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2" + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", - "reference": "ab9c2ac9305008aa9ab0f1beecec8ed6c3a591b2", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ff019f8f6f48b7a2315922e45a70ad8fd75d1934", + "reference": "ff019f8f6f48b7a2315922e45a70ad8fd75d1934", "shasum": "" }, "require": { @@ -3708,7 +3708,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.3" + "source": "https://github.com/livewire/livewire/tree/v3.8.7" }, "funding": [ { @@ -3716,7 +3716,7 @@ "type": "github" } ], - "time": "2026-07-31T00:08:18+00:00" + "time": "2026-08-31T15:40:46+00:00" }, { "name": "log1x/laravel-webfonts", diff --git a/config/constants.php b/config/constants.php index a406dd0ea7..457a8a1ff8 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.10', - 'helper_version' => '1.0.15', - 'realtime_version' => '1.0.17', + 'version' => env('COOLIFY_VERSION') ?: '4.3.15', + 'helper_version' => '1.0.16', + 'realtime_version' => '1.0.18', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -14,6 +14,7 @@ return [ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), + 'avatar_cdn_url' => env('AVATAR_CDN_URL'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), diff --git a/config/logging.php b/config/logging.php index 05cf8e13d3..89c9d38dde 100644 --- a/config/logging.php +++ b/config/logging.php @@ -133,13 +133,6 @@ return [ 'days' => 14, ], - 'audit' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/audit.log'), - 'level' => env('LOG_AUDIT_LEVEL', 'info'), - 'days' => env('LOG_AUDIT_DAYS', 90), - 'replace_placeholders' => true, - ], ], ]; diff --git a/database/factories/AuditEventFactory.php b/database/factories/AuditEventFactory.php new file mode 100644 index 0000000000..01ddebbd2b --- /dev/null +++ b/database/factories/AuditEventFactory.php @@ -0,0 +1,29 @@ + + */ +class AuditEventFactory extends Factory +{ + protected $model = AuditEvent::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'event' => 'ui.application.updated', + 'source' => 'ui', + 'action' => 'updated', + 'actor_type' => 'user', + 'description' => 'Application updated', + 'metadata' => [], + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_08_20_000000_create_audit_events_table.php b/database/migrations/2026_08_20_000000_create_audit_events_table.php new file mode 100644 index 0000000000..0ace21f229 --- /dev/null +++ b/database/migrations/2026_08_20_000000_create_audit_events_table.php @@ -0,0 +1,45 @@ +id(); + $table->unsignedBigInteger('team_id')->nullable(); + $table->string('event'); + $table->string('source', 32); + $table->string('action', 64); + $table->string('actor_type', 32); + $table->unsignedBigInteger('actor_id')->nullable(); + $table->string('actor_name')->nullable(); + $table->string('actor_email')->nullable(); + $table->unsignedBigInteger('actor_token_id')->nullable(); + $table->string('actor_token_name')->nullable(); + $table->string('resource_type')->nullable(); + $table->string('resource_uuid')->nullable(); + $table->string('resource_name')->nullable(); + $table->text('description'); + $table->json('metadata')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 200)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('created_at'); + $table->index(['team_id', 'created_at', 'id']); + $table->index(['team_id', 'action', 'created_at', 'id']); + $table->index(['team_id', 'source', 'created_at', 'id']); + $table->index(['team_id', 'resource_type', 'resource_uuid', 'created_at']); + $table->index(['team_id', 'actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_events'); + } +}; diff --git a/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php new file mode 100644 index 0000000000..744697628f --- /dev/null +++ b/database/migrations/2026_08_23_000000_add_secret_manager_integrations.php @@ -0,0 +1,36 @@ +json('metadata')->nullable()->after('capabilities'); + }); + + Schema::create('secret_manager_links', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('resourceable_type'); + $table->unsignedBigInteger('resourceable_id'); + $table->foreignId('integration_token_id')->constrained()->cascadeOnDelete(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique(['resourceable_type', 'resourceable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_manager_links'); + + Schema::table('integration_tokens', function (Blueprint $table) { + $table->dropColumn('metadata'); + }); + } +}; diff --git a/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php new file mode 100644 index 0000000000..fd1865ed32 --- /dev/null +++ b/database/migrations/2026_08_28_193100_add_domain_dns_statuses_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_dns_statuses')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_dns_statuses'); + }); + } +}; diff --git a/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php new file mode 100644 index 0000000000..fc59fb587b --- /dev/null +++ b/database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php @@ -0,0 +1,28 @@ +boolean('container_present')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('container_present'); + }); + } +}; diff --git a/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php new file mode 100644 index 0000000000..80603ee978 --- /dev/null +++ b/database/migrations/2026_08_30_220617_add_restart_limit_reached_to_applications_table.php @@ -0,0 +1,37 @@ +boolean('restart_limit_reached')->default(false)->after('max_restart_count'); + }); + + DB::table('applications') + ->where('status', 'like', 'exited%') + ->where('restart_count', '>', 0) + ->where('max_restart_count', '>', 0) + ->whereColumn('restart_count', '>=', 'max_restart_count') + ->where('last_restart_type', 'crash') + ->update(['restart_limit_reached' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached'); + }); + } +}; diff --git a/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php new file mode 100644 index 0000000000..adf119a6e0 --- /dev/null +++ b/database/migrations/2026_08_31_073116_add_restart_limit_to_application_previews.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php new file mode 100644 index 0000000000..0838ad4bd7 --- /dev/null +++ b/database/migrations/2026_08_31_073117_add_restart_limit_to_service_applications.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php new file mode 100644 index 0000000000..046b02960c --- /dev/null +++ b/database/migrations/2026_08_31_073118_add_restart_limit_to_service_databases.php @@ -0,0 +1,32 @@ +integer('restart_count')->default(0); + $table->integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + $table->timestamp('last_restart_at')->nullable(); + $table->string('last_restart_type', 10)->nullable(); + }); + } + + public function down(): void + { + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn([ + 'restart_count', + 'max_restart_count', + 'restart_limit_reached', + 'last_restart_at', + 'last_restart_type', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php new file mode 100644 index 0000000000..641da5b759 --- /dev/null +++ b/database/migrations/2026_08_31_073119_add_restart_limit_to_standalone_postgresqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_postgresqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php new file mode 100644 index 0000000000..24329da9f3 --- /dev/null +++ b/database/migrations/2026_08_31_073120_add_restart_limit_to_standalone_redis.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_redis', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php new file mode 100644 index 0000000000..08980a7cb8 --- /dev/null +++ b/database/migrations/2026_08_31_073121_add_restart_limit_to_standalone_mongodbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mongodbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php new file mode 100644 index 0000000000..729f852739 --- /dev/null +++ b/database/migrations/2026_08_31_073122_add_restart_limit_to_standalone_mysqls.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mysqls', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php new file mode 100644 index 0000000000..6bada23268 --- /dev/null +++ b/database/migrations/2026_08_31_073123_add_restart_limit_to_standalone_mariadbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_mariadbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php new file mode 100644 index 0000000000..41a983924b --- /dev/null +++ b/database/migrations/2026_08_31_073124_add_restart_limit_to_standalone_keydbs.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_keydbs', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php new file mode 100644 index 0000000000..23d8ccf2c0 --- /dev/null +++ b/database/migrations/2026_08_31_073125_add_restart_limit_to_standalone_dragonflies.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_dragonflies', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php new file mode 100644 index 0000000000..5ec8d4522c --- /dev/null +++ b/database/migrations/2026_08_31_073126_add_restart_limit_to_standalone_clickhouses.php @@ -0,0 +1,23 @@ +integer('max_restart_count')->default(10); + $table->boolean('restart_limit_reached')->default(false); + }); + } + + public function down(): void + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->dropColumn(['max_restart_count', 'restart_limit_reached']); + }); + } +}; diff --git a/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php new file mode 100644 index 0000000000..e4db4b7e8c --- /dev/null +++ b/database/migrations/2026_08_31_092837_add_restart_limit_reached_notifications_to_email_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_email_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_email_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php new file mode 100644 index 0000000000..b7803e6335 --- /dev/null +++ b/database/migrations/2026_08_31_092838_add_restart_limit_reached_notifications_to_discord_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_discord_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('discord_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_discord_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php new file mode 100644 index 0000000000..51880ceb81 --- /dev/null +++ b/database/migrations/2026_08_31_092840_add_restart_limit_reached_notifications_to_telegram_notification_settings_table.php @@ -0,0 +1,30 @@ +boolean('restart_limit_reached_telegram_notifications')->default(true); + $table->text('telegram_notifications_restart_limit_reached_thread_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('telegram_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_telegram_notifications'); + $table->dropColumn('telegram_notifications_restart_limit_reached_thread_id'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php new file mode 100644 index 0000000000..0b82b423f8 --- /dev/null +++ b/database/migrations/2026_08_31_092841_add_restart_limit_reached_notifications_to_slack_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_slack_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slack_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_slack_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php new file mode 100644 index 0000000000..596af0b5cc --- /dev/null +++ b/database/migrations/2026_08_31_092842_add_restart_limit_reached_notifications_to_pushover_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_pushover_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('pushover_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_pushover_notifications'); + }); + } +}; diff --git a/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php new file mode 100644 index 0000000000..cc03cf0f52 --- /dev/null +++ b/database/migrations/2026_08_31_092843_add_restart_limit_reached_notifications_to_webhook_notification_settings_table.php @@ -0,0 +1,28 @@ +boolean('restart_limit_reached_webhook_notifications')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_notification_settings', function (Blueprint $table) { + $table->dropColumn('restart_limit_reached_webhook_notifications'); + }); + } +}; diff --git a/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php new file mode 100644 index 0000000000..c51551e792 --- /dev/null +++ b/database/migrations/2026_09_01_210751_add_domain_port_overrides_to_service_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php new file mode 100644 index 0000000000..f208c5865f --- /dev/null +++ b/database/migrations/2026_09_02_064120_add_domain_port_overrides_to_applications_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('applications', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php new file mode 100644 index 0000000000..80fc8a0618 --- /dev/null +++ b/database/migrations/2026_09_02_132544_add_domain_port_overrides_to_application_previews_table.php @@ -0,0 +1,28 @@ +json('domain_port_overrides')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_previews', function (Blueprint $table) { + $table->dropColumn('domain_port_overrides'); + }); + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0d7caceb95..ebf12379d5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 33709873f2..cc266e5562 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.17' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 567cfbeebe..94330bbcec 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -2,11 +2,11 @@ # https://hub.docker.com/_/alpine ARG BASE_IMAGE=alpine:3.21 # https://download.docker.com/linux/static/stable/ -ARG DOCKER_VERSION=28.0.0 +ARG DOCKER_VERSION=29.7.2 # https://github.com/docker/compose/releases -ARG DOCKER_COMPOSE_VERSION=2.38.2 +ARG DOCKER_COMPOSE_VERSION=5.5.0 # https://github.com/docker/buildx/releases -ARG DOCKER_BUILDX_VERSION=0.25.0 +ARG DOCKER_BUILDX_VERSION=0.36.1 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 0d7b6dcdd9..b72574c8d0 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -10,6 +10,8 @@ import { extractTimeout, getTerminalSessionTimeout, isAuthorizedTargetHost, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; async function postToCoolify(path, headers) { @@ -384,6 +386,16 @@ async function handleCommand(ws, command, userId) { return; } + if (!validateSshArgs(sshArgs, userSession.authorizedIPs)) { + logTerminal('warn', 'Rejecting terminal command because its SSH arguments are not allowed.', { + userId, + targetHost, + }); + ws.send('Invalid SSH command: Unsupported SSH arguments'); + return; + } + const sanitizedSshArgs = sanitizeSshArgs(sshArgs); + const options = { name: 'xterm-color', cols: 80, @@ -401,7 +413,7 @@ async function handleCommand(ws, command, userId) { commandTimeout, terminalSessionTimeout, }); - const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); + const ptyProcess = pty.spawn('ssh', sanitizedSshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; userSession.isActive = true; diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 8769d62d9d..c6865f1800 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -20,7 +20,7 @@ function normalizeShellArgument(argument) { } export function extractSshArgs(commandString) { - const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/); + const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /); if (!sshCommandMatch) return []; const argsString = sshCommandMatch[1]; @@ -131,3 +131,133 @@ export function isAuthorizedTargetHost(targetHost, authorizedHosts = []) { .map(host => normalizeHostForAuthorization(host)) .includes(normalizedTargetHost); } + +const REQUIRED_SSH_OPTIONS = new Set([ + 'StrictHostKeyChecking', + 'UserKnownHostsFile', + 'PasswordAuthentication', + 'ConnectTimeout', + 'ServerAliveInterval', + 'RequestTTY', + 'LogLevel', +]); + +function isAllowedSshOption(name, value) { + const fixedOptions = { + StrictHostKeyChecking: 'no', + UserKnownHostsFile: '/dev/null', + PasswordAuthentication: 'no', + LogLevel: 'ERROR', + ControlMaster: 'auto', + ProxyCommand: 'cloudflared access ssh --hostname %h', + }; + + if (Object.hasOwn(fixedOptions, name)) { + return value === fixedOptions[name]; + } + + if (name === 'RequestTTY') { + return value === 'yes' || value === 'no'; + } + + if (name === 'ConnectTimeout' || name === 'ServerAliveInterval' || name === 'ControlPersist') { + return /^\d+$/.test(value) && Number(value) > 0; + } + + if (name === 'ControlPath') { + return /^\/var\/www\/html\/storage\/app\/ssh\/mux\/mux_[a-zA-Z0-9_-]+$/.test(value); + } + + return false; +} + +export function validateSshArgs(sshArgs, authorizedHosts = []) { + if (!Array.isArray(sshArgs) || sshArgs.length === 0) { + return false; + } + + const seenOptions = new Set(); + let hasIdentityFile = false; + let hasPort = false; + let targetHost = null; + + for (let index = 0; index < sshArgs.length; index++) { + const argument = sshArgs[index]; + + if (typeof argument !== 'string' || /[\0\r\n]/.test(argument)) { + return false; + } + + if (argument === '-i') { + const identityFile = sshArgs[++index]; + if (hasIdentityFile || !/^\/var\/www\/html\/storage\/app\/ssh\/keys\/ssh_key@[a-zA-Z0-9_-]+$/.test(identityFile ?? '')) { + return false; + } + hasIdentityFile = true; + continue; + } + + if (argument === '-p') { + const port = sshArgs[++index]; + if (hasPort || !/^\d+$/.test(port ?? '') || Number(port) < 1 || Number(port) > 65535) { + return false; + } + hasPort = true; + continue; + } + + if (argument === '-o') { + const option = sshArgs[++index]; + const separator = option?.indexOf('=') ?? -1; + if (separator < 1) { + return false; + } + + const name = option.slice(0, separator); + const value = option.slice(separator + 1); + if (seenOptions.has(name) || !isAllowedSshOption(name, value)) { + return false; + } + seenOptions.add(name); + continue; + } + + if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) { + targetHost = extractTargetHost([argument]); + continue; + } + + return false; + } + + const hasRequiredOptions = [...REQUIRED_SSH_OPTIONS].every(option => seenOptions.has(option)); + const hasCompleteMultiplexingOptions = + !['ControlMaster', 'ControlPath', 'ControlPersist'].some(option => seenOptions.has(option)) + || ['ControlMaster', 'ControlPath', 'ControlPersist'].every(option => seenOptions.has(option)); + + return hasIdentityFile + && hasPort + && targetHost !== null + && hasRequiredOptions + && hasCompleteMultiplexingOptions + && isAuthorizedTargetHost(targetHost, authorizedHosts); +} + +export function sanitizeSshArgs(sshArgs) { + const multiplexingOptions = new Set(['ControlMaster', 'ControlPath', 'ControlPersist']); + const sanitizedArgs = []; + + for (let index = 0; index < sshArgs.length; index++) { + if (sshArgs[index] === '-o') { + const optionName = sshArgs[index + 1]?.split('=', 1)[0]; + if (multiplexingOptions.has(optionName)) { + index++; + continue; + } + } + + sanitizedArgs.push(sshArgs[index]); + } + + return sanitizedArgs; +} diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index bf863099b4..7af98be898 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -7,6 +7,8 @@ import { getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, + sanitizeSshArgs, + validateSshArgs, } from './terminal-utils.js'; test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { @@ -34,6 +36,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v assert.equal(sshArgs[4], 'root@example.com'); }); +test('extractSshArgs supports the generated bash or sh fallback command', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc" + ); + + assert.equal(extractTargetHost(sshArgs), '10.0.0.5'); +}); + test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true); assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true); @@ -48,6 +58,79 @@ test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); +test('validateSshArgs accepts the SSH arguments generated by Coolify', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p '22' 'root'@'10.0.0.5' 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); +}); + +test('validateSshArgs rejects an injected ProxyCommand', () => { + const sshArgs = extractSshArgs( + "timeout 300 ssh -o 'ProxyCommand=/bin/busybox id >/tmp/marker' root@10.0.0.5 'bash -se' << \\ENDSSH\nENDSSH" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('validateSshArgs accepts only the fixed Cloudflare ProxyCommand', () => { + const validArgs = extractSshArgs( + "timeout 3600 ssh -o ProxyCommand='cloudflared access ssh --hostname %h' -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@example.com 'bash -se' << \\$abc\necho hi\nabc" + ); + const maliciousArgs = [...validArgs]; + maliciousArgs[1] = 'ProxyCommand=cloudflared access ssh --hostname %h; id'; + + assert.equal(validateSshArgs(validArgs, ['example.com']), true); + assert.equal(validateSshArgs(maliciousArgs, ['example.com']), false); +}); + +test('validateSshArgs rejects unknown SSH options and key paths', () => { + const baseArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false); +}); + +test('validateSshArgs rejects a destination that begins with an option prefix', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 -evil@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), false); +}); + +test('sanitizeSshArgs removes SSH multiplexing options before spawning SSH', () => { + const sshArgs = extractSshArgs( + "timeout 3600 ssh -o ControlMaster=auto -o ControlPath=/var/www/html/storage/app/ssh/mux/mux_cm123 -o ControlPersist=3600 -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(sshArgs, ['10.0.0.5']), true); + assert.deepEqual(sanitizeSshArgs(sshArgs), [ + '-i', + '/var/www/html/storage/app/ssh/keys/ssh_key@cm123', + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'PasswordAuthentication=no', + '-o', + 'ConnectTimeout=10', + '-o', + 'ServerAliveInterval=20', + '-o', + 'RequestTTY=yes', + '-o', + 'LogLevel=ERROR', + '-p', + '22', + 'root@10.0.0.5', + ]); +}); + test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 0d7caceb95..ebf12379d5 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.17' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 43f6f0d0e9..32524c4f43 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.17' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 440ad36160..455918fdc0 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,16 +1,16 @@ { "coolify": { "v4": { - "version": "4.3.10" + "version": "4.3.15" }, "nightly": { "version": "4.4-rc.1" }, "helper": { - "version": "1.0.15" + "version": "1.0.16" }, "realtime": { - "version": "1.0.17" + "version": "1.0.18" }, "sentinel": { "version": "0.0.22" diff --git a/public/svgs/executor.png b/public/svgs/executor.png new file mode 100644 index 0000000000..a7cc57de9f Binary files /dev/null and b/public/svgs/executor.png differ diff --git a/resources/css/app.css b/resources/css/app.css index 49e2998311..d62a33ce57 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1914,6 +1914,61 @@ html[data-theme="custom"] textarea:disabled { flex-shrink: 0; } +.split-action { + display: inline-flex; +} + +.split-action-main, +.split-action-caret { + @apply button-highlighted; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.split-action-main { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.625rem; + border-radius: 6px 0 0 6px; +} + +.split-action-caret { + flex-shrink: 0; + width: 1.75rem; + border-left: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0 6px 6px 0; +} + +.split-action > .split-action-main:only-of-type { + border-radius: 6px; +} + +.split-action-main:disabled, +.split-action-caret:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.split-action-main:focus-visible, +.split-action-caret:focus-visible { + outline: none; + position: relative; + z-index: 1; + box-shadow: 0 0 0 1px var(--color-accent); +} + +.application-heading-actions .split-action-main, +.application-heading-actions .split-action-caret { + height: 1.75rem; +} + /* Custom listbox (replaces native