diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index e887abed9e..05b295b88b 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -20,10 +20,10 @@ class Show extends Component #[Validate(['string', 'required'])] public string $name; - #[Validate(['string', 'required', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'])] + #[Locked] public string $network; - #[Validate(['string', 'required'])] + #[Locked] public string $serverIp; public function mount(string $destination_uuid) @@ -49,8 +49,6 @@ class Show extends Component if ($toModel) { $this->validate(); $this->destination->name = $this->name; - $this->destination->network = $this->network; - $this->destination->server->ip = $this->serverIp; $changedFields = auditChangedFields($this->destination); $this->destination->save(); if ($changedFields !== []) { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ea8d8f197b..2cd0bfecee 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -122,8 +122,11 @@ class General extends Component $this->validate(); $this->database->name = $this->name; $this->database->description = $this->description; - $this->database->clickhouse_admin_user = $this->clickhouseAdminUser; - $this->database->clickhouse_admin_password = $this->clickhouseAdminPassword; + // After the first start the credentials are read-only, so autofilled values must not be saved. + if (! $this->database->started_at) { + $this->database->clickhouse_admin_user = $this->clickhouseAdminUser; + $this->database->clickhouse_admin_password = $this->clickhouseAdminPassword; + } $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 1505aad781..b3c5bcc945 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -110,7 +110,10 @@ class General extends Component $this->validate(); $this->database->name = $this->name; $this->database->description = $this->description; - $this->database->dragonfly_password = $this->dragonflyPassword; + // After the first start the password is read-only, so autofilled values must not be saved. + if (! $this->database->started_at) { + $this->database->dragonfly_password = $this->dragonflyPassword; + } $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 2ae64f3121..fd84bad209 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -114,7 +114,10 @@ class General extends Component $this->database->name = $this->name; $this->database->description = $this->description; $this->database->keydb_conf = $this->keydbConf; - $this->database->keydb_password = $this->keydbPassword; + // After the first start the password is read-only, so autofilled values must not be saved. + if (! $this->database->started_at) { + $this->database->keydb_password = $this->keydbPassword; + } $this->database->image = $this->image; $this->database->ports_mappings = $this->portsMappings; $this->database->is_public = $this->isPublic; diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index d8ce2e03fc..a3ddb9dc2e 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -76,9 +76,6 @@ class ExecuteContainerCommand extends Component $this->type = 'service'; $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->resource); - if (! $this->resource->isRunning()) { - $this->containersLoaded = true; - } if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } @@ -140,7 +137,7 @@ class ExecuteContainerCommand extends Component } }); $this->resource->databases()->get()->each(function ($database) { - if ($database->isRunning()) { + if ($database->isRunning() && $this->resource->server->isTerminalEnabled()) { $this->containers->push([ 'server' => $this->resource->server, 'container' => [ diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php index 0f92b2ad34..d7eaf7d596 100644 --- a/app/Livewire/SettingsBackup.php +++ b/app/Livewire/SettingsBackup.php @@ -31,21 +31,9 @@ class SettingsBackup extends Component #[Locked] public $executions = []; - #[Validate(['required'])] - public string $uuid; - - #[Validate(['required'])] - public string $name; - #[Validate(['nullable'])] public ?string $description = null; - #[Validate(['required'])] - public string $postgres_user; - - #[Validate(['required'])] - public string $postgres_password; - public bool $is_backup_before_update_enabled; public function mount() @@ -59,11 +47,7 @@ class SettingsBackup extends Component $this->database = StandalonePostgresql::find(0); $s3s = S3Storage::whereTeamId(0)->get() ?? []; if ($this->database) { - $this->uuid = $this->database->uuid; - $this->name = $this->database->name; $this->description = $this->database->description; - $this->postgres_user = $this->database->postgres_user; - $this->postgres_password = $this->database->postgres_password; if ($this->database->status !== 'running') { $this->database->status = 'running'; @@ -127,11 +111,7 @@ class SettingsBackup extends Component } $this->s3s = S3Storage::whereTeamId(0)->get(); - $this->uuid = $this->database->uuid; - $this->name = $this->database->name; $this->description = $this->database->description; - $this->postgres_user = $this->database->postgres_user; - $this->postgres_password = $this->database->postgres_password; $this->executions = $this->backup->executions; } catch (\Exception $e) { @@ -166,11 +146,9 @@ class SettingsBackup extends Component $this->authorize('update', $this->settings); $this->validate(); + // Credentials come from the coolify-db container and are display-only, so only the description is saved. $this->database->update([ - 'name' => $this->name, 'description' => $this->description, - 'postgres_user' => $this->postgres_user, - 'postgres_password' => $this->postgres_password, ]); $this->dispatch('success', 'Backup updated.'); } catch (\Throwable $e) { diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f2c6116c06..f279f978ef 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -322,7 +322,9 @@ class ServerSetting extends Model { $url = $this->sentinel_custom_url; - if ($this->server->isLocalhost() && $url === 'http://host.docker.internal:8000') { + // The local Sentinel runs on the coolify network. A host gateway URL leaves that network + // through the host INPUT chain, where host firewalls such as UFW drop it. + if ($this->server->isLocalhost() && strtolower((string) parse_url((string) $url, PHP_URL_HOST)) === 'host.docker.internal') { $url = null; } diff --git a/resources/views/livewire/settings-backup.blade.php b/resources/views/livewire/settings-backup.blade.php index 05673e9816..93c2269d65 100644 --- a/resources/views/livewire/settings-backup.blade.php +++ b/resources/views/livewire/settings-backup.blade.php @@ -13,13 +13,12 @@
- +
- +
- - +
diff --git a/tests/Feature/ReadOnlyFieldsAreNotPersistedTest.php b/tests/Feature/ReadOnlyFieldsAreNotPersistedTest.php new file mode 100644 index 0000000000..4075717d90 --- /dev/null +++ b/tests/Feature/ReadOnlyFieldsAreNotPersistedTest.php @@ -0,0 +1,95 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner->id, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); +}); + +dataset('read-only-credential-components', [ + 'keydb' => ['create_standalone_keydb', KeydbGeneral::class, ['keydbPassword' => 'keydb_password']], + 'dragonfly' => ['create_standalone_dragonfly', DragonflyGeneral::class, ['dragonflyPassword' => 'dragonfly_password']], + 'clickhouse' => ['create_standalone_clickhouse', ClickhouseGeneral::class, [ + 'clickhouseAdminUser' => 'clickhouse_admin_user', + 'clickhouseAdminPassword' => 'clickhouse_admin_password', + ]], +]); + +it('does not save read-only credentials of a started database', function (string $createDatabase, string $generalClass, array $fields) { + $database = $createDatabase($this->environment->id, $this->destination, ['started_at' => now()])->fresh(); + $stored = collect($fields)->mapWithKeys(fn (string $column) => [$column => $database->{$column}]); + + $component = Livewire::test($generalClass, ['database' => $database])->set('description', 'Updated'); + foreach (array_keys($fields) as $property) { + $component->set($property, 'AutofilledLogin1234567890'); + } + $component->call('submit')->assertHasNoErrors(); + + $database->refresh(); + expect($database->description)->toBe('Updated'); + foreach ($stored as $column => $value) { + expect($database->{$column})->toBe($value); + } +})->with('read-only-credential-components'); + +it('saves credentials of a database that was never started', function (string $createDatabase, string $generalClass, array $fields) { + $database = $createDatabase($this->environment->id, $this->destination)->fresh(); + + $component = Livewire::test($generalClass, ['database' => $database]); + foreach (array_keys($fields) as $property) { + $component->set($property, 'ChangedBeforeStart1234567890'); + } + $component->call('submit')->assertHasNoErrors(); + + $database->refresh(); + foreach ($fields as $column) { + expect($database->{$column})->toBe('ChangedBeforeStart1234567890'); + } +})->with('read-only-credential-components'); + +it('rejects changes to the read-only destination network and server IP', function (string $property) { + $component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destination->uuid]); + + expect(fn () => $component->set($property, 'tampered'))->toThrow(CannotUpdateLockedPropertyException::class); +})->with(['network', 'serverIp']); + +it('saves the destination name without changing the network or server IP', function () { + $network = $this->destination->network; + $ip = (string) $this->server->ip; + + Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destination->uuid]) + ->set('name', 'renamed-destination') + ->call('submit') + ->assertHasNoErrors(); + + expect($this->destination->fresh()->name)->toBe('renamed-destination') + ->and($this->destination->fresh()->network)->toBe($network) + ->and((string) $this->server->fresh()->ip)->toBe($ip); +}); diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php index 6c59952522..2416317c64 100644 --- a/tests/Feature/SentinelTokenValidationTest.php +++ b/tests/Feature/SentinelTokenValidationTest.php @@ -193,6 +193,30 @@ describe('ServerSetting::ensureSentinelUrl', function () { ->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url); }); + it('replaces a local host gateway endpoint on any port, because the Coolify network firewall can block it', function (string $legacyUrl) { + $this->server->update(['ip' => 'host.docker.internal']); + DB::table('server_settings')->where('id', $this->server->settings->id)->update([ + 'sentinel_custom_url' => $legacyUrl, + ]); + + $url = $this->server->settings->fresh()->ensureSentinelUrl(); + + expect($url)->toBe('http://coolify:8080') + ->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url); + })->with([ + 'custom APP_PORT' => 'http://host.docker.internal:8010', + 'trailing slash' => 'http://host.docker.internal:8000/', + 'uppercase host' => 'HTTP://Host.Docker.Internal:8010', + ]); + + it('keeps a host gateway endpoint for a remote server', function () { + DB::table('server_settings')->where('id', $this->server->settings->id)->update([ + 'sentinel_custom_url' => 'http://host.docker.internal:8010', + ]); + + expect($this->server->settings->fresh()->ensureSentinelUrl())->toBe('http://host.docker.internal:8010'); + }); + it('preserves an explicit custom URL for the local server', function () { $this->server->update(['ip' => 'host.docker.internal']); DB::table('server_settings')->where('id', $this->server->settings->id)->update([ diff --git a/tests/Feature/SettingsBackupEmptyStateTest.php b/tests/Feature/SettingsBackupEmptyStateTest.php index 605350beb8..30392acc49 100644 --- a/tests/Feature/SettingsBackupEmptyStateTest.php +++ b/tests/Feature/SettingsBackupEmptyStateTest.php @@ -216,11 +216,38 @@ test('configure backup creates the instance database instead of using a team dat Livewire::test(SettingsBackup::class) ->call('addCoolifyDatabase') - ->assertHasNoErrors() - ->assertSet('postgres_password', 'instance-secret'); + ->assertHasNoErrors(); $instanceDatabase = StandalonePostgresql::query()->findOrFail(0); - expect($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0) + expect($instanceDatabase->postgres_password)->toBe('instance-secret') + ->and($instanceDatabase->scheduledBackups()->sole()->team_id)->toBe(0) ->and($tenantDatabase->fresh()->scheduledBackups()->exists())->toBeFalse() ->and($tenantDatabase->fresh()->postgres_password)->toBe('tenant-secret'); }); + +test('saving instance backup settings only persists the description', function () { + $database = settingsBackupInstanceDatabaseWithoutSchedule(); + $database->forceFill(['postgres_password' => 'instance-db-secret'])->save(); + $database->scheduledBackups()->create([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 0 * * *', + 'team_id' => 0, + ]); + + Livewire::test(SettingsBackup::class) + ->assertSee('Instance database') + ->assertSee('coolify') + ->assertDontSeeHtml('wire:model=postgres_user') + ->assertDontSeeHtml('wire:model=postgres_password') + ->assertDontSee('instance-db-secret', stripInitialData: false) + ->set('description', 'Updated description') + ->call('submit') + ->assertHasNoErrors(); + + $database->refresh(); + expect($database->description)->toBe('Updated description') + ->and($database->name)->toBe('coolify-db') + ->and($database->postgres_user)->toBe('coolify') + ->and($database->postgres_password)->toBe('instance-db-secret'); +}); diff --git a/tests/Feature/TerminalSessionStartFailureTest.php b/tests/Feature/TerminalSessionStartFailureTest.php index 80990fed1f..6871500448 100644 --- a/tests/Feature/TerminalSessionStartFailureTest.php +++ b/tests/Feature/TerminalSessionStartFailureTest.php @@ -8,6 +8,9 @@ use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\Team; use App\Models\User; @@ -106,6 +109,27 @@ function createTerminalApplication(Server $server, Team $team): Application ]); } +/** + * A degraded service: app0 has exited, app1 and postgres are running. + */ +function createDegradedTerminalService(Server $server, Team $team): Service +{ + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + ServiceApplication::create(['service_id' => $service->id, 'name' => 'app0', 'status' => 'exited']); + ServiceApplication::create(['service_id' => $service->id, 'name' => 'app1', 'status' => 'running:unhealthy']); + ServiceDatabase::create(['service_id' => $service->id, 'name' => 'postgres', 'status' => 'running:healthy']); + + return $service; +} + describe('Terminal::sendTerminalCommand', function () { it('issues a token for a running container with a shell', function () { fakeTerminalContainer('running'); @@ -269,4 +293,32 @@ describe('ExecuteContainerCommand', function () { ->assertDispatchedTo(Terminal::class, Terminal::AUTO_START_CANCELLED_EVENT) ->assertNotDispatched('send-terminal-command'); }); + + it('lists the running containers of a degraded service', function () { + Process::fake(); + $service = createDegradedTerminalService($this->server, $this->team); + bindTerminalRouteParameters(['service_uuid' => $service->uuid]); + + expect($service->fresh()->status)->toStartWith('degraded'); + + $component = Livewire::test(ExecuteContainerCommand::class) + ->call('loadContainers') + ->assertDispatchedTo(Terminal::class, Terminal::AUTO_START_CANCELLED_EVENT); + + expect(collect($component->get('containerOptions'))->pluck('value')->all())->toBe([ + "{$this->server->uuid}:app1-{$service->uuid}", + "{$this->server->uuid}:postgres-{$service->uuid}", + ]); + }); + + it('lists no service containers when terminal access is disabled on the server', function () { + Process::fake(); + $service = createDegradedTerminalService($this->server, $this->team); + $this->server->settings->update(['is_terminal_enabled' => false]); + bindTerminalRouteParameters(['service_uuid' => $service->uuid]); + + Livewire::test(ExecuteContainerCommand::class) + ->call('loadContainers') + ->assertSet('containerOptions', []); + }); });