Merge remote-tracking branch 'origin/next' into fix/ux-close-icon-buttons

This commit is contained in:
Andras Bacsai 2026-06-03 11:40:56 +02:00
commit 41fd6f5608
263 changed files with 9358 additions and 3475 deletions

View file

@ -13,7 +13,7 @@ class StopApplication
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
{ {
$servers = collect([$application->destination->server]); $servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) { if ($application?->additional_servers?->count() > 0) {
@ -57,12 +57,17 @@ public function handle(Application $application, bool $previewDeployments = fals
} }
} }
// Reset restart tracking when application is manually stopped if ($resetRestartCount) {
$application->update([ $application->update([
'restart_count' => 0, 'restart_count' => 0,
'last_restart_at' => null, 'last_restart_at' => null,
'last_restart_type' => null, 'last_restart_type' => null,
]); ]);
} else {
$application->update([
'status' => 'exited',
]);
}
ServiceStatusChanged::dispatch($application->environment->project->team->id); ServiceStatusChanged::dispatch($application->environment->project->team->id);
} }

View file

@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
], ],
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -98,6 +94,9 @@ public function handle(StandaloneClickhouse $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -182,6 +178,9 @@ public function handle(StandaloneDragonfly $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
// Add custom docker run options // Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD',
'CMD', 'echo',
'echo', 'ok',
'ok', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database)
$docker_compose['services'][$container_name]['command'] = $commandParts; $docker_compose['services'][$container_name]['command'] = $commandParts;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database)
]; ];
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'], 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'interval' => '5s', ]),
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database)
$docker_compose['services'][$container_name]['command'] = $command; $docker_compose['services'][$container_name]['command'] = $command;
} }
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
$this->database->destination->network, $this->database->destination->network,
], ],
'labels' => defaultDatabaseLabels($this->database)->toArray(), 'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [ 'healthcheck' => $this->database->healthCheckConfiguration([
'test' => [ 'CMD-SHELL',
'CMD-SHELL', 'redis-cli',
'redis-cli', 'ping',
'ping', ]),
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory, 'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap, 'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness, 'mem_swappiness' => $this->database->limits_memory_swappiness,
@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (! $this->database->isHealthcheckEnabled()) {
unset($docker_compose['services'][$container_name]['healthcheck']);
}
$docker_compose = Yaml::dump($docker_compose, 10); $docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose); $docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";

View file

@ -2,6 +2,7 @@
namespace App\Actions\Docker; namespace App\Actions\Docker;
use App\Actions\Application\StopApplication;
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck; use App\Actions\Shared\ComplexStatusCheck;
@ -9,6 +10,7 @@
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Models\Server; use App\Models\Server;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator; use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus; use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -464,7 +466,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} }
// Wrap all database updates in a transaction to ensure consistency // Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) { $restartLimitReached = false;
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0; $previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) { if ($maxRestartCount > $previousRestartCount) {
@ -475,16 +479,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
'last_restart_type' => 'crash', 'last_restart_type' => 'crash',
]); ]);
// Send notification // Check if restart limit has been reached
$containerName = $application->name; $maxAllowedRestarts = $application->max_restart_count ?? 0;
$projectUuid = data_get($application, 'environment.project.uuid'); if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
$environmentName = data_get($application, 'environment.name'); $restartLimitReached = true;
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
} }
} }
@ -499,6 +497,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} }
} }
}); });
if ($restartLimitReached) {
$application->refresh();
StopApplication::dispatch($application, false, true, false);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
} }
} }

View file

@ -51,7 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd, $imagePruneCmd,
'docker builder prune -af', 'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', "docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",

View file

@ -3,6 +3,7 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Models\Server; use App\Models\Server;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain class StartLogDrain
@ -201,10 +202,29 @@ public function handle(Server $server)
"echo 'Starting Fluent Bit'", "echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d", "cd $config_path && docker compose up -d",
]; ];
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
return instant_remote_process($command, $server); return instant_remote_process($command, $server);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
} }
} }
private function logDrainNetworkConnectCommands(Server $server): array
{
if (! $server->isLogDrainEnabled()) {
return [];
}
return $server->services()
->with('destination')
->where('connect_to_docker_network', true)
->get()
->map(fn (Service $service) => data_get($service, 'destination.network'))
->filter()
->unique()
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
->values()
->all();
}
} }

View file

@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages) public function handle(Service $service, bool $pullLatestImages)
{ {
StopService::run($service); return StartService::run(
service: $service,
return StartService::run($service, $pullLatestImages); pullLatestImages: $pullLatestImages,
stopBeforeStart: true,
);
} }
} }

View file

@ -19,7 +19,7 @@ public function configureJob(JobDecorator $job): void
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{ {
$service->parse(); $service->parse();
if ($stopBeforeStart) { if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false); StopService::run(service: $service, dockerCleanup: false);
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
@ -50,7 +50,34 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true"; $commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
} }
} }
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
} }
private function logDrainNetworkConnectCommands(Service $service): array
{
if (! data_get($service, 'connect_to_docker_network')) {
return [];
}
if (! $service->destination?->server?->isLogDrainEnabled()) {
return [];
}
$network = data_get($service, 'destination.network');
if (blank($network)) {
return [];
}
return [
'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
];
}
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
{
return $stopBeforeStart && ! $pullLatestImages;
}
} }

View file

@ -137,9 +137,11 @@ public function execute(): array
// Update the new owner's role to owner // Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team // Remove the current user from the team
$team->members()->detach($this->user->id); $team->members()->detach($this->user->id);
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['transferred']++; $counts['transferred']++;
} catch (\Exception $e) { } catch (\Exception $e) {
@ -152,6 +154,7 @@ public function execute(): array
foreach ($preview['to_leave'] as $team) { foreach ($preview['to_leave'] as $team) {
try { try {
$team->members()->detach($this->user->id); $team->members()->detach($this->user->id);
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['left']++; $counts['left']++;
} catch (\Exception $e) { } catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());

View file

@ -0,0 +1,43 @@
<?php
namespace App\Actions\User;
use App\Models\PersonalAccessToken;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class RevokeUserTeamTokens
{
public static function forUserTeam(User|int $user, int|string $teamId): int
{
return self::baseQuery()
->where('tokenable_id', self::userId($user))
->where('team_id', $teamId)
->delete();
}
public static function forUser(User|int $user): int
{
return self::baseQuery()
->where('tokenable_id', self::userId($user))
->delete();
}
public static function forTeam(int|string $teamId): int
{
return self::baseQuery()
->where('team_id', $teamId)
->delete();
}
private static function baseQuery(): Builder
{
return PersonalAccessToken::query()
->where('tokenable_type', User::class);
}
private static function userId(User|int $user): int
{
return $user instanceof User ? $user->id : $user;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/**
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
* plaintext JSON rows written before the column was encrypted, so existing
* snapshots keep decoding instead of throwing.
*
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
*/
class EncryptedArrayCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
* @return array<mixed>|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if ($value === null || $value === '') {
return null;
}
try {
$value = Crypt::decryptString($value);
} catch (DecryptException) {
// Legacy plaintext JSON written before this column was encrypted.
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
}
}

View file

@ -18,9 +18,13 @@ public function handle()
if ($servers->count() > 0) { if ($servers->count() > 0) {
foreach ($servers as $server) { foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name"; echo "Cleanup unreachable server ($server->id) with name $server->name";
$server->update([ if (isCloud()) {
'ip' => '1.2.3.4', $server->update([
]); 'ip' => '1.2.3.4',
]);
} else {
$server->forceDisableServer();
}
} }
} }
} }

View file

@ -253,7 +253,7 @@ private function restoreCoolifyDbBackup()
'save_s3' => false, 'save_s3' => false,
'frequency' => '0 0 * * *', 'frequency' => '0 0 * * *',
'database_id' => $database->id, 'database_id' => $database->id,
'database_type' => \App\Models\StandalonePostgresql::class, 'database_type' => StandalonePostgresql::class,
'team_id' => 0, 'team_id' => 0,
]); ]);
} }

View file

@ -8,6 +8,7 @@
use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog; use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
@ -40,6 +41,10 @@ protected function schedule(Schedule $schedule): void
$this->instanceTimezone = config('app.timezone'); $this->instanceTimezone = config('app.timezone');
} }
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
->name('cleanup:ssh-mux')
->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:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();

View file

@ -4,6 +4,8 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
@ -23,23 +25,77 @@ public static function serverSshConfiguration(Server $server): array
public static function ensureMultiplexedConnection(Server $server): bool public static function ensureMultiplexedConnection(Server $server): bool
{ {
return self::isMultiplexingEnabled(); if (! self::isMultiplexingEnabled()) {
return false;
}
if (self::connectionIsReusable($server)) {
return true;
}
try {
return Cache::lock(
self::connectionLockKey($server),
config('constants.ssh.mux_lock_ttl')
)->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
if (self::connectionIsReusable($server)) {
return true;
}
if (self::masterConnectionExists($server)) {
return self::refreshMultiplexedConnection($server);
}
return self::establishNewMultiplexedConnection($server);
});
} catch (LockTimeoutException) {
Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
]);
return false;
} catch (\Throwable $e) {
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
return false;
}
}
public static function establishNewMultiplexedConnection(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server);
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
}
self::storeConnectionMetadata($server);
return true;
} }
public static function removeMuxFile(Server $server): void public static function removeMuxFile(Server $server): void
{ {
$closeCommand = self::muxControlCommand($server, 'exit'); Process::run(self::muxControlCommand($server, 'exit'));
Process::run($closeCommand); self::clearConnectionMetadata($server);
}
private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
return $command.self::escapedUserAtHost($server);
} }
public static function generateScpCommand(Server $server, string $source, string $dest): string public static function generateScpCommand(Server $server, string $source, string $dest): string
@ -53,7 +109,16 @@ public static function generateScpCommand(Server $server, string $source, string
} }
if (self::isMultiplexingEnabled()) { if (self::isMultiplexingEnabled()) {
$scpCommand .= self::multiplexingOptions($server); try {
if (self::ensureMultiplexedConnection($server)) {
$scpCommand .= self::multiplexingOptions($server);
}
} catch (\Throwable $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
@ -69,7 +134,7 @@ public static function generateScpCommand(Server $server, string $source, string
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest); return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
} }
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false): string public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{ {
if ($server->settings->force_disabled) { if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.'); throw new \RuntimeException('Server is disabled.');
@ -80,10 +145,20 @@ public static function generateSshCommand(Server $server, string $command, bool
self::validateSshKey($server->privateKey); self::validateSshKey($server->privateKey);
$sshCommand = 'timeout '.config('constants.ssh.command_timeout').' ssh '; $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
if (! $disableMultiplexing && self::isMultiplexingEnabled()) { if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
$sshCommand .= self::multiplexingOptions($server); try {
if (self::ensureMultiplexedConnection($server)) {
$sshCommand .= self::multiplexingOptions($server);
}
} catch (\Throwable $e) {
Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
}
} }
if (data_get($server, 'settings.is_cloudflare_tunnel')) { if (data_get($server, 'settings.is_cloudflare_tunnel')) {
@ -100,6 +175,99 @@ public static function generateSshCommand(Server $server, string $command, bool
.$delimiter; .$delimiter;
} }
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
public static function isConnectionHealthy(Server $server): bool
{
$sshConfig = self::serverSshConfiguration($server);
$muxSocket = $sshConfig['muxFilename'];
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
$process = Process::run($healthCommand);
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
}
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
public static function getConnectionAge(Server $server): ?int
{
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
public static function refreshMultiplexedConnection(Server $server): bool
{
self::removeMuxFile($server);
return self::establishNewMultiplexedConnection($server);
}
private static function connectionLockKey(Server $server): string
{
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
}
private static function masterConnectionExists(Server $server): bool
{
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
}
private static function connectionIsReusable(Server $server): bool
{
if (! self::masterConnectionExists($server)) {
return false;
}
if (self::getConnectionAge($server) === null) {
self::storeConnectionMetadata($server);
}
if (self::isConnectionExpired($server)) {
return false;
}
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
return false;
}
return true;
}
private static function muxControlCommand(Server $server, string $operation): string
{
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
return $command.self::escapedUserAtHost($server);
}
private static function multiplexingOptions(Server $server): string private static function multiplexingOptions(Server $server): string
{ {
return '-o ControlMaster=auto ' return '-o ControlMaster=auto '
@ -157,15 +325,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
} }
} }
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{ {
$options = "-i {$sshKeyLocation} " $options = "-i {$sshKeyLocation} "
@ -182,4 +341,14 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
return $options.'-p '.escapeshellarg((string) $server->port).' '; return $options.'-p '.escapeshellarg((string) $server->port).' ';
} }
private static function storeConnectionMetadata(Server $server): void
{
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
}
private static function clearConnectionMetadata(Server $server): void
{
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
}
} }

