diff --git a/.ai/lessons.md b/.ai/lessons.md index 7b94e29b3b..a1096cebad 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -14,6 +14,7 @@ - Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. +- Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. ## Verify the complete user flow diff --git a/README.md b/README.md index 2d02aefa24..13635cd659 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Thank you so much! * [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany. * [ISHosting](https://ishosting.com/) - Hosting and VPS solutions * [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting +* [NodePlus.ai](https://nodeplus.ai/) - AI that reads every system your business runs on ### Small Sponsors diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index f3831125a1..d053142d2c 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Models\StandaloneClickhouse; use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; @@ -36,6 +37,7 @@ class StartClickhouse $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); @@ -178,15 +180,15 @@ class StartClickhouse } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_USER')) { $environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_PASSWORD')) { $environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'CLICKHOUSE_DB')) { $environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}"); } diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 1f71465a1a..f26b8cbb25 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Models\Server; use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; @@ -58,6 +59,8 @@ class StartDatabaseProxy $configuration_dir = database_proxy_dir($database->uuid); $host_configuration_dir = devHostDockerPath($server, $configuration_dir); $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); + $listenConfig = $this->buildListenConfig($database->public_port, $this->isNetworkIpv6Enabled($network, $server)); + $upstreamConfig = $this->buildUpstreamConfig($containerName, $internalPort); $nginxconf = <<public_port; - proxy_pass $containerName:$internalPort; + $listenConfig + $upstreamConfig $timeoutConfig } } @@ -164,6 +168,36 @@ class StartDatabaseProxy return false; } + /** + * Docker publishes the port on [::] too and forwards it to the container's IPv6 address + * when the network has IPv6 enabled, so nginx must listen there as well. + */ + private function isNetworkIpv6Enabled(string $network, Server $server): bool + { + $safeNetwork = escapeshellarg($network); + $output = instant_remote_process(["docker network inspect {$safeNetwork} --format '{{.EnableIPv6}}'"], $server, false); + + return trim((string) $output) === 'true'; + } + + private function buildListenConfig(int $port, bool $ipv6Enabled): string + { + if (! $ipv6Enabled) { + return "listen {$port};"; + } + + return "listen {$port};\n listen [::]:{$port};"; + } + + /** + * The upstream is a variable so nginx resolves it through Docker's DNS on new connections. + * A literal host is resolved only once at startup, so a restarted database with a new IP would break the proxy. + */ + private function buildUpstreamConfig(string $host, int $port): string + { + return "set \$upstream {$host}:{$port};\n proxy_pass \$upstream;"; + } + private function buildProxyTimeoutConfig(?int $timeout): string { if ($timeout === null || $timeout < 1) { diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 58d1607aa8..16ad09c95d 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -87,6 +88,7 @@ class StartDragonfly $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $startCommand = $this->buildStartCommand(); @@ -263,7 +265,7 @@ class StartDragonfly } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) { $environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}"); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 770d055001..fa86576d5e 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -87,6 +88,7 @@ class StartKeydb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_keydb(); @@ -263,7 +265,7 @@ class StartKeydb } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'REDIS_PASSWORD')) { $environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 6dd7842fed..bf5739a86b 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -82,6 +83,7 @@ class StartMariadb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mysql(); @@ -251,18 +253,18 @@ class StartMariadb $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_ROOT_PASSWORD')) { $environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_DATABASE')) { $environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_USER')) { $environment_variables->push("MARIADB_USER={$this->database->mariadb_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MARIADB_PASSWORD')) { $environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}"); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 61f252da17..0afb0c4a6f 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -89,6 +90,7 @@ class StartMongodb $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mongo_conf(); @@ -311,15 +313,15 @@ class StartMongodb } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_USERNAME')) { $environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_ROOT_PASSWORD')) { $environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MONGO_INITDB_DATABASE')) { $environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}"); } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index a5c9f8515e..ea723aada7 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -84,6 +85,7 @@ class StartMysql $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_mysql(); @@ -263,18 +265,18 @@ class StartMysql } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_ROOT_PASSWORD')) { $environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_DATABASE')) { $environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_USER')) { $environment_variables->push("MYSQL_USER={$this->database->mysql_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'MYSQL_PASSWORD')) { $environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}"); } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 0f1da0e481..c0949e51b8 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -88,6 +89,7 @@ class StartPostgresql $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->generate_init_scripts(); @@ -274,18 +276,18 @@ class StartPostgresql } } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_USER')) { $environment_variables->push("POSTGRES_USER={$this->database->postgres_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('PGUSER'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'PGUSER')) { $environment_variables->push("PGUSER={$this->database->postgres_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_PASSWORD'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_PASSWORD')) { $environment_variables->push("POSTGRES_PASSWORD={$this->database->postgres_password}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_DB'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'POSTGRES_DB')) { $environment_variables->push("POSTGRES_DB={$this->database->postgres_db}"); } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 7fc89b25ce..c12381aca8 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; use App\Models\SslCertificate; @@ -86,6 +87,7 @@ class StartRedis $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); $this->add_custom_redis(); @@ -268,10 +270,6 @@ class StartRedis if ($env->key === 'REDIS_USERNAME') { $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); - - if (! $usesSecretManager) { - $this->database->update(['redis_username' => $this->resolvedRedisUsername]); - } } } else { if ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 377a680594..9289cdf5a0 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Shared\EnsureContentFilesOnServer; use App\Models\StandaloneSqlite; use App\Traits\ExecutesDatabaseStartCommands; use Lorisleiva\Actions\Concerns\AsAction; @@ -32,6 +33,7 @@ class StartSqlite $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->database->fileStorages()->get(); + array_push($this->commands, ...EnsureContentFilesOnServer::echoCommands($persistent_file_volumes, $this->database->destination->server)); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $environment_variables = $this->generate_environment_variables(); @@ -147,7 +149,7 @@ class StartSqlite $environment_variables->push($env->key.'='.$resolvedValue); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('SQLITE_DATABASES'))->isEmpty()) { + if (! $this->hasEnvironmentVariable($environment_variables, 'SQLITE_DATABASES')) { $environment_variables->push("SQLITE_DATABASES={$this->database->sqlite_databases}"); } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index ddfc6578e4..52c37743ec 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -5,6 +5,8 @@ namespace App\Actions\Server; use App\Jobs\DatabaseBackupJob; use App\Models\Server; use App\Models\StandalonePostgresql; +use App\Notifications\Server\UpgradeSkippedLowDiskSpace; +use App\Services\CoolifyUpgradeDiskSpace; use App\Services\CoolifyUpgradeStatus; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -21,7 +23,7 @@ class UpdateCoolify public ?string $currentVersion = null; - public function handle($manual_update = false) + public function handle($manual_update = false, bool $skipDiskSpaceCheck = false) { if (isDev()) { $this->backupDatabase(); @@ -113,8 +115,12 @@ class UpdateCoolify ); } + if (! $skipDiskSpaceCheck && ! $this->ensureEnoughDiskSpace($manual_update)) { + return; + } + $this->backupDatabase(); - $this->update(); + $this->update($skipDiskSpaceCheck); $settings->new_version_available = false; $settings->save(); } @@ -135,7 +141,7 @@ class UpdateCoolify } } - private function update() + private function update(bool $skipDiskSpaceCheck) { $this->ensureNoUpgradeIsRunning(); @@ -143,15 +149,54 @@ class UpdateCoolify $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); $registryUrl = coolifyRegistryUrl(); + $upgradeCommand = 'bash /data/coolify/source/upgrade.sh '. + escapeshellarg($this->latestVersion).' '. + escapeshellarg($latestHelperImageVersion).' '. + escapeshellarg($registryUrl); + if ($skipDiskSpaceCheck) { + // Arguments 4 and 5: do not skip the backup, skip the disk space check. + $upgradeCommand .= " 'false' 'true'"; + } + remote_process([ "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", - 'bash /data/coolify/source/upgrade.sh '. - escapeshellarg($this->latestVersion).' '. - escapeshellarg($latestHelperImageVersion).' '. - escapeshellarg($registryUrl), + $upgradeCommand, ], $this->server); } + /** + * Automatic updates are skipped and the team is notified. Manual updates fail with an error. + */ + private function ensureEnoughDiskSpace(bool $manualUpdate): bool + { + $availableGb = $this->availableDiskSpaceGb(); + if (! CoolifyUpgradeDiskSpace::isLow($availableGb)) { + return true; + } + + Log::warning('Upgrade skipped because of low disk space', [ + 'target_version' => $this->latestVersion, + 'available_gb' => $availableGb, + 'required_gb' => CoolifyUpgradeDiskSpace::REQUIRED_GB, + 'manual_update' => $manualUpdate, + ]); + + if ($manualUpdate) { + throw new \Exception( + "Not enough free disk space to upgrade: {$availableGb} GB free, ".CoolifyUpgradeDiskSpace::REQUIRED_GB.' GB required.' + ); + } + + $this->server->team?->notify(new UpgradeSkippedLowDiskSpace($this->server, $this->latestVersion, $availableGb)); + + return false; + } + + protected function availableDiskSpaceGb(): ?float + { + return app(CoolifyUpgradeDiskSpace::class)->availableGb($this->server); + } + private function ensureNoUpgradeIsRunning(): void { $status = $this->readUpgradeStatus(); diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php index d52ba50142..de56a4e33b 100644 --- a/app/Actions/Shared/CheckDomainDns.php +++ b/app/Actions/Shared/CheckDomainDns.php @@ -5,7 +5,6 @@ namespace App\Actions\Shared; use App\Models\Server; use App\Support\DnsRecordHints; use Lorisleiva\Actions\Concerns\AsAction; -use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; @@ -76,14 +75,9 @@ class CheckDomainDns } try { - $query = app()->make(DNSQuery::class, [ - 'server' => $dnsServer, - 'port' => 53, - 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), - ]); - $records = $query->query($host, $type); + $records = queryDnsServer($dnsServer, $host, $type, min(5, (int) floor($remainingNanoseconds / 1_000_000_000))); - if ($records === false || $query->hasError()) { + if ($records === false) { continue; } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index a0edd8cb60..e872cef31d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4092,7 +4092,7 @@ class ApplicationsController extends Controller $is_shown_once = $item->get('is_shown_once') ?? false; $key = str($item->get('key'))->trim()->replace(' ', '_')->value; if ($is_preview) { - $env = $application->environment_variables_preview->where('key', $key)->first(); + $env = $application->environment_variables_preview()->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); @@ -4131,7 +4131,7 @@ class ApplicationsController extends Controller ]); } } else { - $env = $application->environment_variables->where('key', $key)->first(); + $env = $application->environment_variables()->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); if ($env->is_literal != $is_literal) { diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 7c2cbd8ace..d432c90811 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -932,7 +932,7 @@ class HetznerController extends Controller if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } if (! $ipAddress) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 5fa1801a5e..69622d8255 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -358,11 +358,7 @@ class ServersController extends Controller if ($applications->count() > 0) { foreach ($applications as $application) { $ip = $application->destination->server->ip; - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); @@ -401,11 +397,7 @@ class ServersController extends Controller $service_applications = $service->applications; if ($service_applications->count() > 0) { foreach ($service_applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); $serviceIp = $server->ip; @@ -597,7 +589,7 @@ class ServersController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $foundServer = ModelsServer::whereIp($request->ip)->first(); + $foundServer = ModelsServer::whereIp(ModelsServer::normalizeIp($request->ip))->first(); if ($foundServer) { return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } @@ -1125,4 +1117,17 @@ class ServersController extends Controller return response()->json(['message' => $message], 201); } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 009b11f299..ba270cf688 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -26,6 +26,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; class Github extends Controller @@ -747,11 +748,21 @@ class Github extends Controller action: 'install', ); - abort_unless( - $this->githubInstallationBelongsToApp($github_app, $installation_id), - 403, - 'GitHub App installation could not be verified.' - ); + $verification_error = $this->githubInstallationVerificationError($github_app, $installation_id); + if ($verification_error) { + Log::warning('GitHub App installation could not be verified.', [ + 'github_app_id' => $github_app->id, + 'installation_id' => $installation_id, + 'reason' => $verification_error, + ]); + + $message = "GitHub App installation could not be verified: {$verification_error}"; + abort_if($request->expectsJson(), 403, $message); + + return redirect() + ->route('source.github.show', ['github_app_uuid' => $github_app->uuid]) + ->with('error', $message); + } $github_app->installation_id = $installation_id; $github_app->save(); @@ -778,11 +789,13 @@ class Github extends Controller * The installation id arrives as an untrusted query parameter on an * unauthenticated-reachable GET callback, so it must be confirmed against * the GitHub API using the App's own credentials before it is persisted. + * + * @return string|null The reason the verification failed, or null when it passed. */ - private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool + private function githubInstallationVerificationError(GithubApp $github_app, string $installation_id): ?string { - if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) { - return false; + if (blank($github_app->app_id) || blank(githubAppPrivateKey($github_app)?->private_key)) { + return 'The GitHub App ID or private key is missing.'; } try { @@ -794,12 +807,19 @@ class Github extends Controller ->timeout(10) ->connectTimeout(5) ->get("{$github_app->api_url}/app/installations/{$installation_id}"); - - return $response->successful() - && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id; - } catch (\Throwable) { - return false; + } catch (\Throwable $e) { + return strip_tags(str_replace('
', ' ', $e->getMessage())); } + + if (! $response->successful()) { + return "GitHub API returned HTTP {$response->status()}: ".data_get($response->json(), 'message', 'no error message found'); + } + + if ((string) data_get($response->json(), 'app_id') !== (string) $github_app->app_id) { + return 'The installation belongs to a different GitHub App.'; + } + + return null; } private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp @@ -813,12 +833,20 @@ class Github extends Controller $this->rejectInvalidGithubAppSetupState($request); } - $team_id = $request->user()?->currentTeam()?->id; - abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403); + // The state is bound to the team that started the setup. The user may have switched + // teams while on GitHub, so check membership of that team, not the session team. + $team_id = (int) data_get($payload, 'team_id'); + abort_unless((bool) $request->user()?->teams->contains('id', $team_id), 403); - return GithubApp::whereKey(data_get($payload, 'github_app_id')) - ->where('team_id', data_get($payload, 'team_id')) + $github_app = GithubApp::whereKey(data_get($payload, 'github_app_id')) + ->where('team_id', $team_id) ->firstOrFail(); + + if ($request->user()->currentTeam()?->id !== $team_id) { + refreshSession($github_app->team); + } + + return $github_app; } private function rejectInvalidGithubAppSetupState(Request $request): never diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 8ef89f3014..056a327f81 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1332,7 +1332,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Pushing image to docker registry ({$this->production_image_name})."); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "docker push {$this->production_image_name}"), + executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg($this->production_image_name)), 'hidden' => true, ], ); @@ -1341,12 +1341,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "docker tag {$this->production_image_name} {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"), + executeInDocker($this->deployment_uuid, 'docker tag '.escapeshellarg($this->production_image_name).' '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], [ - executeInDocker($this->deployment_uuid, "docker push {$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}"), + executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], @@ -1552,7 +1552,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) { $this->execute_remote_command([ - "docker pull {$this->production_image_name} 2>/dev/null", + 'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null', 'ignore_errors' => true, 'hidden' => true, ]); @@ -3478,7 +3478,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $environmentPrefix = $this->railpack_build_environment_prefix($controlVariables); $secretFlags = $this->railpack_build_secret_flags($variables); - $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); + // The helper image sets RAILPACK_VERSION, so the frontend always matches the Railpack CLI in the helper. + // Helper images without it fall back to the version of the Railpack CLI itself. + $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION:-$(railpack --version | cut -d " " -f 3)}'; $buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" @@ -3943,6 +3945,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $labels = collect(generateLabelsApplication($this->application, $this->preview)); } $labels = $this->useDestinationNetworkInCaddyLabels($labels); + $labels = traefikHostRulesWithoutIpv6Brackets($labels); if ($this->application->settings->is_container_label_escape_enabled) { $labels = $labels->map(function ($value, $key) { return escapeDollarSign($value); @@ -4244,7 +4247,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); - $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); + $host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host) + ? formatHostForUrl($this->application->health_check_host) + : 'localhost'; $path = $this->application->health_check_path ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/') : null; diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index eb0959865b..00f3d35a50 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -922,7 +922,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; - $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; + $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp{$resolveOptions} {$escapedBackupLocation} {$escapedS3Destination}"; instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true); } catch (Throwable $e) { diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index db226e5675..fe27be0441 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -403,7 +403,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue .escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image), 'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary ' .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret), - 'docker exec '.escapeshellarg($containerName).' mc cp '.escapeshellarg($backupLocation).' ' + 'docker exec '.escapeshellarg($containerName).' mc cp'.$resolveOptions.' '.escapeshellarg($backupLocation).' ' .escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'), ], $server, timeout: $this->timeout, disableMultiplexing: true); } finally { diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ca305905a3..50bad00678 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -302,7 +302,7 @@ class Index extends Component $this->validate(); - $foundServer = Server::whereIp($this->remoteServerHost)->first(); + $foundServer = Server::whereIp(Server::normalizeIp($this->remoteServerHost))->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index f1f897b855..fe7f99aa3c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2064,7 +2064,7 @@ class Domains extends Component $lowerHost = strtolower($host); // Always skip bare IPs and localhost. - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 7452a13f1a..7349b63c27 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -1780,7 +1780,7 @@ class Domains extends Component $lowerHost = strtolower($host); - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 70633fe030..3d9e2431ac 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Shared; +use App\Support\ValidationPatterns; +use Closure; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -28,7 +30,6 @@ class HealthChecks extends Component #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] @@ -58,23 +59,30 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $customHealthcheckFound = false; - protected $rules = [ - 'healthCheckEnabled' => 'boolean', - 'healthCheckType' => 'string|in:http,cmd', - 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], - 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], - 'healthCheckPort' => 'nullable|integer|min:1|max:65535', - 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], - 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', - 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'required|string|in:http,https', - 'healthCheckResponseText' => 'nullable|string', - 'healthCheckInterval' => 'integer|min:1', - 'healthCheckTimeout' => 'integer|min:1', - 'healthCheckRetries' => 'integer|min:1', - 'healthCheckStartPeriod' => 'integer', - 'customHealthcheckFound' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'healthCheckEnabled' => 'boolean', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname, an IPv4 address, or an IPv6 address.'); + } + }], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', + 'healthCheckReturnCode' => 'integer', + 'healthCheckScheme' => 'required|string|in:http,https', + 'healthCheckResponseText' => 'nullable|string', + 'healthCheckInterval' => 'integer|min:1', + 'healthCheckTimeout' => 'integer|min:1', + 'healthCheckRetries' => 'integer|min:1', + 'healthCheckStartPeriod' => 'integer', + 'customHealthcheckFound' => 'boolean', + ]; + } public function mount() { diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 66310c104e..16a7f0ad9b 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -30,6 +30,8 @@ class IntegrationTokenEditor extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(string $integration_token_uuid): void { $this->integrationToken = IntegrationToken::ownedByCurrentTeam() @@ -43,6 +45,7 @@ class IntegrationTokenEditor extends Component $this->metadata = $this->integrationToken->metadata ?? []; $this->loadZones(); $this->automaticDns = $this->integrationToken->automaticDnsEnabled(); + $this->proxiedDns = $this->integrationToken->proxiedDnsEnabled(); } protected function rules(): array @@ -55,6 +58,7 @@ class IntegrationTokenEditor extends Component 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->integrationToken->provider === 'infisical') { @@ -91,6 +95,11 @@ class IntegrationTokenEditor extends Component } else { $metadata['automatic_dns'] = false; } + if ($validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } else { + unset($metadata['proxied_dns']); + } } $storedMetadata = $this->integrationToken->metadata ?? []; $changedConnectionFields = $this->changedConnectionFields($provider, $storedMetadata, $metadata); diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index e07e22db0b..3cfbc75aa7 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -27,6 +27,8 @@ class IntegrationTokenForm extends Component public bool $automaticDns = true; + public bool $proxiedDns = false; + public function mount(): void { $this->authorize('create', IntegrationToken::class); @@ -38,6 +40,7 @@ class IntegrationTokenForm extends Component $this->capabilities = ['dns']; $this->metadata = []; $this->automaticDns = true; + $this->proxiedDns = false; } else { $this->capabilities = ['secrets']; $this->metadata = $this->provider === 'infisical' @@ -57,6 +60,7 @@ class IntegrationTokenForm extends Component 'capabilities' => ['required', 'array', 'min:1'], 'capabilities.*' => ['required', 'in:'.$allowedCapability], 'automaticDns' => ['boolean'], + 'proxiedDns' => ['boolean'], ]; if ($this->provider === 'infisical') { @@ -94,6 +98,9 @@ class IntegrationTokenForm extends Component if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) { $metadata['automatic_dns'] = false; } + if ($validated['provider'] === 'cloudflare' && $validated['proxiedDns']) { + $metadata['proxied_dns'] = true; + } try { if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index aadcb7b0c1..868b9e44b3 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -721,7 +721,7 @@ class ByHetzner extends Component if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } // Create server in Coolify database immediately so the Hetzner diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 6d35d73df8..d3bb69ae64 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -143,6 +143,7 @@ class ByIp extends Component $this->validate(); try { $this->authorize('create', Server::class); + $this->ip = Server::normalizeIp($this->ip); $foundServer = Server::whereIp($this->ip)->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 839f545ae2..4cb59e1996 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -250,9 +250,12 @@ class Show extends Component { if ($toModel) { $this->validate(); - $foundServer = Server::where('ip', $this->ip) + $this->ip = Server::normalizeIp($this->ip); + // Check only a changed IP, so that an old duplicate does not block saving other fields. + $ipChanged = $this->ip !== Server::normalizeIp($this->server->getRawOriginal('ip')); + $foundServer = $ipChanged ? Server::where('ip', $this->ip) ->where('id', '!=', $this->server->id) - ->first(); + ->first() : null; if ($foundServer) { $this->ip = $this->server->ip; throw new \Exception('A server with this IP/Domain already exists.'); @@ -605,7 +608,7 @@ class Show extends Component $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } - $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($serverData, 'public_net.ipv6.ip')); if ($this->server->backfillPlaceholderIp($assignedIp)) { $this->ip = $this->server->ip; } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index a5f60fc0e8..feba6f3bea 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -325,7 +325,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } if ($this->github_app->installation_id && session('from')) { $source_id = data_get(session('from'), 'source_id'); diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index a0e2049e01..128374efc1 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -156,7 +156,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 75d9b32b3e..62d922e4f6 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -3,8 +3,10 @@ namespace App\Livewire; use App\Actions\Server\UpdateCoolify; +use App\Jobs\DockerCleanupJob; use App\Models\InstanceSettings; use App\Models\Server; +use App\Services\CoolifyUpgradeDiskSpace; use App\Services\CoolifyUpgradeStatus; use Livewire\Component; @@ -62,19 +64,30 @@ class Upgrade extends Component $this->isUpgradeAvailable = $hasNewerVersion && $newVersionAvailable; } - public function upgrade() + /** + * Start the upgrade. Without $skipDiskSpaceCheck, the upgrade does not start when the disk is almost full. + * + * @return array{status: 'started'|'low_disk_space', available_gb?: float|null, required_gb?: int}|null + */ + public function upgrade(bool $skipDiskSpaceCheck = false) { try { if (! isInstanceAdmin()) { abort(403); } if ($this->updateInProgress) { - return; + return null; + } + if (! $skipDiskSpaceCheck) { + $diskSpace = $this->checkDiskSpace(); + if ($diskSpace['status'] === 'low_disk_space') { + return $diskSpace; + } } $this->updateInProgress = true; - dispatch(function () { + dispatch(function () use ($skipDiskSpaceCheck) { try { - UpdateCoolify::run(manual_update: true); + UpdateCoolify::run(manual_update: true, skipDiskSpaceCheck: $skipDiskSpaceCheck); } catch (\Throwable $e) { report($e); } @@ -85,7 +98,50 @@ class Upgrade extends Component 'resource' => 'instance', 'from_version' => config('constants.coolify.version'), 'to_version' => get_latest_version_of_coolify(), + 'skip_disk_space_check' => $skipDiskSpaceCheck, ]); + + return ['status' => 'started']; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + /** + * @return array{status: 'low_disk_space'|'ok', available_gb: float|null, required_gb: int} + */ + public function checkDiskSpace(): array + { + if (! isInstanceAdmin()) { + abort(403); + } + + $server = Server::find(0); + $availableGb = $server ? app(CoolifyUpgradeDiskSpace::class)->availableGb($server) : null; + + return [ + 'status' => CoolifyUpgradeDiskSpace::isLow($availableGb) ? 'low_disk_space' : 'ok', + 'available_gb' => $availableGb, + 'required_gb' => CoolifyUpgradeDiskSpace::REQUIRED_GB, + ]; + } + + public function runDockerCleanup() + { + try { + if (! isInstanceAdmin()) { + abort(403); + } + $server = Server::findOrFail(0); + DockerCleanupJob::dispatch($server, true); + auditLog('ui.server.docker_cleanup_started', [ + 'team_id' => $server->team_id, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'delete_unused_volumes' => false, + 'delete_unused_networks' => false, + ]); + $this->dispatch('success', 'Docker cleanup started. When it is done, click "Check again".'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Mcp/Tools/GetServerDomains.php b/app/Mcp/Tools/GetServerDomains.php index 7780f59715..9378549220 100644 --- a/app/Mcp/Tools/GetServerDomains.php +++ b/app/Mcp/Tools/GetServerDomains.php @@ -62,11 +62,7 @@ class GetServerDomains extends Tool $domains = collect(); foreach ($applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(fn (Stringable $f) => $f->isNotEmpty()); + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(fn (Stringable $f) => $f->isNotEmpty()); if ($fqdn->isNotEmpty()) { $domains->push([ @@ -90,4 +86,17 @@ class GetServerDomains extends Tool 'uuid' => $schema->string()->description('Server UUID.')->required(), ]; } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 25c2f55939..119c8d95df 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -76,6 +76,12 @@ class IntegrationToken extends BaseModel return $this->provider === 'cloudflare' && data_get($this->metadata, 'automatic_dns', true) !== false; } + /** New Cloudflare records are created proxied (orange cloud) when enabled, so the server IP is not exposed. */ + public function proxiedDnsEnabled(): bool + { + return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/Server.php b/app/Models/Server.php index 3ab35001f2..3e5797fd55 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -401,6 +401,7 @@ class Server extends BaseModel */ public function backfillPlaceholderIp(?string $ip): bool { + $ip = blank($ip) ? null : self::normalizeIp($ip); if (self::isPlaceholderIp($ip)) { return false; } @@ -451,7 +452,7 @@ class Server extends BaseModel $hetznerService = new HetznerService($this->cloudProviderToken->token); $server = $hetznerService->getServer($this->hetzner_server_id); $status = $server['status'] ?? null; - $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? data_get($server, 'public_net.ipv6.ip'); + $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($server, 'public_net.ipv6.ip')); $updates = []; if ($this->hetzner_server_status !== $status) { @@ -1054,7 +1055,7 @@ class Server extends BaseModel ], $this); } else { $url = Url::fromString($settings->fqdn); - $host = $url->getHost(); + $host = $this->dashboardTraefikHost($url->getHost()); $schema = $url->getScheme(); $traefik_dynamic_conf = [ 'http' => [ @@ -1243,6 +1244,14 @@ $siteAddress { return ['gzip']; } + /** + * Traefik matches an IPv6 Host() rule only without brackets (Host(`2a01:4f8::1`)). + */ + public function dashboardTraefikHost(string $host): string + { + return trim($host, '[]'); + } + public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string { if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { @@ -1858,12 +1867,19 @@ $siteAddress { get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); }, - set: function ($value) { - return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); - } + set: fn ($value) => self::normalizeIp($value), ); } + /** + * The form a server IP is stored in: unsafe characters removed (including IPv6 brackets) and + * IPv6 addresses in their short lower-case form (2A01:04F8::0001 -> 2a01:4f8::1). + */ + public static function normalizeIp(?string $value): string + { + return normalizeIpAddress(preg_replace('/[^0-9a-zA-Z.:%-]/', '', (string) $value)); + } + public function getIp(): Attribute { return Attribute::make( diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c7b28fb4ef..8aa3dedf6b 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -330,7 +330,7 @@ class ServerSetting extends Model return match (true) { filled($fqdn) => $fqdn, filled($publicIpv4) => 'http://'.$publicIpv4.':8000', - filled($publicIpv6) => 'http://'.$publicIpv6.':8000', + filled($publicIpv6) => 'http://'.formatHostForUrl($publicIpv6).':8000', default => null, }; } @@ -392,7 +392,7 @@ class ServerSetting extends Model } $request = request(); - $host = strtolower($request->getHost()); + $host = normalizeIpAddress(strtolower($request->getHost())); if ( $host === 'localhost' || diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 959c3a69c5..33f43561ec 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -151,7 +151,7 @@ class ServiceDatabase extends BaseModel $realIp = base_ip(); } - return "{$realIp}:{$port}"; + return formatHostForUrl((string) $realIp).":{$port}"; } public function team() diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 8c15e6853b..24455ffdd0 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -379,11 +379,12 @@ class StandaloneClickhouse extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->clickhouse_admin_user); $encodedPass = rawurlencode($this->clickhouse_admin_password); $database = $this->clickhouse_db ?? 'default'; - return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}"; + return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$database}"; } return null; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 98f9ff5afc..2f03815b25 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -333,9 +333,10 @@ class StandaloneDragonfly extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index bc1e045335..ca2c4174c1 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -334,9 +334,10 @@ class StandaloneKeydb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index c923b489bd..05aac1b42d 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -321,10 +321,11 @@ class StandaloneMariadb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mariadb_user); $encodedPass = rawurlencode($this->mariadb_password); - return "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mariadb_database}"; + return "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mariadb_database}"; } return null; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 70b108087a..a273132871 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -354,9 +354,10 @@ class StandaloneMongodb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mongo_initdb_root_username); $encodedPass = rawurlencode($this->mongo_initdb_root_password); - $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/?directConnection=true"; + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/?directConnection=true"; if ($this->enable_ssl) { $url .= '&tls=true&tlsCAFile=/etc/mongo/certs/ca.pem'; if (in_array($this->ssl_mode, ['verify-full'])) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 6a08a4dc45..7475421ba1 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -335,9 +335,10 @@ class StandaloneMysql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mysql_user); $encodedPass = rawurlencode($this->mysql_password); - $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mysql_database}"; + $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mysql_database}"; if ($this->enable_ssl) { $url .= "?ssl-mode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index f8dc5c0caa..1cb5c4ec41 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -346,9 +346,10 @@ class StandalonePostgresql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->postgres_user); $encodedPass = rawurlencode($this->postgres_password); - $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->postgres_db}"; + $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->postgres_db}"; if ($this->enable_ssl) { $url .= "?sslmode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index b8bdbef49b..abc1d4c0c6 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -105,12 +105,6 @@ class StandaloneRedis extends BaseModel $database->last_online_at = now(); } }); - - static::retrieved(function ($database) { - if (! $database->redis_username) { - $database->redis_username = 'default'; - } - }); } /** @@ -339,11 +333,12 @@ class StandaloneRedis extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $redis_version = $this->getRedisVersion(); $username_part = version_compare($redis_version, '6.0', '>=') ? rawurlencode($this->redis_username).':' : ''; $encodedPass = rawurlencode($this->connectionPassword()); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; - $url = "{$scheme}://{$username_part}{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://{$username_part}{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; @@ -472,19 +467,10 @@ class StandaloneRedis extends BaseModel { return new Attribute( get: function () { - // A loaded relation does not hold a variable created by an earlier read, so check the table before creating one. - $username = $this->runtimeEnvironmentVariable('REDIS_USERNAME') - ?? $this->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->first(); - if (! $username) { - $this->runtime_environment_variables()->create([ - 'key' => 'REDIS_USERNAME', - 'value' => 'default', - ]); + // Reading must not create the variable: a read during the create or clone of the variables adds a duplicate. + $username = $this->runtimeEnvironmentVariable('REDIS_USERNAME')?->value; - return 'default'; - } - - return $username->value; + return filled($username) ? $username : 'default'; } ); } diff --git a/app/Notifications/Server/UpgradeSkippedLowDiskSpace.php b/app/Notifications/Server/UpgradeSkippedLowDiskSpace.php new file mode 100644 index 0000000000..4a015009b6 --- /dev/null +++ b/app/Notifications/Server/UpgradeSkippedLowDiskSpace.php @@ -0,0 +1,121 @@ +onQueue('high'); + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('server_disk_usage'); + } + + public function throttleSubject(): ?Model + { + return $this->server; + } + + public function throttleIntervalMinutes(): int + { + return 24 * 60; + } + + private function cleanupUrl(): string + { + return base_url().'/server/'.$this->server->uuid.'/docker-cleanup'; + } + + public function toMail(): MailMessage + { + $mail = new MailMessage; + $mail->subject("Coolify: Automatic update to {$this->version} skipped, not enough free disk space"); + $mail->view('emails.upgrade-skipped-low-disk-space', [ + 'version' => $this->version, + 'available_gb' => $this->availableGb, + 'required_gb' => $this->requiredGb, + 'cleanup_url' => $this->cleanupUrl(), + ]); + + return $mail; + } + + public function toDiscord(): DiscordMessage + { + $message = new DiscordMessage( + title: ':warning: Automatic update skipped', + description: "Coolify did not update to {$this->version} because the server does not have enough free disk space.", + color: DiscordMessage::warningColor(), + isCritical: true, + ); + + $message->addField('Free', "{$this->availableGb} GB", true); + $message->addField('Required', "{$this->requiredGb} GB", true); + $message->addField('What to do?', '[Docker cleanup]('.$this->cleanupUrl().')', true); + + return $message; + } + + public function toTelegram(): array + { + return [ + 'message' => "Coolify: Automatic update to {$this->version} skipped.\nFree disk space: {$this->availableGb} GB. Required: {$this->requiredGb} GB.\nFree up disk space (for example, run a Docker cleanup): {$this->cleanupUrl()}", + ]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage( + title: 'Automatic update skipped', + level: 'warning', + message: "Coolify did not update to {$this->version} because the server does not have enough free disk space.