View file

@ -17,6 +17,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\Server; use App\Models\Server;
use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
@ -145,7 +146,7 @@ public function applications(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -311,7 +312,7 @@ public function create_public_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -477,7 +478,7 @@ public function create_private_gh_app_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -780,7 +781,7 @@ public function create_dockerfile_application(Request $request)
mediaType: 'application/json', mediaType: 'application/json',
schema: new OA\Schema( schema: new OA\Schema(
type: 'object', type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'], required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
properties: [ properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@ -1023,7 +1024,7 @@ private function create_application(Request $request, $type)
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
@ -1229,7 +1230,7 @@ private function create_application(Request $request, $type)
'git_repository' => 'string|required', 'git_repository' => 'string|required',
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'github_app_uuid' => 'string|required', 'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable', 'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
@ -1469,7 +1470,7 @@ private function create_application(Request $request, $type)
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch], 'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'private_key_uuid' => 'string|required', 'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable', 'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
@ -1790,9 +1791,9 @@ private function create_application(Request $request, $type)
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === 'dockerimage') { } elseif ($type === 'dockerimage') {
$validationRules = [ $validationRules = [
'docker_registry_image_name' => 'string|required', 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_tag' => 'string', 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules); $validator = customApiValidator($request->all(), $validationRules);

View file

@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
], ],
), ),
) )
@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
} }
break; break;
} }
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
$healthCheckValidator = customApiValidator($request->all(), [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer|min:1',
'health_check_timeout' => 'integer|min:1',
'health_check_retries' => 'integer|min:1',
'health_check_start_period' => 'integer|min:0',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields); $extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) { if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
$errors = $validator->errors(); $errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) { if (! empty($extraFields)) {
foreach ($extraFields as $field) { foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.'); $errors->add($field, 'This field is not allowed.');

View file

@ -7,6 +7,7 @@
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -98,23 +99,50 @@ public function link()
{ {
$token = request()->get('token'); $token = request()->get('token');
if ($token) { if ($token) {
$decrypted = Crypt::decryptString($token); try {
$email = str($decrypted)->before('@@@'); $decrypted = Crypt::decryptString($token);
$password = str($decrypted)->after('@@@'); } catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first(); $user = User::whereEmail($email)->first();
if (! $user) { if (! $user) {
return redirect()->route('login'); return redirect()->route('login');
} }
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) { if (Hash::check($password, $user->password)) {
$invitation = TeamInvitation::whereEmail($email); $team = $invitation->team;
if ($invitation->exists()) { if (! $user->teams()->where('team_id', $team->id)->exists()) {
$team = $invitation->first()->team; $user->teams()->attach($team->id, ['role' => $invitation->role]);
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
$invitation->delete();
} else {
$team = $user->teams()->first();
} }
$invitation->delete();
Auth::login($user); Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
session(['currentTeam' => $team]); session(['currentTeam' => $team]);
return redirect()->route('dashboard'); return redirect()->route('dashboard');

View file

@ -81,6 +81,10 @@ protected function canonicalManualWebhookRepository(?string $gitRepository): ?st
$path = data_get($parts, 'path'); $path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) { } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':'); $path = Str::after($gitRepository, ':');
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else { } else {
$path = $gitRepository; $path = $gitRepository;
} }

View file

@ -11,6 +11,8 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use Exception; use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -62,6 +64,7 @@ public function manual(Request $request)
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -222,6 +225,7 @@ public function manual(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -303,6 +307,7 @@ public function normal(Request $request)
$before_sha = data_get($payload, 'before'); $before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association'); $author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
} }
if (! in_array($x_github_event, ['push', 'pull_request'])) { if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported."); return response("Nothing to do. Event '$x_github_event' is not supported.");
@ -434,6 +439,7 @@ public function normal(Request $request)
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association, authorAssociation: $author_association,
fullName: $full_name, fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
); );
$return_payloads->push([ $return_payloads->push([
@ -451,6 +457,40 @@ public function normal(Request $request)
} }
} }
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return (string) $headRepoId !== (string) $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
}
public function redirect(Request $request) public function redirect(Request $request)
{ {
$code = (string) $request->query('code', ''); $code = (string) $request->query('code', '');
@ -501,19 +541,22 @@ public function redirect(Request $request)
public function install(Request $request) public function install(Request $request)
{ {
$source = (string) $request->query('source', '');
abort_if(blank($source), 404);
$github_app = GithubApp::ownedByCurrentTeam()->where('uuid', $source)->firstOrFail();
$setup_action = (string) $request->query('setup_action', ''); $setup_action = (string) $request->query('setup_action', '');
if ($setup_action !== 'install') { abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
$installation_id = (string) $request->query('installation_id', ''); $installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.'); abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
abort_unless( abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id), $this->githubInstallationBelongsToApp($github_app, $installation_id),
403, 403,
@ -526,6 +569,19 @@ public function install(Request $request)
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} }
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
return redirect()->route('source.all');
}
/** /**
* Verify that the given installation id actually belongs to this GitHub App. * Verify that the given installation id actually belongs to this GitHub App.
* *
@ -558,11 +614,14 @@ private function githubInstallationBelongsToApp(GithubApp $github_app, string $i
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{ {
abort_if(blank($state), 404); if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state)); $payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
abort_unless(is_array($payload), 404); if (! is_array($payload) || data_get($payload, 'action') !== $action) {
abort_unless(data_get($payload, 'action') === $action, 404); $this->rejectInvalidGithubAppSetupState($request);
}
$team_id = $request->user()?->currentTeam()?->id; $team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
@ -572,6 +631,18 @@ private function consumeGithubAppSetupState(Request $request, string $state, str
->firstOrFail(); ->firstOrFail();
} }
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string private function githubAppSetupStateCacheKey(string $state): string
{ {
return 'github-app-setup-state:'.hash('sha256', $state); return 'github-app-setup-state:'.hash('sha256', $state);

View file

@ -12,6 +12,7 @@
use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrimStrings;
@ -104,6 +105,7 @@ class Kernel extends HttpKernel
'ability' => CheckForAnyAbility::class, 'ability' => CheckForAnyAbility::class,
'api.ability' => ApiAbility::class, 'api.ability' => ApiAbility::class,
'api.sensitive' => ApiSensitiveData::class, 'api.sensitive' => ApiSensitiveData::class,
'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class,
'can.create.resources' => CanCreateResources::class, 'can.create.resources' => CanCreateResources::class,
'can.update.resource' => CanUpdateResource::class, 'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class, 'can.access.terminal' => CanAccessTerminal::class,

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTokenBelongsToCurrentTeamMember
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$token = $user?->currentAccessToken();
$teamId = $token?->team_id;
if (! $user || ! $token || is_null($teamId)) {
return response()->json(['message' => 'Invalid token.'], 401);
}
$team = $user->teams()
->where('teams.id', $teamId)
->first();
if (! $team) {
return response()->json(['message' => 'Invalid token.'], 401);
}
$role = $team->pivot?->role;
if (($token->can('root') || $token->can('write') || $token->can('write:sensitive'))
&& ! in_array($role, ['admin', 'owner'], true)) {
return response()->json(['message' => 'Missing required team role.'], 403);
}
return $next($request);
}
}

View file

@ -220,6 +220,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile'; $this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server; $this->only_this_server = $this->application_deployment_queue->only_this_server;
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
$this->validateDockerRegistryImageConfiguration();
$this->git_type = data_get($this->application_deployment_queue, 'git_type'); $this->git_type = data_get($this->application_deployment_queue, 'git_type');
@ -1106,7 +1107,7 @@ private function push_to_docker_registry()
'hidden' => true, 'hidden' => true,
], ],
); );
if ($this->application->docker_registry_image_tag) { if ($this->shouldPushDockerRegistryImageTag()) {
// Tag image with docker_registry_image_tag // Tag image with docker_registry_image_tag
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
$this->execute_remote_command( $this->execute_remote_command(
@ -1130,6 +1131,30 @@ private function push_to_docker_registry()
} }
} }
private function shouldPushDockerRegistryImageTag(): bool
{
if (blank($this->application->docker_registry_image_tag)) {
return false;
}
return $this->pull_request_id === 0;
}
private function validateDockerRegistryImageConfiguration(): void
{
if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
throw new DeploymentException('Docker registry image name contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
throw new DeploymentException('Docker registry image tag contains invalid characters.');
}
if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
}
}
private function generate_image_names() private function generate_image_names()
{ {
if ($this->application->dockerfile) { if ($this->application->dockerfile) {
@ -1293,12 +1318,8 @@ private function generate_runtime_environment_variables()
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
} }
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
});
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
});
} }
$ports = $this->application->main_port(); $ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
@ -1367,7 +1388,7 @@ private function generate_runtime_environment_variables()
// Add PORT if not exists, use the first port as default // Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') { if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
$envs->push("PORT={$ports[0]}"); $envs->push("PORT={$ports[0]}");
} }
} }
@ -1451,6 +1472,15 @@ private function generate_runtime_environment_variables()
return $envs; return $envs;
} }
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
$key = str($environmentVariable->key);
return $key->startsWith('SERVICE_FQDN_')
|| $key->startsWith('SERVICE_URL_')
|| $key->startsWith('SERVICE_NAME_');
}
private function save_runtime_environment_variables() private function save_runtime_environment_variables()
{ {
// This method saves the .env file with ALL runtime variables // This method saves the .env file with ALL runtime variables
@ -1666,11 +1696,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these // For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -1719,11 +1747,9 @@ private function generate_buildtime_environment_variables()
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get(); ->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
@ -2103,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true)
$helperImage = "{$helperImage}:".getHelperVersion(); $helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory // Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets(); $env_flags = $this->generate_docker_env_flags_for_secrets();
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) { if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') { if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
} }
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else { } else {
if ($this->dockerConfigFileExists === 'OK') { if ($this->dockerConfigFileExists === 'OK') {
$safeNetwork = escapeshellarg($this->destination->network); $safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else { } else {
$safeNetwork = escapeshellarg($this->destination->network); $safeNetwork = escapeshellarg($this->destination->network);
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} }
} }
if ($firstTry) { if ($firstTry) {
@ -2222,11 +2250,22 @@ private function set_coolify_variables()
} }
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
} }
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
} }
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
{
$sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
if ($identityFile !== null) {
$sshCommand .= " -i {$identityFile}";
}
return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
}
private function check_git_if_build_needed() private function check_git_if_build_needed()
{ {
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
@ -2272,7 +2311,7 @@ private function check_git_if_build_needed()
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
], ],
[ [
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
] ]
@ -2280,7 +2319,7 @@ private function check_git_if_build_needed()
} else { } else {
$this->execute_remote_command( $this->execute_remote_command(
[ [
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
'hidden' => true, 'hidden' => true,
'save' => 'git_commit_sha', 'save' => 'git_commit_sha',
], ],
@ -3019,6 +3058,10 @@ private function generate_env_variables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -3031,6 +3074,10 @@ private function generate_env_variables()
->where('is_buildtime', true) ->where('is_buildtime', true)
->get(); ->get();
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($envs as $env) { foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) { if (! is_null($resolvedValue)) {
@ -3091,7 +3138,7 @@ private function generate_compose_file()
'image' => $this->production_image_name, 'image' => $this->production_image_name,
'container_name' => $this->container_name, 'container_name' => $this->container_name,
'restart' => RESTART_MODE, 'restart' => RESTART_MODE,
'expose' => $ports, ...(! empty($ports) ? ['expose' => $ports] : []),
'networks' => [ 'networks' => [
$this->destination->network => [ $this->destination->network => [
'aliases' => array_merge( 'aliases' => array_merge(
@ -3123,16 +3170,19 @@ private function generate_compose_file()
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added // If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
$docker_compose['services'][$this->container_name]['healthcheck'] = [ $healthcheck_command = $this->generate_healthcheck_commands();
'test' => [ if ($healthcheck_command !== null) {
'CMD-SHELL', $docker_compose['services'][$this->container_name]['healthcheck'] = [
$this->generate_healthcheck_commands(), 'test' => [
], 'CMD-SHELL',
'interval' => $this->application->health_check_interval.'s', $healthcheck_command,
'timeout' => $this->application->health_check_timeout.'s', ],
'retries' => $this->application->health_check_retries, 'interval' => $this->application->health_check_interval.'s',
'start_period' => $this->application->health_check_start_period.'s', 'timeout' => $this->application->health_check_timeout.'s',
]; 'retries' => $this->application->health_check_retries,
'start_period' => $this->application->health_check_start_period.'s',
];
}
} }
if (! is_null($this->application->limits_cpuset)) { if (! is_null($this->application->limits_cpuset)) {
@ -3342,7 +3392,11 @@ private function generate_healthcheck_commands()
// HTTP type healthcheck (default) // HTTP type healthcheck (default)
if (! $this->application->health_check_port) { if (! $this->application->health_check_port) {
$health_check_port = (int) $this->application->ports_exposes_array[0]; if (! empty($this->application->ports_exposes_array)) {
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
return null;
}
} else { } else {
$health_check_port = (int) $this->application->health_check_port; $health_check_port = (int) $this->application->health_check_port;
} }

View file

@ -0,0 +1,228 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class CleanupStaleMultiplexedConnections implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle()
{
$this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections();
$this->cleanupOrphanedSshProcesses();
$this->cleanupOrphanedCloudflaredProcesses();
}
/**
* Kill backgrounded ssh master processes that lost the ControlPath socket
* race. Such processes are not masters, so ControlPersist never reaps them
* and they leak memory until the container restarts. A legitimate master
* always owns its socket file; an orphan has none.
*
* Processes younger than the minimum age are skipped: a freshly forked
* master creates its socket a few milliseconds after starting, so a young
* process with no socket may simply be mid-establish rather than orphaned.
*/
private function cleanupOrphanedSshProcesses(): void
{
$muxDir = storage_path('app/ssh/mux');
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
foreach ($this->listProcesses() as $process) {
// Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
continue;
}
// Only ever touch ssh processes pointing at Coolify's mux directory.
if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
continue;
}
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
$this->reapOrphan('ssh', $process);
}
}
}
/**
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
* mux master), the cloudflared process can leak and accumulate. A legitimate
* proxy always has a live ssh parent; one without is safe to reap.
*
* Processes younger than the minimum age are skipped so a proxy whose parent
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
* never mistaken for an orphan.
*/
private function cleanupOrphanedCloudflaredProcesses(): void
{
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
$processes = $this->listProcesses();
$sshPids = [];
foreach ($processes as $process) {
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
$sshPids[$process['pid']] = true;
}
}
foreach ($processes as $process) {
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
if (! str_contains($process['args'], 'cloudflared access ssh')) {
continue;
}
// Orphaned when no live ssh process is its parent.
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
$this->reapOrphan('cloudflared', $process);
}
}
}
/**
* Reap a detected orphan process. When orphan reaping is disabled (the
* default), the orphan is only logged a dry-run mode that lets operators
* verify what would be killed before enabling it for real.
*
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
*/
private function reapOrphan(string $kind, array $process): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
return;
}
Process::run('kill '.escapeshellarg($process['pid']));
Log::info("Killed orphaned {$kind} process", [
'pid' => $process['pid'],
'etimes' => $process['etimes'],
'command' => $process['args'],
]);
}
/**
* Snapshot of running processes.
*
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
*/
private function listProcesses(): array
{
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
if ($ps->exitCode() !== 0) {
return [];
}
$processes = [];
foreach (explode("\n", trim($ps->output())) as $line) {
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
continue;
}
$processes[] = [
'pid' => $matches[1],
'ppid' => $matches[2],
'etimes' => (int) $matches[3],
'args' => $matches[4],
];
}
return $processes;
}
private function cleanupStaleConnections()
{
$muxFiles = Storage::disk('ssh-mux')->files();
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
$this->removeMultiplexFile($muxFile, 'server_not_found');
continue;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null";
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile, 'expired');
}
}
}
}
private function cleanupNonExistentServerConnections()
{
$muxFiles = Storage::disk('ssh-mux')->files();
$existingServerUuids = Server::pluck('uuid')->toArray();
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) {
$this->removeMultiplexFile($muxFile, 'server_does_not_exist');
}
}
}
private function extractServerUuidFromMuxFile($muxFile)
{
return substr($muxFile, 4);
}
/**
* Close and delete a stale mux socket file. When orphan reaping is disabled
* (the default), the file is only logged a dry-run mode that lets operators
* verify what would be removed before enabling it for real.
*/
private function removeMultiplexFile(string $muxFile, string $reason): void
{
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
Log::info('Stale mux file detected (dry-run, not removed)', [
'file' => $muxFile,
'reason' => $reason,
]);
return;
}
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile);
Log::info('Removed stale mux file', [
'file' => $muxFile,
'reason' => $reason,
]);
}
}

View file

@ -668,12 +668,14 @@ private function calculate_size()
private function upload_to_s3(): void private function upload_to_s3(): void
{ {
if (is_null($this->s3)) { if (is_null($this->s3)) {
$previousS3StorageId = $this->backup->s3_storage_id;
$this->backup->update([ $this->backup->update([
'save_s3' => false, 'save_s3' => false,
's3_storage_id' => null, 's3_storage_id' => null,
]); ]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.'); throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
} }
try { try {

View file

@ -39,6 +39,7 @@ public function __construct(
public string $commitSha, public string $commitSha,
public ?string $authorAssociation, public ?string $authorAssociation,
public string $fullName, public string $fullName,
public bool $isForkPullRequest = false,
) { ) {
$this->onQueue('high'); $this->onQueue('high');
} }
@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
// Check if PR deployments from public contributors are restricted // Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) { if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; // Fork PRs carry untrusted code from a repository outside our control.
// GitHub's author_association cannot be trusted to gate these (it grants
// CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
// PRs are never deployed automatically when public previews are off.
if ($this->isForkPullRequest) {
return;
}
// Same-repo (non-fork) branch PRs require push access to the base repo,
// so only trusted associations are allowed to trigger a deployment.
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) { if (! in_array($this->authorAssociation, $trustedAssociations)) {
return; return;
} }

View file

@ -2,6 +2,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -44,7 +45,7 @@ public function handle(): void
{ {
$validator = Validator::make( $validator = Validator::make(
['webhook_url' => $this->webhookUrl], ['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]] ['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
); );
if ($validator->fails()) { if ($validator->fails()) {

View file

@ -0,0 +1,125 @@
<?php
namespace App\Livewire\Destination;
use App\Models\Application;
use App\Models\BaseModel;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Resources extends Component
{
#[Locked]
public $destination;
public array $resources = [];
public function mount(string $destination_uuid)
{
try {
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
return redirect()->route('destination.index');
}
if (! $destination instanceof StandaloneDocker) {
return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
}
$this->destination = $destination;
$this->loadResources();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Load applications, services, and database resources deployed to the standalone Docker destination.
*
* @return void Populates the resources property for display.
*/
public function loadResources(): void
{
$this->resources = $this->collectResources([
$this->destination->applications,
$this->destination->services,
$this->destination->postgresqls,
$this->destination->redis,
$this->destination->mongodbs,
$this->destination->mysqls,
$this->destination->mariadbs,
$this->destination->keydbs,
$this->destination->dragonflies,
$this->destination->clickhouses,
]);
}
/**
* @param array<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
*/
protected function collectResources(array $groups): array
{
$rows = [];
foreach ($groups as $group) {
foreach ($group as $resource) {
$rows[] = $this->resourceRow($resource);
}
}
return $rows;
}
/**
* @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
* @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
*/
protected function resourceRow(BaseModel $resource): array
{
$type = match (true) {
$resource instanceof Application => 'application',
$resource instanceof Service => 'service',
default => 'database',
};
$environment = $resource->environment;
$project = $environment?->project;
$routeName = "project.{$type}.configuration";
$url = ($project && $environment)
? route($routeName, [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
"{$type}_uuid" => $resource->uuid,
])
: null;
return [
'uuid' => $resource->uuid,
'type' => $type,
'name' => $resource->name,
'project' => $project?->name,
'environment' => $environment?->name,
'url' => $url,
'search' => strtolower(implode(' ', array_filter([
$type,
$resource->name,
$project?->name,
$environment?->name,
]))),
];
}
public function render(): View
{
return view('livewire.destination.resources');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Profile;
use Livewire\Component;
class Appearance extends Component
{
public function render()
{
return view('livewire.profile.appearance');
}
}

View file

@ -87,6 +87,9 @@ class Advanced extends Component
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $isConnectToDockerNetworkEnabled = false; public bool $isConnectToDockerNetworkEnabled = false;
#[Validate(['integer', 'min:0'])]
public int $maxRestartCount = 10;
public function mount() public function mount()
{ {
try { try {
@ -149,6 +152,7 @@ public function syncData(bool $toModel = false)
$this->disableBuildCache = $this->application->settings->disable_build_cache; $this->disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
$this->maxRestartCount = $this->application->max_restart_count ?? 10;
} }
// Load stop_grace_period separately since it has its own save handler // Load stop_grace_period separately since it has its own save handler
@ -289,6 +293,21 @@ public function saveStopGracePeriod()
} }
} }
public function saveMaxRestartCount()
{
try {
$this->authorize('update', $this->application);
$this->validate([
'maxRestartCount' => 'integer|min:0',
]);
$this->application->max_restart_count = $this->maxRestartCount;
$this->application->save();
$this->dispatch('success', 'Max restart count saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render() public function render()
{ {
return view('livewire.project.application.advanced'); return view('livewire.project.application.advanced');

View file

@ -17,17 +17,10 @@ class Configuration extends Component
public $servers; public $servers;
public function getListeners() protected $listeners = [
{ 'buildPackUpdated' => '$refresh',
$teamId = auth()->user()->currentTeam()->id; 'refresh' => '$refresh',
];
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
'buildPackUpdated' => '$refresh',
'refresh' => '$refresh',
];
}
public function mount() public function mount()
{ {
@ -35,7 +28,7 @@ public function mount()
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -51,8 +44,6 @@ public function mount()
$this->environment = $environment; $this->environment = $environment;
$this->application = $application; $this->application = $application;
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
} }

View file

@ -5,6 +5,7 @@
use App\Actions\Application\GenerateConfig; use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob; use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application; use App\Models\Application;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -144,7 +145,7 @@ protected function rules(): array
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable', 'fqdn' => 'nullable',
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => ValidationPatterns::shellSafeCommandRules(), 'installCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
@ -153,12 +154,12 @@ protected function rules(): array
'staticImage' => 'required', 'staticImage' => 'required',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(), 'publishDirectory' => ValidationPatterns::directoryPathRules(),
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], 'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
'portsMappings' => ValidationPatterns::portMappingRules(), 'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable', 'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable', 'dockerfile' => 'nullable',
'dockerRegistryImageName' => 'nullable', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
'dockerRegistryImageTag' => 'nullable', 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable', 'dockerCompose' => 'nullable',
@ -211,7 +212,6 @@ protected function messages(): array
'buildPack.required' => 'The Build Pack field is required.', 'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.', 'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.', 'baseDirectory.required' => 'The Base Directory field is required.',
'portsExposes.required' => 'The Exposed Ports field is required.',
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
...ValidationPatterns::portMappingMessages(), ...ValidationPatterns::portMappingMessages(),
'isStatic.required' => 'The Static setting is required.', 'isStatic.required' => 'The Static setting is required.',
@ -759,7 +759,7 @@ public function submit($showToaster = true)
$this->resetErrorBag(); $this->resetErrorBag();
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
if ($this->portsMappings) { if ($this->portsMappings) {
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
} }
@ -848,7 +848,7 @@ public function submit($showToaster = true)
} }
if ($this->buildPack === 'dockerimage') { if ($this->buildPack === 'dockerimage') {
$this->validate([ $this->validate([
'dockerRegistryImageName' => 'required', 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
]); ]);
} }

View file

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ServerStatusBadge extends Component
{
public Application $application;
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->application->refresh();
}
public function render(): View
{
return view('livewire.project.application.server-status-badge');
}
}

View file

@ -6,6 +6,7 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\GitlabApp; use App\Models\GitlabApp;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Rules\ValidGitBranch;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
@ -29,7 +30,7 @@ class Source extends Component
#[Validate(['required', 'string'])] #[Validate(['required', 'string'])]
public string $gitRepository; public string $gitRepository;
#[Validate(['required', 'string'])] #[Validate(['required', 'string', new ValidGitBranch])]
public string $gitBranch; public string $gitBranch;
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -144,7 +145,7 @@ public function delete($password, $selectedActions = [])
try { try {
$server = null; $server = null;
if ($this->backup->database instanceof \App\Models\ServiceDatabase) { if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server; $server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) { } elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server; $server = $this->backup->database->destination->server;
@ -170,7 +171,7 @@ public function delete($password, $selectedActions = [])
$this->backup->delete(); $this->backup->delete();
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database; $serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [ return redirect()->route('project.service.database.backups', [
@ -182,7 +183,7 @@ public function delete($password, $selectedActions = [])
} else { } else {
return redirect()->route('project.database.backup.index', $this->parameters); return redirect()->route('project.database.backup.index', $this->parameters);
} }
} catch (\Exception $e) { } catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this); return handleError($e, $this);
@ -207,6 +208,13 @@ private function customValidate()
$this->backup->s3_storage_id = null; $this->backup->s3_storage_id = null;
} }
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$this->backup->s3_storage_id = $this->s3StorageId = null;
}
// Validate that disable_local_backup can only be true when S3 backup is enabled // Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false; $this->backup->disable_local_backup = $this->disableLocalBackup = false;
@ -214,7 +222,7 @@ private function customValidate()
$isValid = validate_cron_expression($this->backup->frequency); $isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) { if (! $isValid) {
throw new \Exception('Invalid Cron / Human expression'); throw new Exception('Invalid Cron / Human expression');
} }
$this->validate(); $this->validate();
} }

View file

@ -40,18 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public function getListeners() public function getListeners(): array
{ {
$teamId = Auth::user()->currentTeam()->id; $user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
]; ];
} }
@ -88,8 +91,6 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
]; ];
} }
@ -129,9 +130,6 @@ public function syncData(bool $toModel = false)
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -144,8 +142,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -194,6 +190,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -202,9 +199,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -220,6 +221,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {

View file

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Database\Clickhouse;
use App\Models\StandaloneClickhouse;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneClickhouse $database;
protected function databaseLabel(): string
{
return 'Clickhouse';
}
protected function supportsSsl(): bool
{
return false;
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -2,8 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use Auth; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\ItemNotFoundException;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@ -18,15 +19,6 @@ class Configuration extends Component
public $environment; public $environment;
public function getListeners()
{
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
];
}
public function mount() public function mount()
{ {
try { try {
@ -34,7 +26,7 @@ public function mount()
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -55,10 +47,10 @@ public function mount()
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
if ($e instanceof \Illuminate\Support\ItemNotFoundException) { if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -48,6 +50,20 @@ public function submit()
$this->validate(); $this->validate();
if ($this->saveToS3) {
$s3StorageExists = ! is_null($this->s3StorageId)
&& S3Storage::where('team_id', currentTeam()->id)
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
if (! $s3StorageExists) {
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
return;
}
}
$isValid = validate_cron_expression($this->frequency); $isValid = validate_cron_expression($this->frequency);
if (! $isValid) { if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.'); $this->dispatch('error', 'Invalid Cron / Human expression.');
@ -74,7 +90,7 @@ public function submit()
} }
$databaseBackup = ScheduledDatabaseBackup::create($payload); $databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id); $this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else { } else {
$this->dispatch('refreshScheduledBackups'); $this->dispatch('refreshScheduledBackups');

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -40,25 +38,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -73,12 +67,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -98,10 +86,7 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'nullable|boolean',
]; ];
} }
@ -137,11 +122,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -153,9 +134,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -204,6 +182,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -212,9 +191,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -230,6 +213,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -241,67 +225,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Dragonfly;
use App\Models\StandaloneDragonfly;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneDragonfly $database;
protected function databaseLabel(): string
{
return 'Dragonfly';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Project\Database;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Health extends Component
{
use AuthorizesRequests;
public $database;
#[Validate(['boolean'])]
public bool $healthCheckEnabled = true;
#[Validate(['integer', 'min:1'])]
public int $healthCheckInterval = 15;
#[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout = 5;
#[Validate(['integer', 'min:1'])]
public int $healthCheckRetries = 5;
#[Validate(['integer', 'min:0'])]
public int $healthCheckStartPeriod = 5;
public function mount(): void
{
$this->authorize('view', $this->database);
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
$this->database->health_check_enabled = $this->healthCheckEnabled;
$this->database->health_check_interval = $this->healthCheckInterval;
$this->database->health_check_timeout = $this->healthCheckTimeout;
$this->database->health_check_retries = $this->healthCheckRetries;
$this->database->health_check_start_period = $this->healthCheckStartPeriod;
$this->database->save();
} else {
$this->healthCheckEnabled = $this->database->health_check_enabled;
$this->healthCheckInterval = $this->database->health_check_interval;
$this->healthCheckTimeout = $this->database->health_check_timeout;
$this->healthCheckRetries = $this->database->health_check_retries;
$this->healthCheckStartPeriod = $this->database->health_check_start_period;
}
}
public function instantSave(): void
{
$this->submit();
}
public function submit(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
public function toggleHealthcheck(): void
{
$updateSuccessful = false;
try {
$this->authorize('update', $this->database);
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
if (! $updateSuccessful) {
return;
}
$this->markConfigurationChanged();
}
private function markConfigurationChanged(): void
{
if (is_null($this->database->config_hash)) {
$this->database->isConfigurationChanged(true);
return;
}
$this->dispatch('configurationChanged');
}
public function render(): View
{
return view('livewire.project.database.health');
}
}

View file

@ -2,23 +2,14 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns; use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
@ -26,803 +17,134 @@ class Import extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked] #[Locked]
public ?int $resourceId = null; public ?int $resourceId = null;
#[Locked] #[Locked]
public ?string $resourceType = null; public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = ''; public string $resourceStatus = '';
#[Locked] public string $resourceUuid = '';
public string $resourceDbType = '';
public array $parameters = []; public bool $unsupported = false;
public array $containers = []; public function getListeners(): array
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{ {
if ($this->resourceId === null || $this->resourceType === null) { $listeners = ['databaseUpdated' => 'refreshStatus'];
return null;
$user = Auth::user();
if (! $user) {
return $listeners;
} }
return $this->resourceType::find($this->resourceId); $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
}
#[Computed] $team = $user->currentTeam();
public function server() if ($team) {
{ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
if ($this->serverId === null) {
return null;
} }
return Server::ownedByCurrentTeam()->find($this->serverId); return $listeners;
} }
public function getListeners() public function mount(): void
{ {
$userId = Auth::id(); $resource = $this->resolveResourceFromRoute();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'slideOverClosed' => 'resetActivityId',
];
}
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource); $this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id; $this->resourceId = $resource->id;
$this->resourceType = get_class($resource); $this->resourceType = get_class($resource);
// Store view-friendly properties $this->refreshStatus();
}
public function refreshStatus(): void
{
$resource = $this->resolveStoredResource();
$this->authorize('view', $resource);
$resource->refresh();
$this->resourceUuid = $resource->uuid;
$this->resourceStatus = $resource->status ?? ''; $this->resourceStatus = $resource->status ?? '';
$this->unsupported = $this->isUnsupportedResource($resource);
}
// Handle ServiceDatabase server access differently public function render(): View
if ($resource->getMorphClass() === ServiceDatabase::class) { {
$server = $resource->service?->server; return view('livewire.project.database.import');
if (! $server) { }
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase private function resolveResourceFromRoute(): object
$dbType = $resource->databaseType(); {
if (str_contains($dbType, 'postgres')) { $parameters = get_route_parameters();
$this->resourceDbType = 'standalone-postgresql'; $teamId = data_get(Auth::user()?->currentTeam(), 'id');
} elseif (str_contains($dbType, 'mysql')) { $databaseUuid = data_get($parameters, 'database_uuid');
$this->resourceDbType = 'standalone-mysql'; $stackServiceUuid = data_get($parameters, 'stack_service_uuid');
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb'; if ($databaseUuid) {
} elseif (str_contains($dbType, 'mongo')) { $resource = getResourceByUuid($databaseUuid, $teamId);
$this->resourceDbType = 'standalone-mongodb'; if ($resource) {
} else { return $resource;
$this->resourceDbType = $dbType;
} }
} else {
$server = $resource->destination?->server; abort(404);
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
} }
if (str($resource->status)->startsWith('running')) { if ($stackServiceUuid) {
$this->containers[] = $this->container; $project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if ($resource) {
return $resource;
}
} }
abort(404);
}
private function resolveStoredResource(): object
{
if ($this->resourceId === null || $this->resourceType === null) {
return $this->resolveResourceFromRoute();
}
$resource = $this->resourceType::find($this->resourceId);
if ($resource) {
return $resource;
}
abort(404);
}
private function isUnsupportedResource(object $resource): bool
{
if ( if (
$resource->getMorphClass() === StandaloneRedis::class || $resource instanceof StandaloneRedis ||
$resource->getMorphClass() === StandaloneKeydb::class || $resource instanceof StandaloneKeydb ||
$resource->getMorphClass() === StandaloneDragonfly::class || $resource instanceof StandaloneDragonfly ||
$resource->getMorphClass() === StandaloneClickhouse::class $resource instanceof StandaloneClickhouse
) { ) {
$this->unsupported = true; return true;
} }
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) if ($resource instanceof ServiceDatabase) {
if ($resource->getMorphClass() === ServiceDatabase::class) {
$dbType = $resource->databaseType(); $dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile() return str_contains($dbType, 'redis') ||
{ str_contains($dbType, 'keydb') ||
if (filled($this->customLocation)) { str_contains($dbType, 'dragonfly') ||
// Validate the custom location to prevent command injection str_contains($dbType, 'clickhouse');
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
} }
$this->authorize('update', $this->resource); return false;
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
} }
} }