Free: {$this->availableGb} GB.
Required: {$this->requiredGb} GB.", + buttons: [ + 'Docker cleanup' => $this->cleanupUrl(), + ], + ); + } + + public function toSlack(): SlackMessage + { + $description = "Coolify did not update to {$this->version} because the server does not have enough free disk space.\n"; + $description .= "Free: {$this->availableGb} GB\n"; + $description .= "Required: {$this->requiredGb} GB\n\n"; + $description .= 'Free up disk space (for example, run a Docker cleanup): '.$this->cleanupUrl(); + + return new SlackMessage( + title: 'Automatic update skipped', + description: $description, + color: SlackMessage::warningColor() + ); + } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => 'Automatic update skipped, not enough free disk space', + 'event' => 'upgrade_skipped_low_disk_space', + 'version' => $this->version, + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'available_gb' => $this->availableGb, + 'required_gb' => $this->requiredGb, + 'url' => $this->cleanupUrl(), + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ff7eeb8c44..1c36b36298 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,7 +8,10 @@ use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; +use App\Support\AtomicFilesystem; use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Console\ConfigCacheCommand; +use Illuminate\Foundation\Console\RouteCacheCommand; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -25,6 +28,10 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); + + // Requests must never read a partly written cache file. See AtomicFilesystem. + $this->app->extend(ConfigCacheCommand::class, fn () => new ConfigCacheCommand(new AtomicFilesystem)); + $this->app->extend(RouteCacheCommand::class, fn () => new RouteCacheCommand(new AtomicFilesystem)); } public function boot(): void diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 25bc84449b..c872dde82b 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Log; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Throwable; @@ -362,10 +361,9 @@ class SafeWebhookUrl implements ValidationRule foreach ($dnsServers as $dnsServer) { foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) { try { - $query = new DNSQuery($dnsServer, 53, 5); - $records = $query->query($host, $type); + $records = queryDnsServer($dnsServer, $host, $type); - if ($records === false || $query->hasError()) { + if ($records === false) { continue; } @@ -395,7 +393,7 @@ class SafeWebhookUrl implements ValidationRule } return array_values(array_filter(array_map( - fn (string $server): string => trim($server), + fn (string $server): string => normalizeIpAddress($server), explode(',', $servers), ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false)); } diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index 29e219bd35..9a04409046 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -12,7 +12,7 @@ class ValidGitRepositoryUrl implements ValidationRule protected bool $allowIP; - public function __construct(bool $allowSSH = true, bool $allowIP = false) + public function __construct(bool $allowSSH = true, bool $allowIP = true) { $this->allowSSH = $allowSSH; $this->allowIP = $allowIP; @@ -27,6 +27,13 @@ class ValidGitRepositoryUrl implements ValidationRule return; } + // Brackets are only allowed around an IPv6 host, so check the rest of the URL without it. + $valueWithoutIpv6Host = preg_replace_callback( + '/^((?:https?|git):\/\/|[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/', + fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0], + $value, + ); + // Check for dangerous shell metacharacters that could be used for command injection $dangerousChars = [ ';', '|', '&', '$', '`', '(', ')', '{', '}', @@ -36,7 +43,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousChars as $char) { - if (str_contains($value, $char)) { + if (str_contains($valueWithoutIpv6Host, $char)) { Log::warning('Git repository URL validation failed - dangerous character', [ 'url' => $value, 'character' => $char, @@ -63,7 +70,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousPatterns as $pattern) { - if (preg_match($pattern, $value)) { + if (preg_match($pattern, $valueWithoutIpv6Host)) { Log::warning('Git repository URL validation failed - dangerous pattern', [ 'url' => $value, 'pattern' => $pattern, @@ -77,7 +84,7 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate based on URL type - if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) { + if (preg_match('/^[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+):/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); @@ -86,7 +93,7 @@ class ValidGitRepositoryUrl implements ValidationRule // Validate scp-style SSH URL format (user@host:user/repo.git) $scp = parseScpStyleGitUrl($value); - if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { + if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); return; @@ -100,9 +107,11 @@ class ValidGitRepositoryUrl implements ValidationRule } $parsed = parse_url($value); + // parse_url() keeps the brackets around an IPv6 host. + $host = trim(strtolower($parsed['host'] ?? ''), '[]'); // Check for IP addresses if not allowed - if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) { + if (! $this->allowIP && filter_var($host, FILTER_VALIDATE_IP)) { Log::warning('Git repository URL contains IP address', [ 'url' => $value, 'ip' => request()->ip(), @@ -114,9 +123,10 @@ class ValidGitRepositoryUrl implements ValidationRule } // Check for localhost/internal addresses - $host = strtolower($parsed['host'] ?? ''); $internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; - if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) { + $isReservedIp = filter_var($host, FILTER_VALIDATE_IP) !== false + && filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false; + if (in_array($host, $internalHosts) || $isReservedIp || str_ends_with($host, '.local')) { Log::warning('Git repository URL points to internal host', [ 'url' => $value, 'host' => $host, @@ -144,7 +154,7 @@ class ValidGitRepositoryUrl implements ValidationRule } } elseif (str_starts_with($value, 'git://')) { // Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde) - if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { + if (! preg_match('/^git:\/\/(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9\.\-]+)(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php index 270ff1c348..cdf53b727f 100644 --- a/app/Rules/ValidServerIp.php +++ b/app/Rules/ValidServerIp.php @@ -8,7 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; class ValidServerIp implements ValidationRule { /** - * Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname. + * Accepts a valid IPv4 address, IPv6 address (optionally in brackets), or RFC 1123 hostname. */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -26,6 +26,12 @@ class ValidServerIp implements ValidationRule return; } + // A bracketed IPv6 address ([2a01:4f8::1]); it is stored without brackets. + if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']') + && filter_var(substr($trimmed, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return; + } + // Delegate hostname validation to ValidHostname $hostnameRule = new ValidHostname; $failed = false; diff --git a/app/Services/ConfigurationRepository.php b/app/Services/ConfigurationRepository.php index 660191859e..41905fecef 100644 --- a/app/Services/ConfigurationRepository.php +++ b/app/Services/ConfigurationRepository.php @@ -37,7 +37,7 @@ class ConfigurationRepository $this->config->set('mail.mailers.smtp', [ 'transport' => 'smtp', 'scheme' => $mailerOptions['scheme'], - 'host' => $settings->smtp_host, + 'host' => filled($settings->smtp_host) ? formatHostForUrl($settings->smtp_host) : $settings->smtp_host, 'port' => $settings->smtp_port, 'encryption' => $mailerOptions['encryption'], 'username' => $settings->smtp_username, diff --git a/app/Services/CoolifyUpgradeDiskSpace.php b/app/Services/CoolifyUpgradeDiskSpace.php new file mode 100644 index 0000000000..a0ec794f21 --- /dev/null +++ b/app/Services/CoolifyUpgradeDiskSpace.php @@ -0,0 +1,63 @@ +/dev/null || true"], + $server, + false + )); + + $paths = collect([...self::UPGRADE_PATHS, $dockerRootDir ?: self::DEFAULT_DOCKER_ROOT_DIR]) + ->map(fn (string $path) => escapeshellarg($path)) + ->implode(' '); + + $output = instant_remote_process(["df -Pk {$paths} 2>/dev/null || true"], $server, false); + + return self::parseDfOutput((string) $output); + } + + /** + * Read the smallest "Available" column (in 1K blocks) from `df -Pk` output and return it in GB. + */ + public static function parseDfOutput(string $output): ?float + { + $availableKb = collect(preg_split('/\R/', trim($output))) + ->skip(1) + ->map(fn (string $line) => preg_split('/\s+/', trim($line))[3] ?? null) + ->filter(fn (?string $value) => $value !== null && ctype_digit($value)) + ->map(fn (string $value) => (int) $value) + ->min(); + + if ($availableKb === null) { + return null; + } + + return round($availableKb / 1024 / 1024, 1); + } + + public static function isLow(?float $availableGb): bool + { + return $availableGb !== null && $availableGb < self::REQUIRED_GB; + } +} diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php index 9cc64beb4b..995a632f8a 100644 --- a/app/Services/Dns/CloudflareDnsProvider.php +++ b/app/Services/Dns/CloudflareDnsProvider.php @@ -153,7 +153,8 @@ class CloudflareDnsProvider $uuid = new_public_id(); $response = $this->client($zone->integrationToken)->post("https://api.cloudflare.com/client/v4/zones/{$zone->provider_zone_id}/dns_records", [ - 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, 'proxied' => false, + 'type' => $type, 'name' => $hostname, 'content' => $content, 'ttl' => 1, + 'proxied' => $zone->integrationToken->proxiedDnsEnabled(), 'comment' => ManagedDnsRecord::ownershipCommentFor($uuid), ]); if (! $response->successful() || ! is_string($response->json('result.id'))) { diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 6b95d56393..a165df7e7b 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -15,7 +15,11 @@ class DockerRegistryLogins { public const DOCKER_HUB = 'docker.io'; - public const REGISTRY_PATTERN = '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/'; + /** + * A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only + * hex digits, ':' and '.', so the value stays safe in shell commands. + */ + public const REGISTRY_PATTERN = '/^(?:[a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(:[0-9]{1,5})?$/'; /** * Read the registries a server is logged in to from the Docker config that deployments mount. diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 27dbc4a83a..c9675a24c8 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -179,18 +179,19 @@ class HetznerService public function findServerByIp(string $ip): ?array { + $ip = normalizeIpAddress($ip); + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; $servers = $this->getServers(); foreach ($servers as $server) { - // Check IPv4 $ipv4 = data_get($server, 'public_net.ipv4.ip'); - if ($ipv4 === $ip) { + if (filled($ipv4) && normalizeIpAddress($ipv4) === $ip) { return $server; } - // Check IPv6 (Hetzner returns the full /64 block) - $ipv6 = data_get($server, 'public_net.ipv6.ip'); - if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + // Hetzner returns the IPv6 network (2a01:4f8:c016:bd23::/64), not the server address. + $ipv6Network = data_get($server, 'public_net.ipv6.ip'); + if ($isIpv6 && filled($ipv6Network) && str_contains($ipv6Network, '/') && ipMatch($ip, [$ipv6Network])) { return $server; } } diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 06f1e5d49f..1d3e87e48c 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -39,11 +39,14 @@ class InfisicalService { $client = $this->client()->withToken($this->login()); $secretPath = $secretPath ?: '/'; + // Older Infisical versions do not expand ${KEY} references unless asked to. + $valueOptions = ['expandSecretReferences' => 'true', 'viewSecretValue' => 'true']; $response = $client->get($this->baseUrl.'/api/v4/secrets', [ 'projectId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + ...$valueOptions, ]); // Older self-hosted instances only expose the v3 endpoint. @@ -52,6 +55,7 @@ class InfisicalService 'workspaceId' => $projectId, 'environment' => $environment, 'secretPath' => $secretPath, + ...$valueOptions, ]); } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index d1714d4e70..029544640a 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -128,7 +128,7 @@ class ServerTransferImporter $serverIp = (string) data_get($bundle, 'server.ip'); $uuidConflict = Server::withTrashed()->where('uuid', $serverUuid)->exists() && $preserveUuids; - $existingIp = Server::where('ip', $serverIp)->first(); + $existingIp = Server::where('ip', Server::normalizeIp($serverIp))->first(); $ipConflict = $existingIp !== null; if (! $dryRun && $uuidConflict) { diff --git a/app/Support/AtomicFilesystem.php b/app/Support/AtomicFilesystem.php new file mode 100644 index 0000000000..321b5aa584 --- /dev/null +++ b/app/Support/AtomicFilesystem.php @@ -0,0 +1,51 @@ +smtp_host, + formatHostForUrl((string) $settings->smtp_host), (int) $settings->smtp_port, match ($mode) { 'none' => false, diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 4f5a77a140..53bc945c18 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -137,10 +137,11 @@ class ValidationPatterns * 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 + * path components. A bracketed IPv6 registry host ([2a01:4f8::1]:5000) may + * only hold hex digits, ':' and '.'. 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/'; + public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; /** * Pattern for Docker image tags. @@ -327,6 +328,24 @@ class ValidationPatterns return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; } + /** + * Check if a string is a safe health check host: a hostname or IPv4 address, or an IPv6 address + * with or without brackets (::1, [::1]). + */ + public static function isValidHealthCheckHost(?string $value): bool + { + if (! is_string($value)) { + return false; + } + + if (preg_match('/^[a-zA-Z0-9.\-_]+$/', $value) === 1) { + return true; + } + + return preg_match('/^(?:\[[0-9a-fA-F:.]+\]|[0-9a-fA-F:.]+)$/', $value) === 1 + && filter_var(trim($value, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + /** * Check if a string is a valid Docker image tag. */ diff --git a/app/Traits/ExecutesDatabaseStartCommands.php b/app/Traits/ExecutesDatabaseStartCommands.php index fca00cafa0..d61cfb3146 100644 --- a/app/Traits/ExecutesDatabaseStartCommands.php +++ b/app/Traits/ExecutesDatabaseStartCommands.php @@ -4,6 +4,7 @@ namespace App\Traits; use App\Services\DatabaseStartCommandExecutor; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Spatie\Activitylog\Models\Activity; trait ExecutesDatabaseStartCommands @@ -16,4 +17,14 @@ trait ExecutesDatabaseStartCommands return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged', queue: deployment_queue()); } + + /** + * Whether a `KEY=value` list already defines the exact key (not just a key or value containing it). + * + * @param Collection $environmentVariables + */ + private function hasEnvironmentVariable(Collection $environmentVariables, string $key): bool + { + return $environmentVariables->contains(fn ($variable) => str($variable)->before('=')->value() === $key); + } } diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 46501331e9..2e6e2eec17 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -240,7 +240,11 @@ function sharedDataApplications(?Application $application = null): array 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], 'health_check_port' => 'integer|nullable|min:1|max:65535', - 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname or an IP address.'); + } + }], 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', 'health_check_scheme' => 'string|in:http,https', diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 2f57eb8a9b..9e1f8867c5 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -509,6 +509,15 @@ function clone_application(Application $source, $destination, array $overrides = // Clone production environment variables without triggering the created hook $environmentVariables = $source->environment_variables()->get(); + $previewEnvironmentVariables = $source->environment_variables_preview()->get(); + + // Drop defaults auto-created for the new application (e.g. NIXPACKS_NODE_VERSION) so the source values win + EnvironmentVariable::query() + ->where('resourceable_type', $newApplication->getMorphClass()) + ->where('resourceable_id', $newApplication->id) + ->whereIn('key', $environmentVariables->pluck('key')->merge($previewEnvironmentVariables->pluck('key'))->unique()) + ->delete(); + foreach ($environmentVariables as $environmentVariable) { EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { $newEnvironmentVariable = $environmentVariable->replicate([ @@ -527,7 +536,6 @@ function clone_application(Application $source, $destination, array $overrides = $source->cloneSecretManagerLinkTo($newApplication); // Clone preview environment variables - $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 966b2de3e6..a34fdde9a8 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -235,7 +235,7 @@ function streamBackupFromServer(Server $server, string $filename, string $conten $disk = Storage::build([ 'driver' => 'sftp', - 'host' => $server->ip, + 'host' => formatHostForUrl($server->ip), 'port' => (int) $server->port, 'username' => $server->user, 'privateKey' => $privateKey->getKeyLocation(), diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2c1025b623..9793336698 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -89,10 +89,12 @@ function containerOwnerUuid(Collection|array|string $labels, string $type): ?str /** * Resource that owns a container, from the given candidates, by UUID label or compose project / - * stack namespace (see containerOwnerUuid()). Applications deployed before mid-2024 used the - * deployment directory as compose project; their compose service (the container name) starts - * with the application UUID. The numeric id label is never used: another Coolify instance that - * manages the same server can have a resource with the same id. + * stack namespace (see containerOwnerUuid()). Some older application containers have another + * compose project: the deployment directory (before mid-2024, or a custom Compose start command + * without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also + * the compose service) contains the application UUID as a dash-separated part, for example + * `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used: + * another Coolify instance that manages the same server can have a resource with the same id. * * @template TResource of \Illuminate\Database\Eloquent\Model * @@ -110,9 +112,11 @@ function resolveContainerOwner(Collection $resources, Collection|array|string $l return $owner; } - $composeService = (string) $labels->get('com.docker.compose.service'); + $names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')]; - return $resources->first(fn ($resource) => $composeService === $resource->uuid || str_starts_with($composeService, $resource->uuid.'-')); + return $resources->first(fn ($resource) => collect($names)->contains( + fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1 + )); } /** @@ -238,8 +242,8 @@ function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters $base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument, ]; if ($type === 'application') { - // Deployed before mid-2024 (see resolveContainerOwner()): the container name starts with the UUID. - $commands[] = $base.' '.$filters(['label=coolify.applicationId', "name=^{$uuid}", ...$legacyFilters]).' '.$formatArgument; + // Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID. + $commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument; } return $commands; @@ -922,6 +926,19 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra ->unique()->values()->all(); } +/** + * Traefik matches an IPv6 host without brackets: Host(`[2a01:4f8::1]`) never matches a request, + * Host(`2a01:4f8::1`) does. Labels that were saved with brackets get them removed here. + */ +function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection +{ + return $labels->map(fn ($label) => is_string($label) ? preg_replace_callback( + '/Host\(`\[([0-9A-Fa-f:.]+)\]`\)/', + fn (array $matches): string => filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "Host(`{$matches[1]}`)" : $matches[0], + $label, + ) : $label); +} + function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?int $http_basic_auth_bcrypt_cost = null) { $labels = collect([]); @@ -975,6 +992,8 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $url = Url::fromString($domain); $host = $url->getHost(); + // Traefik matches an IPv6 Host header without brackets: Host(`[2a01:4f8::1]`) never matches. + $traefikHost = trim($host, '[]'); $path = $url->getPath(); $schema = $url->getScheme(); $portlessDomain = ServiceApplication::withoutPort($domain); @@ -1019,7 +1038,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ ]; if ($schema === 'https') { // Set labels for https - $labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$https_label}.entryPoints=https"); if ($port) { $labels->push("traefik.http.routers.{$https_label}.service={$https_label}"); @@ -1091,7 +1110,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ $labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt"); // Set labels for http (redirect to https) - $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); @@ -1111,7 +1130,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } else { // Set labels for http - $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 765ec995e8..991023b621 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -196,18 +196,38 @@ function validateComposeArrayVolumeSource(string $source): void } /** - * Splits a top-level network `name:` that is one whole Compose variable (`${VAR}`, `${VAR:-default}` - * or `${VAR-default}`), such as an external network that differs per server. + * Matches one braced Compose variable: `${VAR}`, `${VAR:-default}`, `${VAR-default}`, + * `${VAR:?error}`, `${VAR?error}`, `${VAR:+value}` or `${VAR+value}`. + */ +const COMPOSE_NETWORK_VARIABLE_PATTERN = '\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?([-?+])([^}]*))?\}'; + +/** + * Splits a top-level network `name:` that is one whole Compose variable (see + * COMPOSE_NETWORK_VARIABLE_PATTERN), such as an external network that differs per server. Only the + * `-` operators give a default; the text after `?` is an error message and after `+` a value that + * Compose uses only when the variable is set. * * @return array{variable: string, default: ?string}|null */ function composeNetworkNameVariable(string $name): ?array { - if (preg_match('/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}\z/', $name, $matches) !== 1) { + if (preg_match('/\A'.COMPOSE_NETWORK_VARIABLE_PATTERN.'\z/', $name, $matches) !== 1) { return null; } - return ['variable' => $matches[1], 'default' => $matches[2] ?? null]; + return ['variable' => $matches[1], 'default' => ($matches[2] ?? '') === '-' ? $matches[3] : null]; +} + +/** + * Tells if the text after the operator of a Compose variable is safe. A default or alternative value + * becomes part of the network name; an error message only shows in the Compose error, so it can + * also contain spaces and simple punctuation. + */ +function isSafeComposeNetworkVariableOperand(string $operator, string $operand): bool +{ + $pattern = $operator === '?' ? '/\A[A-Za-z0-9 _.,:!-]*\z/' : '/\A[A-Za-z0-9_.-]*\z/'; + + return preg_match($pattern, $operand) === 1; } /** @@ -254,20 +274,19 @@ function validateComposeNetworkNameField(string $name): void } /** - * Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates. + * Allows $VAR and the braced variables of COMPOSE_NETWORK_VARIABLE_PATTERN with safe operands in a network value that Compose interpolates. */ function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void { $variable = composeNetworkNameVariable($name); - if ($variable !== null) { - $isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']); + if ($variable !== null && $variable['default'] !== null) { + $isValid = ValidationPatterns::isValidDockerNetwork($variable['default']); } else { $hasSafeDefaults = true; $withoutVariables = preg_replace_callback( - '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/', + '/'.COMPOSE_NETWORK_VARIABLE_PATTERN.'|\$[A-Za-z_][A-Za-z0-9_]*/', function (array $matches) use (&$hasSafeDefaults): string { - $default = $matches[2] ?? ''; - if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) { + if (! isSafeComposeNetworkVariableOperand($matches[2] ?? '', $matches[3] ?? '')) { $hasSafeDefaults = false; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 42abc2dcb0..137964d43c 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -72,6 +72,7 @@ use phpseclib3\Crypt\EC; use phpseclib3\Crypt\RSA; use Poliander\Cron\CronExpression; use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSResult; use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; @@ -1494,19 +1495,9 @@ function sslip(Server $server) if (isDev() && $server->id === 0) { return 'http://127.0.0.1.sslip.io'; } - if ($server->ip === 'host.docker.internal') { - $baseIp = base_ip(); + $ip = $server->ip === 'host.docker.internal' ? base_ip() : $server->ip; - return "http://$baseIp.sslip.io"; - } - // ipv6 - if (str($server->ip)->contains(':')) { - $ipv6 = str($server->ip)->replace(':', '-'); - - return "http://{$ipv6}.sslip.io"; - } - - return "http://{$server->ip}.sslip.io"; + return 'http://'.sslipHostLabel($ip).'.sslip.io'; } function service_templates_cache_key(): string @@ -2352,10 +2343,8 @@ function validateDNSEntry(string $fqdn, Server $server) $type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { - $query = createDnsQuery($dns_server); - $results = $query->query($host, $type); - if ($results === false || $query->hasError()) { - } else { + $results = queryDnsServer($dns_server, $host, $type); + if ($results !== false) { foreach ($results as $result) { if ($result->getType() == $type) { if (isCloudflareIp($result->getData())) { @@ -2376,15 +2365,40 @@ function validateDNSEntry(string $fqdn, Server $server) return $found_matching_ip; } -function createDnsQuery(string $dnsServer): DNSQuery +function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true): DNSQuery { return app()->make(DNSQuery::class, [ - 'server' => $dnsServer, + 'server' => formatHostForUrl($dnsServer), 'port' => 53, - 'timeout' => 5, + 'timeout' => $timeout, + 'udp' => $udp, ]); } +/** + * Query one DNS server for one record type. When the UDP answer is truncated (TC bit), + * retry over TCP: resolvers such as 1.1.1.1 can truncate even small answers, and the + * records would otherwise be silently dropped. + * + * @return iterable|false + */ +function queryDnsServer(string $dnsServer, string $host, string $type, int $timeout = 5): iterable|false +{ + $query = createDnsQuery($dnsServer, $timeout); + $records = $query->query($host, $type); + + if ($records === false && str_contains($query->getLasterror(), 'too big for UDP')) { + $query = createDnsQuery($dnsServer, $timeout, udp: false); + $records = $query->query($host, $type); + } + + if ($records === false || $query->hasError()) { + return false; + } + + return $records; +} + function isCloudflareIp(string $ip): bool { // https://www.cloudflare.com/ips/ @@ -2400,6 +2414,72 @@ function isCloudflareIp(string $ip): bool return ipMatch($ip, $cloudflareIps); } +/** + * The one stored form of an IP address: no spaces, no brackets, and IPv6 in its short lower-case + * form (2A01:04F8::0001 becomes 2a01:4f8::1). IPv4 addresses and hostnames stay as they are. + */ +function normalizeIpAddress(string $value): string +{ + $value = trim($value); + $withoutBrackets = trim($value, '[]'); + if (filter_var($withoutBrackets, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return inet_ntop(inet_pton($withoutBrackets)); + } + + return $value; +} + +/** + * The host for a URL or a "host:port" value: an IPv6 address gets brackets ([2a01:4f8::1]), so + * that its colons are not read as the port. Other hosts stay as they are. + */ +function formatHostForUrl(string $host): string +{ + $normalized = normalizeIpAddress($host); + + return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "[{$normalized}]" : $normalized; +} + +/** + * Hetzner gives an IPv6 network (2a01:4f8:c016:bd23::/64), not an address. The server uses the + * first address of the network (2a01:4f8:c016:bd23::1). + */ +function hetznerServerIpv6(?string $network): ?string +{ + if (blank($network)) { + return null; + } + $address = str($network)->before('/')->value(); + if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return null; + } + if (str_contains($network, '/')) { + $address = inet_ntop(inet_pton($address) | inet_pton('::1')); + } + + return normalizeIpAddress($address); +} + +/** + * The sslip.io label for an IP address. IPv6 colons become dashes, and a "0" is added where the + * address starts or ends with "::", because a DNS label cannot start or end with a dash. + */ +function sslipHostLabel(string $ip): string +{ + $ip = normalizeIpAddress($ip); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return $ip; + } + if (str_starts_with($ip, '::')) { + $ip = '0'.$ip; + } + if (str_ends_with($ip, '::')) { + $ip .= '0'; + } + + return str_replace(':', '-', $ip); +} + function ipMatch($ip, $cidrs, &$match = null) { foreach ((array) $cidrs as $cidr) { @@ -2508,8 +2588,8 @@ function checkIPAgainstAllowlist($ip, $allowlist) return true; } - // Direct IP comparison - if ($ip === $allowed) { + // Direct IP comparison; IPv6 can be written in more than one form + if (normalizeIpAddress((string) $ip) === normalizeIpAddress((string) $allowed)) { return true; } } @@ -4713,7 +4793,7 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array return null; } - if (preg_match('/^(?[A-Za-z0-9._-]+)@(?[^:]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { + if (preg_match('/^(?[A-Za-z0-9._-]+)@(?\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { return null; } diff --git a/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php new file mode 100644 index 0000000000..8563a607ad --- /dev/null +++ b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php @@ -0,0 +1,93 @@ +select(['id', 'team_id', 'ip', 'hetzner_server_id']) + ->where('ip', 'like', '%:%') + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + $this->normalizeServerIp($server); + } + }); + + DB::table('server_settings') + ->select(['id', 'sentinel_custom_url']) + ->where('sentinel_custom_url', 'like', '%://%:%:%') + ->orderBy('id') + ->chunkById(100, function ($settings): void { + foreach ($settings as $setting) { + $this->bracketSentinelUrlHost($setting); + } + }); + } + + public function down(): void + { + // + } + + private function normalizeServerIp(object $server): void + { + $ip = (string) $server->ip; + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + $newIp = normalizeIpAddress($ip); + // A Hetzner server that really uses ::64 is reachable; the broken network address is not. + $isReachable = (bool) DB::table('server_settings')->where('server_id', $server->id)->value('is_reachable'); + if (! is_null($server->hetzner_server_id) && str_ends_with($ip, '::64') && ! $isReachable) { + $newIp = hetznerServerIpv6(substr($ip, 0, -strlen('::64')).'::/64') ?? $newIp; + } + + if ($newIp === $ip) { + return; + } + + // Coolify does not allow the same server IP twice, in any team. + $duplicateExists = DB::table('servers') + ->whereNull('deleted_at') + ->where('id', '!=', $server->id) + ->where('ip', $newIp) + ->exists(); + if ($duplicateExists) { + Log::warning('Skipped normalizing a server IPv6 address because another server already uses it.', [ + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'ip' => $ip, + 'normalized_ip' => $newIp, + ]); + + return; + } + + DB::table('servers')->where('id', $server->id)->update(['ip' => $newIp]); + } + + private function bracketSentinelUrlHost(object $setting): void + { + if (preg_match('#\A(https?)://([0-9A-Fa-f:.]+):(\d+)(/.*)?\z#', (string) $setting->sentinel_custom_url, $matches) !== 1) { + return; + } + if (filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + DB::table('server_settings')->where('id', $setting->id)->update([ + 'sentinel_custom_url' => $matches[1].'://'.formatHostForUrl($matches[2]).':'.$matches[3].($matches[4] ?? ''), + ]); + } +}; diff --git a/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php new file mode 100644 index 0000000000..6b367cc59c --- /dev/null +++ b/database/migrations/2026_10_09_115444_remove_duplicate_redis_username_environment_variables.php @@ -0,0 +1,42 @@ +where('resourceable_type', StandaloneRedis::class) + ->where('key', 'REDIS_USERNAME') + ->groupBy('resourceable_id') + ->havingRaw('count(*) > 1') + ->pluck('resourceable_id'); + + foreach ($duplicatedDatabaseIds as $databaseId) { + $variables = DB::table('environment_variables') + ->where('resourceable_type', StandaloneRedis::class) + ->where('resourceable_id', $databaseId) + ->where('key', 'REDIS_USERNAME') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->pluck('id'); + + DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete(); + } + } + + public function down(): void + { + // + } +}; diff --git a/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php new file mode 100644 index 0000000000..d82fa896fb --- /dev/null +++ b/database/migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php @@ -0,0 +1,42 @@ +where('resourceable_type', Application::class) + ->where('key', 'NIXPACKS_NODE_VERSION') + ->select('resourceable_id', 'is_preview') + ->groupBy('resourceable_id', 'is_preview') + ->havingRaw('count(*) > 1') + ->get(); + + foreach ($duplicates as $duplicate) { + $variables = DB::table('environment_variables') + ->where('resourceable_type', Application::class) + ->where('resourceable_id', $duplicate->resourceable_id) + ->where('is_preview', $duplicate->is_preview) + ->where('key', 'NIXPACKS_NODE_VERSION') + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->pluck('id'); + + DB::table('environment_variables')->whereIn('id', $variables->slice(1)->all())->delete(); + } + } + + public function down(): void + { + // + } +}; diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 761d88fede..94f7c56efa 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -117,14 +117,14 @@ @else - {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }} @if (count($application->additional_servers) > 0) @foreach ($application->additional_servers as $server) - {{ $server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }} @endforeach @endif diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 13ae6277f4..361b7fc701 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -261,13 +261,13 @@ @if ($deploymentsIndicator) @endif -
+
diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index b3559970bb..1e1770a251 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -8,7 +8,7 @@ $userEmail = $user?->email ?? ''; $userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1)); @endphp -
$sidebar]) x-data="{ +
$sidebar]) x-data="{ open: false, appearanceOpen: false, avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null), @@ -23,10 +23,10 @@ @click.outside="closePanel()">
diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index 0a08cc0fa4..f2a874527e 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -211,9 +211,9 @@ class="relative block truncate text-[13px] font-semibold text-black hover:underline dark:text-fg" x-text="item.name">
-

- -

+

+ {{-- Low Disk Space View --}} + + {{-- Confirmation View --}} -