View file

@ -0,0 +1,825 @@
<?php
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ImportForm extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
// Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
#[Locked]
public ?int $serverId = null;
// View-friendly properties to avoid computed property access in Blade
#[Locked]
public string $resourceUuid = '';
public string $resourceStatus = '';
#[Locked]
public string $resourceDbType = '';
public array $parameters = [];
public array $containers = [];
public bool $scpInProgress = false;
public bool $importRunning = false;
public ?string $filename = null;
public ?string $filesize = null;
public bool $isUploading = false;
public int $progress = 0;
public bool $error = false;
#[Locked]
public string $container;
public array $importCommands = [];
public bool $dumpAll = false;
public string $restoreCommandText = '';
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public array $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
#[Computed]
public function resource()
{
if ($this->resourceId === null || $this->resourceType === null) {
return null;
}
return $this->resourceType::find($this->resourceId);
}
#[Computed]
public function server()
{
if ($this->serverId === null) {
return null;
}
return Server::ownedByCurrentTeam()->find($this->serverId);
}
protected $listeners = [
'slideOverClosed' => 'resetActivityId',
];
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
{
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
if ($value === true) {
$this->mariadbRestoreCommand = <<<'EOD'
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
} else {
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
}
break;
case StandaloneMysql::class:
case 'mysql':
if ($value === true) {
$this->mysqlRestoreCommand = <<<'EOD'
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
done && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
EOD;
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
} else {
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
}
break;
case StandalonePostgresql::class:
case 'postgresql':
if ($value === true) {
$this->postgresqlRestoreCommand = <<<'EOD'
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
EOD;
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
} else {
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
}
break;
}
}
public function getContainers()
{
$this->containers = [];
$teamId = data_get(auth()->user()->currentTeam(), 'id');
// Try to find resource by route parameter
$databaseUuid = data_get($this->parameters, 'database_uuid');
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
$resource = null;
if ($databaseUuid) {
// Standalone database route
$resource = getResourceByUuid($databaseUuid, $teamId);
if (is_null($resource)) {
abort(404);
}
} elseif ($stackServiceUuid) {
// ServiceDatabase route - look up the service database
$serviceUuid = data_get($this->parameters, 'service_uuid');
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
->where('uuid', data_get($this->parameters, 'project_uuid'))
->firstOrFail();
$environment = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->where('uuid', data_get($this->parameters, 'environment_uuid'))
->firstOrFail();
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
if (is_null($resource)) {
abort(404);
}
} else {
abort(404);
}
$this->authorize('view', $resource);
// Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
// Store view-friendly properties
$this->resourceStatus = $resource->status ?? '';
// Handle ServiceDatabase server access differently
if ($resource->getMorphClass() === ServiceDatabase::class) {
$server = $resource->service?->server;
if (! $server) {
abort(404, 'Server not found for this service database.');
}
$this->serverId = $server->id;
$this->container = $resource->name.'-'.$resource->service->uuid;
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
// Determine database type for ServiceDatabase
$dbType = $resource->databaseType();
if (str_contains($dbType, 'postgres')) {
$this->resourceDbType = 'standalone-postgresql';
} elseif (str_contains($dbType, 'mysql')) {
$this->resourceDbType = 'standalone-mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$this->resourceDbType = 'standalone-mariadb';
} elseif (str_contains($dbType, 'mongo')) {
$this->resourceDbType = 'standalone-mongodb';
} else {
$this->resourceDbType = $dbType;
}
} else {
$server = $resource->destination?->server;
if (! $server) {
abort(404, 'Server not found for this database.');
}
$this->serverId = $server->id;
$this->container = $resource->uuid;
$this->resourceUuid = $resource->uuid;
$this->resourceDbType = $resource->type();
}
if (str($resource->status)->startsWith('running')) {
$this->containers[] = $this->container;
}
if (
$resource->getMorphClass() === StandaloneRedis::class ||
$resource->getMorphClass() === StandaloneKeydb::class ||
$resource->getMorphClass() === StandaloneDragonfly::class ||
$resource->getMorphClass() === StandaloneClickhouse::class
) {
$this->unsupported = true;
}
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
if ($resource->getMorphClass() === ServiceDatabase::class) {
$dbType = $resource->databaseType();
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
$this->unsupported = true;
}
}
}
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return;
}
try {
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
$this->filename = $this->customLocation;
$this->dispatch('success', 'The file exists.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
}
public function runImport(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if ($this->filename === '') {
$this->dispatch('error', 'Please select a file to import.');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$this->importCommands = [];
$backupFileName = "upload/{$this->resourceUuid}/restore";
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return true;
}
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return true;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
if (! empty($this->importCommands)) {
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
'scriptPath' => $scriptPath,
'tmpPath' => $tmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
handleError($e, $this);
return true;
} finally {
$this->filename = null;
$this->importCommands = [];
}
return true;
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
->toArray();
} catch (\Throwable $e) {
$this->availableS3Storages = [];
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3(string $password = ''): bool|string
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
$this->authorize('update', $this->resource);
if (! ValidationPatterns::isValidContainerName($this->container)) {
$this->dispatch('error', 'Invalid container name.');
return true;
}
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return true;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return true;
}
if (! $this->server) {
$this->dispatch('error', 'Server not found. Please refresh the page.');
return true;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return true;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return true;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
} else {
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
}
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resourceUuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
$escapedScriptPath = escapeshellarg($scriptPath);
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
$commands[] = "chmod +x {$escapedScriptPath}";
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
handleError($e, $this);
return true;
}
return true;
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
$morphClass = $this->resource->getMorphClass();
// Handle ServiceDatabase by checking the database type
if ($morphClass === ServiceDatabase::class) {
$dbType = $this->resource->databaseType();
if (str_contains($dbType, 'mysql')) {
$morphClass = 'mysql';
} elseif (str_contains($dbType, 'mariadb')) {
$morphClass = 'mariadb';
} elseif (str_contains($dbType, 'postgres')) {
$morphClass = 'postgresql';
} elseif (str_contains($dbType, 'mongo')) {
$morphClass = 'mongodb';
}
}
switch ($morphClass) {
case StandaloneMariadb::class:
case 'mariadb':
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandaloneMysql::class:
case 'mysql':
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
} else {
$restoreCommand .= " < {$escapedTmpPath}";
}
break;
case StandalonePostgresql::class:
case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
} else {
$restoreCommand .= " {$escapedTmpPath}";
}
break;
case StandaloneMongodb::class:
case 'mongodb':
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
}
}

View file

@ -4,11 +4,9 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneKeydb; use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -42,25 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false; public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null; public function getListeners(): array
public bool $enable_ssl = false;
public function getListeners()
{ {
$userId = Auth::id(); $user = Auth::user();
$teamId = Auth::user()->currentTeam()->id; if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [ return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
]; ];
} }
@ -75,12 +69,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -88,7 +76,7 @@ public function mount()
protected function rules(): array protected function rules(): array
{ {
$baseRules = [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string', 'keydbConf' => 'nullable|string',
@ -101,13 +89,8 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string', 'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'boolean',
]; ];
return $baseRules;
} }
protected function messages(): array protected function messages(): array
@ -143,11 +126,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -160,9 +139,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} }
} }
@ -211,6 +187,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -219,9 +196,13 @@ public function instantSave()
} }
} }
public function databaseProxyStopped() public function databaseProxyStopped(): void
{ {
$this->syncData(); $this->database->refresh();
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->dispatch('databaseUpdated');
} }
public function submit() public function submit()
@ -237,6 +218,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -248,65 +230,6 @@ public function submit()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Keydb;
use App\Models\StandaloneKeydb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneKeydb $database;
protected function databaseLabel(): string
{
return 'KeyDB';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMariadb; use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -50,25 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -94,7 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
]; ];
} }
@ -133,7 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
]; ];
public function mount() public function mount()
@ -147,12 +123,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -176,11 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -196,9 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -234,6 +197,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -270,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -278,63 +243,6 @@ public function instantSave()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Project\Database\Mariadb;
use App\Models\StandaloneMariadb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMariadb $database;
protected function databaseLabel(): string
{
return 'MariaDB';
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMongodb; use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -48,27 +45,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -91,8 +67,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
]; ];
} }
@ -112,7 +86,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
] ]
); );
} }
@ -130,8 +103,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -145,12 +116,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -173,12 +138,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -193,10 +153,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -235,6 +191,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -271,6 +228,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -279,68 +237,6 @@ public function instantSave()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mongodb;
use App\Models\StandaloneMongodb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMongodb $database;
protected function databaseLabel(): string
{
return 'Mongo';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MongoDB connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -50,27 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -96,8 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
]; ];
} }
@ -118,7 +92,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
] ]
); );
} }
@ -137,8 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -152,12 +123,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -181,12 +146,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -202,10 +162,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -241,6 +197,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -277,6 +234,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -285,68 +243,6 @@ public function instantSave()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mysql;
use App\Models\StandaloneMysql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMysql $database;
protected function databaseLabel(): string
{
return 'MySQL';
}
protected function sslModeOptions(): array
{
return [
'PREFERRED' => ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MySQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -54,32 +51,14 @@ class General extends Component
public ?string $customDockerRunOptions = null; public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename; public string $new_filename;
public string $new_content; public string $new_content;
public ?string $db_url = null; protected $listeners = [
'save_init_script',
public ?string $db_url_public = null; 'delete_init_script',
];
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'save_init_script',
'delete_init_script',
];
}
protected function rules(): array protected function rules(): array
{ {
@ -106,8 +85,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1', 'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean', 'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable', 'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
]; ];
} }
@ -127,7 +104,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.', 'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.', 'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
] ]
); );
} }
@ -148,8 +124,6 @@ protected function messages(): array
'publicPort' => 'Public Port', 'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout', 'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options', 'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
]; ];
public function mount() public function mount()
@ -163,12 +137,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -194,12 +162,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save(); $this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -217,10 +180,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} }
} }
@ -243,68 +202,6 @@ public function instantSaveAdvanced()
} }
} }
public function updatedSslMode()
{
$this->instantSaveSSL();
}
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function instantSave() public function instantSave()
{ {
try { try {
@ -330,6 +227,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -493,6 +391,7 @@ public function submit()
} }
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Project\Database\Postgresql;
use App\Models\StandalonePostgresql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandalonePostgresql $database;
protected function databaseLabel(): string
{
return 'Postgres';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for PostgreSQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}

View file

@ -4,14 +4,11 @@
use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy; use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class General extends Component class General extends Component
@ -48,25 +45,9 @@ class General extends Component
public string $redisVersion; public string $redisVersion;
public ?string $dbUrl = null; protected $listeners = [
'envsUpdated' => 'refresh',
public ?string $dbUrlPublic = null; ];
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
'envsUpdated' => 'refresh',
];
}
protected function rules(): array protected function rules(): array
{ {
@ -87,7 +68,6 @@ protected function rules(): array
'redisPassword' => ValidationPatterns::databasePasswordRules( 'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password, enforcePattern: $this->redisPassword !== $this->database->redis_password,
), ),
'enableSsl' => 'boolean',
]; ];
} }
@ -122,7 +102,6 @@ protected function messages(): array
'customDockerRunOptions' => 'Custom Docker Options', 'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username', 'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password', 'redisPassword' => 'Redis Password',
'enableSsl' => 'Enable SSL',
]; ];
public function mount() public function mount()
@ -136,12 +115,6 @@ public function mount()
return; return;
} }
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -161,11 +134,7 @@ public function syncData(bool $toModel = false)
$this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save(); $this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else { } else {
$this->name = $this->database->name; $this->name = $this->database->name;
$this->description = $this->database->description; $this->description = $this->database->description;
@ -177,9 +146,6 @@ public function syncData(bool $toModel = false)
$this->publicPortTimeout = $this->database->public_port_timeout; $this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options; $this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion(); $this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username; $this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password; $this->redisPassword = $this->database->redis_password;
@ -227,6 +193,7 @@ public function submit()
); );
$this->dispatch('success', 'Database updated.'); $this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) { } catch (Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@ -259,6 +226,7 @@ public function instantSave()
StopDatabaseProxy::run($this->database); StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.'); $this->dispatch('success', 'Database is no longer publicly accessible.');
} }
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic; $this->isPublic = ! $this->isPublic;
$this->syncData(true); $this->syncData(true);
@ -267,63 +235,6 @@ public function instantSave()
} }
} }
public function instantSaveSSL()
{
try {
$this->authorize('update', $this->database);
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function regenerateSslCertificate()
{
try {
$this->authorize('update', $this->database);
$existingCert = $this->database->sslCertificates()->first();
if (! $existingCert) {
$this->dispatch('error', 'No existing SSL certificate found for this database.');
return;
}
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$this->server->generateCaCertificate();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
resourceType: $existingCert->resource_type,
resourceId: $existingCert->resource_id,
serverId: $existingCert->server_id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $existingCert->configuration_dir,
mountPath: $existingCert->mount_path,
isPemKeyFileRequired: true,
);
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void public function refresh(): void
{ {
$this->database->refresh(); $this->database->refresh();

View file

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Project\Database\Redis;
use App\Models\StandaloneRedis;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneRedis $database;
protected function databaseLabel(): string
{
return 'Redis';
}
}

View file

@ -5,6 +5,7 @@
use App\Models\Application; use App\Models\Application;
use App\Models\Project; use App\Models\Project;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@ -81,8 +82,8 @@ public function updatedImageName(): void
public function submit() public function submit()
{ {
$this->validate([ $this->validate([
'imageName' => ['required', 'string'], 'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], 'imageTag' => ValidationPatterns::dockerImageTagRules(),
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]); ]);

View file

@ -4,7 +4,6 @@
use App\Models\Service; use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
@ -27,16 +26,10 @@ class Configuration extends Component
public array $parameters; public array $parameters;
public function getListeners() protected $listeners = [
{ 'refreshServices' => 'refreshServices',
$teamId = Auth::user()->currentTeam()->id; 'refresh' => 'refreshServices',
];
return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
}
public function render() public function render()
{ {
@ -51,7 +44,7 @@ public function mount()
$this->query = request()->query(); $this->query = request()->query();
$project = currentTeam() $project = currentTeam()
->projects() ->projects()
->select('id', 'uuid', 'team_id') ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid')) ->where('uuid', request()->route('project_uuid'))
->firstOrFail(); ->firstOrFail();
$environment = $project->environments() $environment = $project->environments()
@ -105,18 +98,4 @@ public function restartDatabase($id)
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function serviceChecked()
{
try {
$this->service->applications->each(function ($application) {
$application->refresh();
});
$this->service->databases->each(function ($database) {
$database->refresh();
});
} catch (\Exception $e) {
return handleError($e, $this);
}
}
} }

View file

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ResourceCard extends Component
{
use AuthorizesRequests;
public Service $service;
public ServiceApplication|ServiceDatabase $resource;
public array $parameters = [];
public function getListeners(): array
{
$user = Auth::user();
if (! $user) {
return [];
}
$team = $user->currentTeam();
if (! $team) {
return [];
}
return [
"echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
];
}
public function refreshResource(): void
{
$this->resource->refresh();
}
public function restart(): void
{
try {
$this->authorize('update', $this->service);
$this->resource->restart();
$message = $this->resource instanceof ServiceApplication
? 'Service application restarted successfully.'
: 'Service database restarted successfully.';
$this->dispatch('success', $message);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.project.service.resource-card', [
'isApplication' => $this->resource instanceof ServiceApplication,
'isDatabase' => $this->resource instanceof ServiceDatabase,
]);
}
}

View file

@ -21,8 +21,6 @@ class ConfigurationChecker extends Component
public array $configurationDiff = []; public array $configurationDiff = [];
public array $groupedConfigurationChanges = [];
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
public function getListeners(): array public function getListeners(): array
@ -50,21 +48,56 @@ public function refreshConfigurationChanges(): void
$this->configurationChanged(); $this->configurationChanged();
} }
/**
* Members must never see environment variable values, so redact every
* environment-section change before it is serialized to the browser.
*
* @param array<int, array<string, mixed>> $changes
* @return array<int, array<string, mixed>>
*/
private function redactEnvironmentChanges(array $changes, bool $redact): array
{
if (! $redact) {
return $changes;
}
return collect($changes)
->map(function (array $change): array {
if (data_get($change, 'section') !== 'environment') {
return $change;
}
$change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
$change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
$change['old_full_value'] = null;
$change['new_full_value'] = null;
$change['expandable'] = false;
$change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
return $change;
})
->all();
}
public function configurationChanged(): void public function configurationChanged(): void
{ {
$this->resource->refresh(); $this->resource->refresh();
if ($this->resource instanceof Application) { if ($this->resource instanceof Application) {
$diff = $this->resource->pendingDeploymentConfigurationDiff(); $diff = $this->resource->pendingDeploymentConfigurationDiff();
// Fail closed: only owners/admins may see unlocked env values.
$redactEnvironment = ! (bool) auth()->user()?->isAdmin();
$array = $diff->toArray();
$array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
$this->isConfigurationChanged = $diff->isChanged(); $this->isConfigurationChanged = $diff->isChanged();
$this->configurationDiff = $diff->toArray(); $this->configurationDiff = $array;
$this->groupedConfigurationChanges = $diff->groupedChanges();
return; return;
} }
$this->isConfigurationChanged = $this->resource->isConfigurationChanged(); $this->isConfigurationChanged = $this->resource->isConfigurationChanged();
$this->configurationDiff = []; $this->configurationDiff = [];
$this->groupedConfigurationChanges = [];
} }
} }

View file

@ -0,0 +1,91 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class ResourceDetails extends Component
{
use AuthorizesRequests;
public $resource;
public ?string $project_uuid = null;
public ?string $project_name = null;
public ?string $environment_uuid = null;
public ?string $environment_name = null;
public ?string $server_uuid = null;
public ?string $server_name = null;
public array $stack_applications = [];
public array $stack_databases = [];
public function mount()
{
$this->authorize('view', $this->resource);
$environment = $this->resource->environment ?? null;
if ($environment) {
$this->environment_uuid = $environment->uuid;
$this->environment_name = $environment->name;
$project = $environment->project ?? null;
if ($project) {
$this->project_uuid = $project->uuid;
$this->project_name = $project->name;
}
}
$server = $this->resolveServer();
if ($server) {
$this->server_uuid = $server->uuid;
$this->server_name = $server->name;
}
if ($this->resource instanceof Service) {
$this->stack_applications = $this->resource->applications
->map(fn ($app) => [
'name' => $app->human_name ?: $app->name,
'uuid' => $app->uuid,
])
->values()
->all();
$this->stack_databases = $this->resource->databases
->map(fn ($db) => [
'name' => $db->human_name ?: $db->name,
'uuid' => $db->uuid,
])
->values()
->all();
}
}
private function resolveServer()
{
try {
if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
return $this->resource->destination->server;
}
if (method_exists($this->resource, 'server') && $this->resource->server) {
return $this->resource->server;
}
} catch (\Throwable $e) {
return null;
}
return null;
}
public function render()
{
return view('livewire.project.shared.resource-details');
}
}

View file

@ -12,6 +12,8 @@ class Terminal extends Component
{ {
public bool $hasShell = true; public bool $hasShell = true;
public bool $isTerminalConnected = false;
private function checkShellAvailability(Server $server, string $container): bool private function checkShellAvailability(Server $server, string $container): bool
{ {
$escapedContainer = escapeshellarg($container); $escapedContainer = escapeshellarg($container);
@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$dockerCommand = "sudo {$dockerCommand}"; $dockerCommand = "sudo {$dockerCommand}";
} }
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$dockerCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} else { } else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand); $command = SshMultiplexingHelper::generateSshCommand(
$server,
$shellCommand,
commandTimeout: (int) config('constants.terminal.command_timeout')
);
} }
// ssh command is sent back to frontend then to websocket // ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here // this is done because the websocket connection is not available here
@ -84,6 +94,23 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$this->dispatch('send-back-command', $command); $this->dispatch('send-back-command', $command);
} }
#[On('terminalConnected')]
public function markTerminalConnected(): void
{
$this->isTerminalConnected = true;
}
#[On('terminalDisconnected')]
public function markTerminalDisconnected(): void
{
$this->isTerminalConnected = false;
}
public function keepTerminalPageAlive(): void
{
$this->isTerminalConnected = true;
}
public function render() public function render()
{ {
return view('livewire.project.shared.terminal'); return view('livewire.project.shared.terminal');

View file

@ -2,11 +2,15 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Charts extends Component class Charts extends Component
{ {
use AuthorizesRequests;
public Server $server; public Server $server;
public $chartId = 'server'; public $chartId = 'server';
@ -28,6 +32,29 @@ public function mount(string $server_uuid)
} }
} }
public function toggleMetrics(): void
{
try {
$this->authorize('update', $this->server);
$this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled;
$this->server->settings->save();
$this->server->refresh();
if ($this->server->isMetricsEnabled()) {
StartSentinel::run($this->server, true);
$this->dispatch('success', 'Metrics enabled. Starting Sentinel.');
$this->dispatch('refreshServerShow');
$this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true);
} else {
$this->server->restartSentinel();
$this->dispatch('success', 'Metrics disabled. Restarting Sentinel.');
$this->dispatch('refreshServerShow');
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function pollData() public function pollData()
{ {
if ($this->poll || $this->interval <= 10) { if ($this->poll || $this->interval <= 10) {

View file

@ -28,12 +28,11 @@ public function delete(string $fileName)
// Decode filename: pipes are used to encode dots for Livewire property binding // Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml') // (e.g., 'my|service.yaml' -> 'my.service.yaml')
// This must happen BEFORE validation because validateShellSafePath() correctly // This must happen BEFORE validation because validateFilenameSafe()
// rejects pipe characters as dangerous shell metacharacters // rejects pipe characters through validateShellSafePath().
$file = str_replace('|', '.', $fileName); $file = str_replace('|', '.', $fileName);
// Validate filename to prevent command injection validateFilenameSafe($file, 'proxy configuration filename');
validateShellSafePath($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.'); $this->dispatch('error', 'Cannot delete Caddyfile.');

View file

@ -43,8 +43,7 @@ public function addDynamicConfiguration()
'value' => 'required', 'value' => 'required',
]); ]);
// Additional security validation to prevent command injection validateFilenameSafe($this->fileName, 'proxy configuration filename');
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) { if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();

View file

@ -15,8 +15,6 @@ class Sentinel extends Component
public Server $server; public Server $server;
public array $parameters = [];
public bool $isMetricsEnabled; public bool $isMetricsEnabled;
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])] #[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
@ -51,15 +49,9 @@ public function getListeners()
]; ];
} }
public function mount(string $server_uuid) public function mount()
{ {
try { $this->syncData();
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
} }
public function syncData(bool $toModel = false) public function syncData(bool $toModel = false)
@ -93,7 +85,9 @@ public function handleSentinelRestarted($event)
{ {
if ($event['serverUuid'] === $this->server->uuid) { if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh(); $this->server->refresh();
$this->syncData(); // Only refresh display-only state; never re-sync text-input properties
// (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.'); $this->dispatch('success', 'Sentinel has been restarted successfully.');
} }
} }
@ -110,27 +104,29 @@ public function restartSentinel()
} }
} }
public function updatedIsSentinelEnabled($value) public function toggleSentinel(): void
{ {
try { try {
$this->authorize('manageSentinel', $this->server); $this->authorize('manageSentinel', $this->server);
if ($value === true) { if (! $this->isSentinelEnabled) {
if ($this->server->isBuildServer()) { if ($this->server->isBuildServer()) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); $this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return; return;
} }
$this->isSentinelEnabled = true;
$customImage = isDev() ? $this->sentinelCustomDockerImage : null; $customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage); StartSentinel::run($this->server, true, null, $customImage);
} else { } else {
$this->isSentinelEnabled = false;
$this->isMetricsEnabled = false; $this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false; $this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server); StopSentinel::dispatch($this->server);
} }
$this->submit(); $this->submit();
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); handleError($e, $this);
} }
} }

View file

@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\View\View;
use Livewire\Component;
class Logs extends Component
{
public ?Server $server = null;
public array $parameters = [];
public function mount(): void
{
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.server.sentinel.logs');
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Server\Sentinel;
use App\Models\Server;
use Illuminate\View\View;
use Livewire\Component;
class Show extends Component
{
public ?Server $server = null;
public array $parameters = [];
public function mount(): void
{
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
{
return view('livewire.server.sentinel.show');
}
}

View file

@ -277,7 +277,9 @@ public function handleSentinelRestarted($event)
// Only refresh if the event is for this server // Only refresh if the event is for this server
if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) {
$this->server->refresh(); $this->server->refresh();
$this->syncData(); // Only refresh display-only state; never re-sync text-input properties
// (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.'); $this->dispatch('success', 'Sentinel has been restarted successfully.');
} }
} }
@ -457,12 +459,15 @@ public function handleServerValidated($event = null)
return; return;
} }
// Refresh server data // Refresh server data and only the display-only state that validation produces.
// Never re-sync text-input properties via syncData() — would clobber any
// unsaved typing (see coolify#6062 / #6354 / #9695).
$this->server->refresh(); $this->server->refresh();
$this->syncData(); $this->server->settings->refresh();
// Update validation state
$this->isValidating = $this->server->is_validating ?? false; $this->isValidating = $this->server->is_validating ?? false;
$this->validationLogs = $this->server->validation_logs;
$this->isReachable = $this->server->settings->is_reachable;
$this->isUsable = $this->server->settings->is_usable;
// Reload Hetzner tokens in case the linking section should now be shown // Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens(); $this->loadHetznerTokens();

View file

@ -11,6 +11,8 @@ class SettingsDropdown extends Component
{ {
public $showWhatsNewModal = false; public $showWhatsNewModal = false;
public string $trigger = 'preferences';
public function getUnreadCountProperty() public function getUnreadCountProperty()
{ {
return Auth::user()->getUnreadChangelogCount(); return Auth::user()->getUnreadChangelogCount();

View file

@ -210,6 +210,9 @@ public function checkPermissions()
GithubAppPermissionJob::dispatchSync($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false);
$this->name = str($this->github_app->name)->kebab();
$this->dispatch('success', 'Github App permissions updated.'); $this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Provide better error message for unsupported key formats // Provide better error message for unsupported key formats

View file

@ -61,7 +61,7 @@ private function generateInviteLink(bool $sendEmail = false)
if ($member_emails->contains($this->email)) { if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
} }
$uuid = new Cuid2(32); $uuid = (string) new Cuid2(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid; $link = url('/').config('constants.invitation.link.base_url').$uuid;
$user = User::whereEmail($this->email)->first(); $user = User::whereEmail($this->email)->first();
@ -73,7 +73,7 @@ private function generateInviteLink(bool $sendEmail = false)
'password' => Hash::make($password), 'password' => Hash::make($password),
'force_password_reset' => true, 'force_password_reset' => true,
]); ]);
$token = Crypt::encryptString("{$user->email}@@@$password"); $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]); $link = route('auth.link', ['token' => $token]);
} }
$invitation = TeamInvitation::whereEmail($this->email)->first(); $invitation = TeamInvitation::whereEmail($this->email)->first();

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Team; namespace App\Livewire\Team;
use App\Actions\User\RevokeUserTeamTokens;
use App\Enums\Role; use App\Enums\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -23,7 +24,9 @@ public function makeAdmin()
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) { || Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::ADMIN->value]); $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@ -39,7 +42,9 @@ public function makeOwner()
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) { || Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::OWNER->value]); $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@ -55,7 +60,9 @@ public function makeReadonly()
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) { || Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::MEMBER->value]); $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@ -73,6 +80,7 @@ public function remove()
} }
$teamId = currentTeam()->id; $teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam()); $this->member->teams()->detach(currentTeam());
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
// Clear cache for the removed user - both old and new key formats // Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}"); Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}"); Cache::forget("user:{$this->member->id}:team:{$teamId}");

View file

@ -28,8 +28,14 @@ protected function ensureAbility(Request $request, string $ability = 'read'): ?R
protected function resolveTeamId(Request $request): ?int protected function resolveTeamId(Request $request): ?int
{ {
$token = $request->user()?->currentAccessToken(); $user = $request->user();
$token = $user?->currentAccessToken();
$teamId = $token?->team_id;
return $token?->team_id; if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
return null;
}
return (int) $teamId;
} }
} }

View file

@ -204,6 +204,7 @@ class Application extends BaseModel
'config_hash', 'config_hash',
'last_online_at', 'last_online_at',
'restart_count', 'restart_count',
'max_restart_count',
'last_restart_at', 'last_restart_at',
'last_restart_type', 'last_restart_type',
'uuid', 'uuid',
@ -227,6 +228,7 @@ protected function casts(): array
'manual_webhook_secret_bitbucket' => 'encrypted', 'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted', 'manual_webhook_secret_gitea' => 'encrypted',
'restart_count' => 'integer', 'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
]; ];
} }
@ -570,6 +572,15 @@ public function link()
return null; return null;
} }
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';
}
public function taskLink($task_uuid) public function taskLink($task_uuid)
{ {
if (data_get($this, 'environment.project.uuid')) { if (data_get($this, 'environment.project.uuid')) {
@ -1279,15 +1290,19 @@ public function dirOnServer()
return application_configuration_dir()."/{$this->uuid}"; return application_configuration_dir()."/{$this->uuid}";
} }
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null) public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null)
{ {
$baseDir = $this->generateBaseDir($deployment_uuid); $baseDir = $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir); $escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
$gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
// Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided, $resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand;
// so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. $sshCommand = $resolvedGitSshCommand
$sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; ? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=')
? $resolvedGitSshCommand
: 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
: 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha. // Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
// Invalid refs will cause the git checkout/fetch command to fail on the remote server. // Invalid refs will cause the git checkout/fetch command to fail on the remote server.
@ -1298,9 +1313,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
// If shallow clone is enabled and we need a specific commit, // If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1 // we need to fetch that specific commit with depth=1
if ($isShallowCloneEnabled) { if ($isShallowCloneEnabled) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
} else { } else {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
} }
} }
if ($this->settings->is_git_submodules_enabled) { if ($this->settings->is_git_submodules_enabled) {
@ -1311,10 +1326,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
} }
// Add shallow submodules flag if shallow clone is enabled // Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; $git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi";
} }
if ($this->settings->is_git_lfs_enabled) { if ($this->settings->is_git_lfs_enabled) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
} }
return $git_clone_command; return $git_clone_command;
@ -1555,6 +1570,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$github_access_token = generateGithubInstallationToken($this->source); $github_access_token = generateGithubInstallationToken($this->source);
$encodedToken = rawurlencode($github_access_token); $encodedToken = rawurlencode($github_access_token);
// Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
$git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
if ($exec_in_docker) { if ($exec_in_docker) {
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl); $escapedRepoUrl = escapeshellarg($repoUrl);
@ -1567,7 +1587,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$fullRepoUrl = $repoUrl; $fullRepoUrl = $repoUrl;
} }
if (! $only_checkout) { if (! $only_checkout) {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command)); $commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@ -1578,7 +1598,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
if ($pull_request_id !== 0) { if ($pull_request_id !== 0) {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
$escapedPrBranch = escapeshellarg($branch); $escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
@ -1603,12 +1623,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$gitlabPort = $gitlabSource->custom_port ?? 22; $gitlabPort = $gitlabSource->custom_port ?? 22;
$escapedCustomRepository = escapeshellarg($customRepository); $escapedCustomRepository = escapeshellarg($customRepository);
$gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
$git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
$git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = $git_clone_command_base; $git_clone_command = $git_clone_command_base;
} else { } else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands = collect([ $commands = collect([
@ -1631,7 +1652,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
@ -1674,12 +1695,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} }
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository); $escapedCustomRepository = escapeshellarg($customRepository);
$deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
$git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
$git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) { if ($only_checkout) {
$git_clone_command = $git_clone_command_base; $git_clone_command = $git_clone_command_base;
} else { } else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands = collect([ $commands = collect([
@ -1702,7 +1724,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'github' || $git_type === 'gitea') { } elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) { if ($exec_in_docker) {
@ -1710,14 +1732,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'bitbucket') { } elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
} }
} }
@ -1738,6 +1760,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$escapedCustomRepository = escapeshellarg($customRepository); $escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
$otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
if ($pull_request_id !== 0) { if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') { if ($git_type === 'gitlab') {
@ -1747,7 +1770,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
} elseif ($git_type === 'github' || $git_type === 'gitea') { } elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) { if ($exec_in_docker) {
@ -1755,14 +1778,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
} elseif ($git_type === 'bitbucket') { } elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
} }
} }
@ -2011,13 +2034,15 @@ public function fqdns(): Attribute
); );
} }
protected function buildGitCheckoutCommand($target): string protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
{ {
$escapedTarget = escapeshellarg($target); $escapedTarget = escapeshellarg($target);
$command = "git checkout {$escapedTarget}"; $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
$command = "{$gitCommand} checkout {$escapedTarget}";
if ($this->settings->is_git_submodules_enabled) { if ($this->settings->is_git_submodules_enabled) {
$command .= ' && git submodule update --init --recursive'; $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
$command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive";
} }
return $command; return $command;
@ -2332,7 +2357,7 @@ public function setConfig($config)
'config.build_pack' => 'required|string', 'config.build_pack' => 'required|string',
'config.base_directory' => 'required|string', 'config.base_directory' => 'required|string',
'config.publish_directory' => 'required|string', 'config.publish_directory' => 'required|string',
'config.ports_exposes' => 'required|string', 'config.ports_exposes' => 'nullable|string',
'config.settings.is_static' => 'required|boolean', 'config.settings.is_static' => 'required|boolean',
]); ]);
if ($deepValidator->fails()) { if ($deepValidator->fails()) {

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Casts\EncryptedArrayCast;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@ -74,11 +75,24 @@ class ApplicationDeploymentQueue extends Model
'finished_at', 'finished_at',
]; ];
/**
* The configuration snapshot/diff hold full (decrypted on read) configuration,
* including unlocked environment variable values. They are only meant for the
* in-app diff modal (which redacts per role) and must never be serialized by the
* API, so hide them globally as defense in depth.
*
* @var array<int, string>
*/
protected $hidden = [
'configuration_snapshot',
'configuration_diff',
];
protected $casts = [ protected $casts = [
'pull_request_id' => 'integer', 'pull_request_id' => 'integer',
'finished_at' => 'datetime', 'finished_at' => 'datetime',
'configuration_snapshot' => 'array', 'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => 'array', 'configuration_diff' => EncryptedArrayCast::class,
]; ];
public function application() public function application()

View file

@ -14,7 +14,12 @@ class S3Storage extends BaseModel
{ {
use HasFactory, HasSafeStringAttribute; use HasFactory, HasSafeStringAttribute;
private const CONNECTION_TIMEOUT_SECONDS = 15;
private const REQUEST_TIMEOUT_SECONDS = 15;
protected $fillable = [ protected $fillable = [
'team_id',
'name', 'name',
'description', 'description',
'region', 'region',
@ -157,6 +162,10 @@ public function testConnection(bool $shouldSave = false)
'bucket' => $this['bucket'], 'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'], 'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true, 'use_path_style_endpoint' => true,
'http' => [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
],
]); ]);
// Test the connection by listing files with ListObjectsV2 (S3) // Test the connection by listing files with ListObjectsV2 (S3)
$disk->files(); $disk->files();
@ -164,11 +173,12 @@ public function testConnection(bool $shouldSave = false)
$this->unusable_email_sent = false; $this->unusable_email_sent = false;
$this->is_usable = true; $this->is_usable = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$exception = $this->toUserFriendlyConnectionException($e);
$this->is_usable = false; $this->is_usable = false;
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) { if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
$mail = new MailMessage; $mail = new MailMessage;
$mail->subject('Coolify: S3 Storage Connection Error'); $mail->subject('Coolify: S3 Storage Connection Error');
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
// Load the team with its members and their roles explicitly // Load the team with its members and their roles explicitly
$team = $this->team()->with(['members' => function ($query) { $team = $this->team()->with(['members' => function ($query) {
@ -183,11 +193,25 @@ public function testConnection(bool $shouldSave = false)
$this->unusable_email_sent = true; $this->unusable_email_sent = true;
} }
throw $e; throw $exception;
} finally { } finally {
if ($shouldSave) { if ($shouldSave) {
$this->save(); $this->save();
} }
} }
} }
private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
{
$message = str($exception->getMessage())->lower();
if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) {
return new \RuntimeException(
'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.',
previous: $exception,
);
}
return $exception;
}
} }

View file

@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
protected function casts(): array protected function casts(): array
{ {
return [ return [
'size' => 'integer',
's3_uploaded' => 'boolean', 's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean', 'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean',

View file

@ -778,7 +778,8 @@ public function extraFields()
} }
$rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first(); $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first();
if (is_null($rpc_secret)) { if (is_null($rpc_secret)) {
$rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first(); $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_64_RPCSECRET')->first()
?? $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
} }
$metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first(); $metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first();
if (is_null($metrics_token)) { if (is_null($metrics_token)) {

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneClickhouse extends BaseModel class StandaloneClickhouse extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'clickhouse_admin_password' => 'encrypted', 'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
@ -111,6 +122,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings; $newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -5,6 +5,7 @@
use App\Jobs\ConnectProxyToNetworksJob; use App\Jobs\ConnectProxyToNetworksJob;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
class StandaloneDocker extends BaseModel class StandaloneDocker extends BaseModel
@ -127,7 +128,7 @@ public function services()
return $this->morphMany(Service::class, 'destination'); return $this->morphMany(Service::class, 'destination');
} }
public function databases() public function databases(): Collection
{ {
$postgresqls = $this->postgresqls; $postgresqls = $this->postgresqls;
$redis = $this->redis; $redis = $this->redis;

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneDragonfly extends BaseModel class StandaloneDragonfly extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'dragonfly_password' => 'encrypted', 'dragonfly_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
@ -110,6 +121,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings; $newConfigHash = $this->image.$this->ports_mappings;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneKeydb extends BaseModel class StandaloneKeydb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'keydb_password' => 'encrypted', 'keydb_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
@ -111,6 +122,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -12,7 +13,7 @@
class StandaloneMariadb extends BaseModel class StandaloneMariadb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'mariadb_password' => 'encrypted', 'mariadb_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
@ -114,6 +125,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneMongodb extends BaseModel class StandaloneMongodb extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
@ -120,6 +131,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneMysql extends BaseModel class StandaloneMysql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'mysql_password' => 'encrypted', 'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted', 'mysql_root_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
@ -116,6 +127,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandalonePostgresql extends BaseModel class StandalonePostgresql extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'init_scripts' => 'array', 'init_scripts' => 'array',
'postgres_password' => 'encrypted', 'postgres_password' => 'encrypted',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
@ -158,6 +169,7 @@ public function deleteVolumes()
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method; $newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\ClearsGlobalSearchCache; use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -11,7 +12,7 @@
class StandaloneRedis extends BaseModel class StandaloneRedis extends BaseModel
{ {
use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel
'destination_type', 'destination_type',
'destination_id', 'destination_id',
'environment_id', 'environment_id',
'health_check_enabled',
'health_check_interval',
'health_check_timeout',
'health_check_retries',
'health_check_start_period',
]; ];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [ protected $casts = [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer',
'health_check_timeout' => 'integer',
'health_check_retries' => 'integer',
'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer', 'public_port_timeout' => 'integer',
'restart_count' => 'integer', 'restart_count' => 'integer',
'last_restart_at' => 'datetime', 'last_restart_at' => 'datetime',
@ -115,6 +126,7 @@ protected function serverStatus(): Attribute
public function isConfigurationChanged(bool $save = false) public function isConfigurationChanged(bool $save = false)
{ {
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
$newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash); $newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash'); $oldConfigHash = data_get($this, 'config_hash');

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Actions\User\RevokeUserTeamTokens;
use App\Events\ServerReachabilityChanged; use App\Events\ServerReachabilityChanged;
use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsEmail;
@ -72,6 +73,8 @@ protected static function booted()
}); });
static::deleting(function (Team $team) { static::deleting(function (Team $team) {
RevokeUserTeamTokens::forTeam($team->id);
foreach ($team->privateKeys as $key) { foreach ($team->privateKeys as $key) {
$key->delete(); $key->delete();
} }

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Actions\User\RevokeUserTeamTokens;
use App\Jobs\UpdateStripeCustomerEmailJob; use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\EmailChangeVerification;
@ -121,6 +122,8 @@ protected static function boot()
static::deleting(function (User $user) { static::deleting(function (User $user) {
\DB::transaction(function () use ($user) { \DB::transaction(function () use ($user) {
RevokeUserTeamTokens::forUser($user);
$teams = $user->teams; $teams = $user->teams;
foreach ($teams as $team) { foreach ($teams as $team) {
$user_alone_in_team = $team->members->count() === 1; $user_alone_in_team = $team->members->count() === 1;
@ -158,6 +161,7 @@ protected static function boot()
if ($found_other_member_who_is_not_owner) { if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner'; $found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save(); $found_other_member_who_is_not_owner->pivot->save();
RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id);
$team->members()->detach($user->id); $team->members()->detach($user->id);
} else { } else {
static::finalizeTeamDeletion($user, $team); static::finalizeTeamDeletion($user, $team);

View file

@ -0,0 +1,141 @@
<?php
namespace App\Notifications\Application;
use App\Models\Application;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class RestartLimitReached extends CustomEmailNotification
{
public string $resource_name;
public string $project_uuid;
public string $environment_uuid;
public string $environment_name;
public ?string $resource_url = null;
public ?string $fqdn;
public int $restart_count;
public int $max_restart_count;
public function __construct(public Application $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');
$this->fqdn = data_get($resource, 'fqdn', null);
$this->restart_count = $resource->restart_count;
$this->max_restart_count = $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}";
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('status_change');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})");
$mail->view('emails.application-restart-limit-reached', [
'name' => $this->resource_name,
'fqdn' => $this->fqdn,
'resource_url' => $this->resource_url,
'restart_count' => $this->restart_count,
'max_restart_count' => $this->max_restart_count,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
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})",
color: DiscordMessage::errorColor(),
isCritical: true,
);
}
public function toTelegram(): array
{
$message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
return [
'message' => $message,
'buttons' => [
[
'text' => 'Open Application in Coolify',
'url' => $this->resource_url,
],
],
];
}
public function toPushover(): PushoverMessage
{
$message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
return new PushoverMessage(
title: 'Restart limit reached',
level: 'error',
message: $message,
buttons: [
[
'text' => 'Open Application in Coolify',
'url' => $this->resource_url,
],
],
);
}
public function toSlack(): SlackMessage
{
$title = 'Restart limit reached';
$description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})";
$description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name');
$description .= "\n*Environment:* {$this->environment_name}";
$description .= "\n*Application URL:* {$this->resource_url}";
return new SlackMessage(
title: $title,
description: $description,
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
return [
'success' => false,
'message' => 'Restart limit reached',
'event' => 'restart_limit_reached',
'application_name' => $this->resource_name,
'application_uuid' => $this->resource->uuid,
'restart_count' => $this->restart_count,
'max_restart_count' => $this->max_restart_count,
'url' => $this->resource_url,
'project' => data_get($this->resource, 'environment.project.name'),
'environment' => $this->environment_name,
'fqdn' => $this->fqdn,
];
}
}

View file

@ -2,18 +2,26 @@
namespace App\Rules; namespace App\Rules;
use App\Support\ValidationPatterns;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class DockerImageFormat implements ValidationRule class DockerImageFormat implements ValidationRule
{ {
/** /**
* Run the validation rule. * Run the validation rule.
* *
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail * @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/ */
public function validate(string $attribute, mixed $value, Closure $fail): void public function validate(string $attribute, mixed $value, Closure $fail): void
{ {
if (! is_string($value)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
return;
}
// Check if the value contains ":sha256:" or ":sha" which is incorrect format // Check if the value contains ":sha256:" or ":sha" which is incorrect format
if (preg_match('/:sha256?:/i', $value)) { if (preg_match('/:sha256?:/i', $value)) {
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).'); $fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
@ -21,20 +29,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return; return;
} }
// Valid formats: $imageName = $value;
// 1. image:tag (e.g., nginx:latest) $tag = null;
// 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
// 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
// 4. registry/image@sha256:hash
// 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
$pattern = '/^ if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) {
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port $imageName = $matches[1];
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required) } else {
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash $lastColon = strrpos($value, ':');
$/ix'; $lastSlash = strrpos($value, '/');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$imageName = substr($value, 0, $lastColon);
$tag = substr($value, $lastColon + 1);
}
}
if (! preg_match($pattern, $value)) { if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.'); $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
} }
} }

View file

@ -4,10 +4,13 @@
use App\Models\Application; use App\Models\Application;
use App\Models\EnvironmentVariable; use App\Models\EnvironmentVariable;
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class ApplicationConfigurationSnapshot class ApplicationConfigurationSnapshot
{ {
use SummarizesDiffText;
public const SCHEMA_VERSION = 1; public const SCHEMA_VERSION = 1;
public function __construct(protected Application $application) {} public function __construct(protected Application $application) {}
@ -115,12 +118,14 @@ private function buildItems(): array
$this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
$this->item('install_command', 'Install command', $this->application->install_command, 'build'), $this->item('install_command', 'Install command', $this->application->install_command, 'build'),
$this->item('build_command', 'Build command', $this->application->build_command, 'build'), $this->item('build_command', 'Build command', $this->application->build_command, 'build'),
$this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)), $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile),
$this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'), $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
$this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'), $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
$this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'), $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
$this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)), // The generated docker_compose is intentionally excluded: it is re-rendered
$this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)), // from git on every parse (resolved env, generated labels, deployment context),
// so comparing it would flag a permanent change for git-based compose apps.
$this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
$this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
$this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
$this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
@ -162,9 +167,10 @@ private function domainItems(): array
{ {
return [ return [
$this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), $this->item('fqdn', 'Domains', $this->application->fqdn, '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'), $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
$this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)), $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
$this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)), $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
$this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
$this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
$this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
@ -234,6 +240,7 @@ private function limitItems(): array
private function environmentItem(EnvironmentVariable $environmentVariable): array private function environmentItem(EnvironmentVariable $environmentVariable): array
{ {
$impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy'; $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
$locked = (bool) $environmentVariable->is_shown_once;
$compareValue = [ $compareValue = [
'value_hash' => $this->sensitiveHash($environmentVariable->value), 'value_hash' => $this->sensitiveHash($environmentVariable->value),
'is_multiline' => $environmentVariable->is_multiline, 'is_multiline' => $environmentVariable->is_multiline,
@ -242,20 +249,62 @@ private function environmentItem(EnvironmentVariable $environmentVariable): arra
'is_runtime' => $environmentVariable->is_runtime, 'is_runtime' => $environmentVariable->is_runtime,
]; ];
// Locked (is_shown_once) variables are always redacted and never store a value.
if ($locked) {
return $this->item(
key: (string) $environmentVariable->key,
label: (string) $environmentVariable->key,
value: $compareValue,
impact: $impact,
sensitive: true,
displayValue: $this->environmentDisplayValue($environmentVariable),
);
}
// Unlocked variables expose their value so owners/admins can see the change.
// The compare value is pre-hashed (identical formula to the locked branch) so
// change detection stays stable and never carries the raw value; members are
// redacted at render time in ConfigurationChecker; the column is encrypted at rest.
// The value and each scope flag are rendered as their own line and diffed by line,
// so a change to one or more attributes shows exactly what changed (one line each).
$value = (string) $environmentVariable->value;
return $this->item( return $this->item(
key: (string) $environmentVariable->key, key: (string) $environmentVariable->key,
label: (string) $environmentVariable->key, label: (string) $environmentVariable->key,
value: $compareValue, value: $this->sensitiveHash($this->normalizeValue($compareValue)),
impact: $impact, impact: $impact,
sensitive: true, sensitive: false,
displayValue: $this->environmentDisplayValue($environmentVariable), displayValue: $this->summarizeText($value),
displayFull: $this->environmentLines($environmentVariable),
diffMode: 'lines',
); );
} }
/**
* One line per attribute so the line diff surfaces exactly which value/flags changed.
*/
private function environmentLines(EnvironmentVariable $environmentVariable): string
{
$lines = collect();
$value = (string) $environmentVariable->value;
if (filled($value)) {
$lines->push($value);
}
$lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled'));
$lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled'));
$lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled'));
$lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled'));
return $lines->implode("\n");
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array
{ {
$normalizedValue = $this->normalizeValue($value); $normalizedValue = $this->normalizeValue($value);
@ -264,21 +313,28 @@ private function item(string $key, string $label, mixed $value, string $impact,
'label' => $label, 'label' => $label,
'impact' => $impact, 'impact' => $impact,
'sensitive' => $sensitive, 'sensitive' => $sensitive,
'diff_mode' => $diffMode,
'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue, 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
'display_value' => $displayValue ?? $this->displayValue($normalizedValue), 'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)),
]; ];
} }
private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
{ {
$flags = collect([ $flags = $this->environmentFlags($environmentVariable);
return $flags ? "Hidden ({$flags})" : 'Hidden';
}
private function environmentFlags(EnvironmentVariable $environmentVariable): string
{
return collect([
$environmentVariable->is_buildtime ? 'build-time' : null, $environmentVariable->is_buildtime ? 'build-time' : null,
$environmentVariable->is_runtime ? 'runtime' : null, $environmentVariable->is_runtime ? 'runtime' : null,
$environmentVariable->is_multiline ? 'multiline' : null, $environmentVariable->is_multiline ? 'multiline' : null,
$environmentVariable->is_literal ? 'literal' : null, $environmentVariable->is_literal ? 'literal' : null,
])->filter()->implode(', '); ])->filter()->implode(', ');
return $flags ? "Hidden ({$flags})" : 'Hidden';
} }
private function sensitiveHash(mixed $value): string private function sensitiveHash(mixed $value): string
@ -320,6 +376,58 @@ private function displayValue(mixed $value): string
return $this->summarizeText((string) $value); return $this->summarizeText((string) $value);
} }
private function stringifyValue(mixed $value): ?string
{
if ($value === null || is_bool($value)) {
return null;
}
if (is_array($value)) {
return json_encode($value, JSON_THROW_ON_ERROR);
}
return (string) $value;
}
/**
* @return array<string, mixed>|null
*/
private function decodedComposeDomains(): ?array
{
if (blank($this->application->docker_compose_domains)) {
return null;
}
$decoded = json_decode((string) $this->application->docker_compose_domains, true);
return is_array($decoded) ? $decoded : null;
}
private function composeDomainsText(): ?string
{
$decoded = $this->decodedComposeDomains();
if (blank($decoded)) {
return null;
}
return collect($decoded)
->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-'))
->sort()
->implode("\n");
}
private function decodeCustomLabels(?string $value): ?string
{
if (blank($value)) {
return null;
}
$decoded = base64_decode($value, true);
return $decoded === false ? $value : $decoded;
}
private function summarizeText(?string $value): string private function summarizeText(?string $value): string
{ {
if (blank($value)) { if (blank($value)) {
@ -333,6 +441,6 @@ private function summarizeText(?string $value): string
return str($value)->limit(80)." ({$lines} lines)"; return str($value)->limit(80)." ({$lines} lines)";
} }
return str($value)->limit(120)->value(); return str($value)->limit(self::SINGLE_LINE_LIMIT)->value();
} }
} }

View file

@ -0,0 +1,32 @@
<?php
namespace App\Services\DeploymentConfiguration\Concerns;
trait SummarizesDiffText
{
/**
* Maximum length of a single-line value before it is truncated/considered
* worth expanding. Kept as one constant so the snapshot summary and the
* differ's expand decision never drift apart.
*/
private const SINGLE_LINE_LIMIT = 120;
/**
* Returns the value only when it is worth expanding (multi-line or longer
* than the single-line truncation limit). Otherwise null.
*/
private function expandableText(?string $value): ?string
{
if (blank($value)) {
return null;
}
$value = trim((string) $value);
if (str_contains($value, "\n") || mb_strlen($value) > self::SINGLE_LINE_LIMIT) {
return $value;
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show more