chore: merge main into release/v4.5 (#12140)

This commit is contained in:
Andras Bacsai
2026-10-12 21:40:08 +02:00
committed by GitHub
134 changed files with 3501 additions and 265 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+5 -3
View File
@@ -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}");
}
+36 -2
View File
@@ -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 = <<<EOF
user nginx;
worker_processes auto;
@@ -68,9 +71,10 @@ class StartDatabaseProxy
worker_connections 1024;
}
stream {
resolver 127.0.0.11 valid=10s;
server {
listen $database->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) {
+3 -1
View File
@@ -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}");
}
+3 -1
View File
@@ -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}");
}
+6 -4
View File
@@ -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}");
}
+5 -3
View File
@@ -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}");
}
+6 -4
View File
@@ -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}");
}
+6 -4
View File
@@ -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}");
}
+2 -4
View File
@@ -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) {
+3 -1
View File
@@ -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}");
}
+52 -7
View File
@@ -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();
+2 -8
View File
@@ -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;
}
@@ -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) {
@@ -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) {
+16 -11
View File
@@ -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]);
}
}
+45 -17
View File
@@ -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('<br>', ' ', $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
+11 -6
View File
@@ -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;
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.');
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+26 -18
View File
@@ -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()
{
@@ -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);
@@ -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)) {
+1 -1
View File
@@ -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
+1
View File
@@ -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.');
+6 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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();
+60 -4
View File
@@ -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);
}
+14 -5
View File
@@ -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]);
}
}
+6
View File
@@ -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') {
+21 -5
View File
@@ -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(
+2 -2
View File
@@ -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' ||
+1 -1
View File
@@ -151,7 +151,7 @@ class ServiceDatabase extends BaseModel
$realIp = base_ip();
}
return "{$realIp}:{$port}";
return formatHostForUrl((string) $realIp).":{$port}";
}
public function team()
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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';
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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'])) {
+2 -1
View File
@@ -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'])) {
+2 -1
View File
@@ -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'])) {
+5 -19
View File
@@ -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';
}
);
}
@@ -0,0 +1,121 @@
<?php
namespace App\Notifications\Server;
use App\Contracts\ThrottledNotification;
use App\Models\Server;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use App\Services\CoolifyUpgradeDiskSpace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Messages\MailMessage;
class UpgradeSkippedLowDiskSpace extends CustomEmailNotification implements ThrottledNotification
{
public int $requiredGb = CoolifyUpgradeDiskSpace::REQUIRED_GB;
public function __construct(public Server $server, public string $version, public float $availableGb)
{
$this->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.<br/><br/><b>Free:</b> {$this->availableGb} GB.<br/><b>Required:</b> {$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(),
];
}
}
+7
View File
@@ -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
+3 -5
View File
@@ -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));
}
+19 -9
View File
@@ -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;
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use App\Models\Server;
/**
* An upgrade pulls new images and recreates the Coolify containers. When the disk becomes full
* during an upgrade, Coolify can stop working (for example, an empty config cache file).
*/
class CoolifyUpgradeDiskSpace
{
public const REQUIRED_GB = 5;
public const UPGRADE_PATHS = ['/data/coolify'];
public const DEFAULT_DOCKER_ROOT_DIR = '/var/lib/docker';
/**
* Free space in GB on the Docker data directory and /data/coolify. The smaller value is returned.
* Returns null when the free space cannot be measured, so an unknown value never blocks an upgrade.
*/
public function availableGb(Server $server): ?float
{
$dockerRootDir = trim((string) instant_remote_process(
["docker info --format '{{.DockerRootDir}}' 2>/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;
}
}
+2 -1
View File
@@ -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'))) {
+5 -1
View File
@@ -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.
+6 -5
View File
@@ -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;
}
}
+4
View File
@@ -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,
]);
}
@@ -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) {
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Support;
use Illuminate\Filesystem\Filesystem;
use RuntimeException;
/**
* Laravel writes the config and route cache files in place: the file is empty until the write ends.
* PHP-FPM already serves requests while app:init caches them, and OPcache (validate_timestamps=0)
* keeps an empty file it read, so every request fails until a restart (#12120).
*/
class AtomicFilesystem extends Filesystem
{
/**
* Write to a temporary file in the same directory, then rename it over the target.
* Readers see the old or the new complete file. When the write fails, the old file stays.
*/
public function put($path, $contents, $lock = false)
{
$directory = dirname($path);
$tempPath = @tempnam($directory, basename($path));
// tempnam() falls back to the system temp directory, where rename() is not atomic.
if ($tempPath === false || dirname($tempPath) !== realpath($directory)) {
if ($tempPath !== false) {
@unlink($tempPath);
}
throw new RuntimeException("Could not create a temporary file to write [{$path}].");
}
$bytes = file_put_contents($tempPath, $contents);
if ($bytes !== strlen($contents)) {
@unlink($tempPath);
throw new RuntimeException("Could not write [{$path}].");
}
// tempnam() creates the file with 0600. Use the permissions file_put_contents() would give.
@chmod($tempPath, 0666 & ~umask());
if (! rename($tempPath, $path)) {
@unlink($tempPath);
throw new RuntimeException("Could not replace [{$path}].");
}
return $bytes;
}
}
+8
View File
@@ -58,6 +58,14 @@ class DomainFavicon
|| ! DnsRecordHints::isPublicAddress($embeddedIp);
}
// sslip.io writes an IPv6 address with dashes: fd00--1.sslip.io is fd00::1.
if (preg_match('/(?:^|\.)([0-9a-f]*-[0-9a-f-]*)\.(?:sslip|nip)\.io$/', $host, $matches) === 1) {
$embeddedIp = str_replace('-', ':', $matches[1]);
return filter_var($embeddedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
&& ! DnsRecordHints::isPublicAddress($embeddedIp);
}
return false;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ class SmtpTransportFactory
$mode = self::encryptionMode($settings);
$transport = new EsmtpTransport(
$settings->smtp_host,
formatHostForUrl((string) $settings->smtp_host),
(int) $settings->smtp_port,
match ($mode) {
'none' => false,
+21 -2
View File
@@ -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.
*/
@@ -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<int, string> $environmentVariables
*/
private function hasEnvironmentVariable(Collection $environmentVariables, string $key): bool
{
return $environmentVariables->contains(fn ($variable) => str($variable)->before('=')->value() === $key);
}
}
+5 -1
View File
@@ -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',
+9 -1
View File
@@ -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([
+1 -1
View File
@@ -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(),
+30 -11
View File
@@ -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");
+29 -10
View File
@@ -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;
}
+102 -22
View File
@@ -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<int, DNSResult>|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('/^(?<user>[A-Za-z0-9._-]+)@(?<host>[^:]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
return null;
}
@@ -0,0 +1,93 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
return new class extends Migration
{
/**
* Store every IPv6 server address in its short lower-case form, repair Hetzner servers whose
* IPv6 network (2a01:4f8:c016:bd23::/64) was saved as 2a01:4f8:c016:bd23::64, and add the
* missing brackets to Sentinel URLs that were saved as http://2a01:4f8::1:8000.
*/
public function up(): void
{
DB::table('servers')
->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] ?? ''),
]);
}
};
@@ -0,0 +1,42 @@
<?php
use App\Models\StandaloneRedis;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Redis databases created or cloned in the UI or API since v4.4.0 got two REDIS_USERNAME variables.
* Older Docker Compose versions (for example v2.22) refuse to start the container, and newer ones
* (v2.29 and later) use the last entry. Each start writes the value of one of them to all of them, so values differ only after
* an edit since the last start.
* Keep the most recently changed variable of each database, which is the latest edit of the user.
*/
public function up(): void
{
$duplicatedDatabaseIds = DB::table('environment_variables')
->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
{
//
}
};
@@ -0,0 +1,42 @@
<?php
use App\Models\Application;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Cloned Nixpacks applications got the default NIXPACKS_NODE_VERSION next to the copied one,
* for production and for preview. Keep the most recently changed variable of each application
* and preview flag: the latest edit of the user, else the copied value (it has the higher id).
*/
public function up(): void
{
$duplicates = DB::table('environment_variables')
->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
{
//
}
};
@@ -117,14 +117,14 @@
</a>
@else
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
<span class="min-w-0 truncate">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</span>
href="http://{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}">
<span class="min-w-0 truncate">{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}</span>
</a>
@if (count($application->additional_servers) > 0)
@foreach ($application->additional_servers as $server)
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
<span class="min-w-0 truncate">{{ $server->ip }}:{{ explode(':', $port)[0] }}</span>
href="http://{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}">
<span class="min-w-0 truncate">{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}</span>
</a>
@endforeach
@endif
+3 -3
View File
@@ -261,13 +261,13 @@
@if ($deploymentsIndicator)
<livewire:deployments-indicator variant="sidebar" />
@endif
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
<div class="flex items-center gap-1" :class="collapsed ? 'flex-col justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</div>
@@ -8,7 +8,7 @@
$userEmail = $user?->email ?? '';
$userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1));
@endphp
<div @class(['relative', 'min-w-0' => $sidebar]) x-data="{
<div @class(['relative', 'min-w-0 flex-1' => $sidebar]) x-data="{
open: false,
appearanceOpen: false,
avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null),
@@ -23,10 +23,10 @@
@click.outside="closePanel()">
<button type="button" @click="open ? closePanel() : openPanel()"
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
@if ($sidebar) :class="collapsed && 'w-8 justify-center px-0'" @endif
@if ($sidebar) :class="collapsed ? 'w-8 px-0' : 'w-full'" @endif
@class([
'flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]',
'max-w-36' => $sidebar,
'button rounded-lg px-2 normal-case',
'min-w-0' => $sidebar,
])>
<img x-cloak x-show="avatarUrl" :src="avatarUrl" alt="{{ $userName }}"
class="size-5 shrink-0 rounded-full object-cover">
@@ -35,7 +35,7 @@
{{ $userInitial }}
</span>
@if ($sidebar)
<span class="min-w-0 truncate text-xs font-medium" :class="collapsed && 'hidden'">{{ $userName }}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs font-medium" :class="collapsed && 'hidden'">{{ $userName }}</span>
@endif
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint transition-transform"
:class="[open && 'rotate-180', {{ $sidebar ? "collapsed && 'hidden'" : 'false' }}]"
@@ -88,8 +88,8 @@
:aria-pressed="autoCollapse" title="Collapse the sidebar on pages that have a settings menu">
<span class="flex items-center gap-2">
<svg class="size-4 opacity-80" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
Auto-collapse sidebar
</span>
@@ -0,0 +1,5 @@
<x-emails.layout>
Coolify did not update to {{ $version }} because the server does not have enough free disk space ({{ $available_gb }} GB free, {{ $required_gb }} GB required).
Free up disk space, for example with a [Docker cleanup]({{ $cleanup_url }}). The next automatic update will try again.
</x-emails.layout>
+2 -2
View File
@@ -173,8 +173,8 @@
class="-mr-1 flex size-9 items-center justify-center rounded-md text-neutral-500 transition-transform duration-100 ease-out hover:bg-neutral-100 hover:text-black active:scale-90 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span class="sr-only">Open sidebar</span>
<svg class="size-5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
<rect x="3" y="5" width="18" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
<path d="M16 9v6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</div>
@@ -211,9 +211,9 @@
class="relative block truncate text-[13px] font-semibold text-black hover:underline dark:text-fg"
x-text="item.name"></a>
</div>
<p class="min-h-4 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
<span x-show="item.description" x-text="item.description"></span>
</p>
<p class="truncate text-[11px] text-neutral-500 dark:text-fg-faint"
x-show="item.description?.trim()"
x-text="item.description"></p>
<div class="mobile-resource-domain min-w-0">
<template x-if="item.fqdn">
<a :href="firstDomain(item.fqdn)" target="_blank" rel="noopener noreferrer"
@@ -50,6 +50,13 @@
Create DNS records automatically when a new domain has one unambiguous matching credential.
</p>
</div>
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="edit-proxied-dns" label="Proxy DNS records through Cloudflare" fullWidth
wire:model.live="proxiedDns" canGate="update" :canResource="$integrationToken" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status.
</p>
</div>
@else
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
@@ -58,6 +58,13 @@
Create DNS records automatically when a new domain has one unambiguous matching credential.
</p>
</div>
<div class="rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="proxied-dns" label="Proxy DNS records through Cloudflare" fullWidth
wire:model.live="proxiedDns" canGate="create" :canResource="\App\Models\IntegrationToken::class" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Create new DNS records with the Cloudflare proxy on (orange cloud), so the server IP is not public. Existing records keep their proxy status.
</p>
</div>
@else
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
@@ -166,7 +166,7 @@
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
:show-sentinel-status="$showSentinelStatus" />
@if ($traefikDashboardAvailable)
<a target="_blank" href="http://{{ $serverIp }}:8080" title="Open Traefik dashboard"
<a target="_blank" href="http://{{ formatHostForUrl((string) $serverIp) }}:8080" title="Open Traefik dashboard"
class="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white">
<x-reicon name="external-link" class="size-3" />
Traefik
@@ -282,7 +282,7 @@
@if ($traefikDashboardAvailable)
<div class="resource-heading-menus shrink-0">
<a class="app-tab shrink-0 gap-1" target="_blank" title="Open Traefik dashboard"
href="http://{{ $serverIp }}:8080">
href="http://{{ formatHostForUrl((string) $serverIp) }}:8080">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Traefik Dashboard
</a>
@@ -3,6 +3,10 @@
{{ $github_app->name ?: 'GitHub App' }} | Sources | Coolify
</x-slot>
@if (session('error'))
<span x-data x-init="$wire.dispatch('error', @js(session('error')))" />
@endif
@if (data_get($github_app, 'app_id'))
@php
$githubAppRouteParameters = ['github_app_uuid' => $github_app->uuid];
+90 -6
View File
@@ -137,8 +137,53 @@
</div>
</template>
{{-- Low Disk Space View --}}
<template x-if="!showProgress && lowDiskSpace">
<div class="flex flex-col gap-4">
<x-callout type="danger" title="Not enough free disk space">
<p>
The server has <span class="font-semibold" x-text="availableGb + ' GB'"></span> free.
The upgrade needs at least <span class="font-semibold" x-text="requiredGb + ' GB'"></span>.
If the disk becomes full during the upgrade, Coolify can stop working.
</p>
</x-callout>
<p class="text-[12px] leading-5" style="color: var(--coollabs-subtle)">
Run a Docker cleanup to remove unused images, containers, and build cache, then check again.
</p>
<template x-if="confirmOverride">
<x-callout type="warning" title="Upgrade anyway?">
<p>Only continue if you are sure that the upgrade has enough disk space.</p>
</x-callout>
</template>
<div
class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<template x-if="!confirmOverride">
<x-forms.button @click="confirmOverride = true" type="button">
Upgrade anyway
</x-forms.button>
</template>
<template x-if="confirmOverride">
<x-forms.button @click="confirmed(true)" type="button"
x-bind:disabled="checkingDiskSpace">
Yes, upgrade anyway
</x-forms.button>
</template>
<x-forms.button @click="$wire.runDockerCleanup()" type="button">
Run Docker cleanup
</x-forms.button>
<x-forms.button @click="checkDiskSpaceAgain()" isHighlighted type="button"
x-bind:disabled="checkingDiskSpace">
<span x-text="checkingDiskSpace ? 'Checking…' : 'Check again'"></span>
</x-forms.button>
</div>
</div>
</template>
{{-- Confirmation View --}}
<template x-if="!showProgress">
<template x-if="!showProgress && !lowDiskSpace">
<div class="flex flex-col gap-4">
<x-callout type="warning" title="Caution">
<p>Any deployments running during the update process will fail.</p>
@@ -160,8 +205,9 @@
Simulate
</x-forms.button>
</template>
<x-forms.button @click="confirmed" isHighlighted type="button">
Upgrade now
<x-forms.button @click="confirmed()" isHighlighted type="button"
x-bind:disabled="checkingDiskSpace">
<span x-text="checkingDiskSpace ? 'Checking disk space…' : 'Upgrade now'"></span>
</x-forms.button>
</div>
</div>
@@ -202,6 +248,11 @@
instanceWentDown: false,
devMode: config.devMode || false,
simulationInterval: null,
lowDiskSpace: false,
availableGb: null,
requiredGb: null,
confirmOverride: false,
checkingDiskSpace: false,
simulateUpgrade() {
if (!this.devMode) return;
@@ -234,13 +285,30 @@
}, 2000);
},
confirmed() {
async confirmed(skipDiskSpaceCheck = false) {
if (this.checkingDiskSpace) return;
this.checkingDiskSpace = true;
let result;
try {
// Trigger server-side upgrade script via Livewire. It does not start when the disk is almost full.
result = await this.$wire.upgrade(skipDiskSpaceCheck);
} finally {
this.checkingDiskSpace = false;
}
if (result?.status === 'low_disk_space') {
this.lowDiskSpace = true;
this.availableGb = result.available_gb;
this.requiredGb = result.required_gb;
return;
}
if (result?.status !== 'started') return;
this.lowDiskSpace = false;
this.confirmOverride = false;
this.showProgress = true;
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.startTimer();
// Trigger server-side upgrade script via Livewire
this.$wire.$call('upgrade');
// Start client-side status polling
this.upgrade();
// Prevent accidental navigation during upgrade
@@ -251,6 +319,22 @@
window.addEventListener('beforeunload', this.beforeUnloadHandler);
},
async checkDiskSpaceAgain() {
if (this.checkingDiskSpace) return;
this.checkingDiskSpace = true;
try {
const result = await this.$wire.checkDiskSpace();
this.availableGb = result.available_gb;
this.requiredGb = result.required_gb;
this.lowDiskSpace = result.status === 'low_disk_space';
if (!this.lowDiskSpace) {
this.confirmOverride = false;
}
} finally {
this.checkingDiskSpace = false;
}
},
startTimer() {
this.startTime = Date.now();
this.elapsedInterval = setInterval(() => {
+20
View File
@@ -13,6 +13,8 @@ else
REGISTRY_URL="docker.io"
fi
SKIP_BACKUP=${4:-false}
SKIP_DISK_SPACE_CHECK=${5:-false}
REQUIRED_DISK_SPACE_GB=5
STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
@@ -53,6 +55,24 @@ echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE"
echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
# Stop before anything changes when the disk is almost full.
# A full disk during the upgrade can leave Coolify broken.
if [ "$SKIP_DISK_SPACE_CHECK" != "true" ]; then
DOCKER_ROOT_DIR=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)
AVAILABLE_KB=$(df -Pk /data/coolify "${DOCKER_ROOT_DIR:-/var/lib/docker}" 2>/dev/null | awk 'NR>1 && $4 ~ /^[0-9]+$/ {print $4}' | sort -n | head -n1)
if [ -n "$AVAILABLE_KB" ] && [ "$AVAILABLE_KB" -lt $((REQUIRED_DISK_SPACE_GB * 1024 * 1024)) ]; then
AVAILABLE_GB=$(awk -v kb="$AVAILABLE_KB" 'BEGIN {printf "%.1f", kb / 1024 / 1024}')
log "ERROR: Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required"
write_status "error" "Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required. Free up disk space and try again."
echo " ERROR: Not enough free disk space: ${AVAILABLE_GB} GB free, ${REQUIRED_DISK_SPACE_GB} GB required."
echo " Free up disk space (for example: docker system prune) and try again."
exit 1
fi
log "Disk space check passed (${AVAILABLE_KB:-unknown} KB free)"
else
log "Disk space check skipped"
fi
log_section "Step 1/6: Downloading configuration files"
write_status "1" "Downloading configuration files"
echo "1/6 Downloading latest configuration files..."
@@ -124,3 +124,20 @@ test('only returns domains for applications on the specified server', function (
expect($allDomains)->toContain('app-on-server.example.com');
expect($allDomains)->not->toContain('app-on-other-server.example.com');
});
test('returns IPv6 hosts with brackets and without the port', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Application::query()->whereKey($application->id)->update([
'fqdn' => 'http://[2a01:4f8::1]:8080,https://app.example.com:8443/api,http://192.0.2.10:3000',
]);
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains");
$response->assertOk();
expect($response->json('0.domains'))->toBe(['[2a01:4f8::1]', 'app.example.com', '192.0.2.10']);
});
+41
View File
@@ -669,6 +669,47 @@ describe('POST /api/v1/servers/hetzner', function () {
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('stores the server address of the Hetzner IPv6 network', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => null],
'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'],
],
],
], 201),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', [
'cloud_provider_token_id' => $this->hetznerToken->uuid,
'location' => 'nbg1',
'server_type' => 'cx11',
'image' => 15512617,
'private_key_uuid' => $this->privateKey->uuid,
'enable_ipv4' => false,
'enable_ipv6' => true,
]);
$response->assertStatus(201);
$response->assertJsonFragment(['ip' => '2a01:4f8:c016:bd23::1']);
$this->assertDatabaseHas('servers', [
'hetzner_server_id' => '456',
'ip' => '2a01:4f8:c016:bd23::1',
]);
});
test('rejects server creation when both public IP protocols are disabled', function () {
Http::fake();
+15
View File
@@ -14,6 +14,7 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Database\BackupMissing;
@@ -169,6 +170,20 @@ describe('POST /api/v1/databases/{uuid}/clone', function () {
->and(NotificationThrottle::wasSent($clonedBackup, BackupMissing::class))->toBeFalse();
});
test('clones a Redis database with one REDIS_USERNAME variable', function () {
$database = create_standalone_redis($this->environment->id, $this->destination, ['image' => 'redis:7.2']);
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$database->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
]);
$response->assertCreated();
$cloned = StandaloneRedis::where('uuid', $response->json('uuid'))->firstOrFail();
expect($cloned->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1)
->and($cloned->runtime_environment_variables()->where('key', 'REDIS_PASSWORD')->count())->toBe(1);
});
test('creates renamed volumes when cloning a database with clone_volumes', function () {
// Prevent remote stop/clone/start from running in unit tests.
Bus::fake();
@@ -133,12 +133,12 @@ it('does not query environment variables per Redis database in the environment d
}
});
it('creates a missing Redis username variable only once when the variables are loaded', function () {
it('reads a missing Redis username as default without creating a variable', function () {
$redis = create_standalone_redis($this->environment->id, $this->destination);
$redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->delete();
$redis->load('runtime_environment_variables');
expect($redis->redis_username)->toBe('default')
->and($redis->redis_username)->toBe('default')
->and($redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(1);
->and($redis->fresh()->redis_username)->toBe('default')
->and($redis->runtime_environment_variables()->where('key', 'REDIS_USERNAME')->count())->toBe(0);
});
@@ -932,3 +932,32 @@ describe('GitHub App name sync after the user switches the session team', functi
expect($this->githubApp->fresh()->name)->toBe('synced-app');
});
});
describe('GitHub webhook endpoint options for IPv6 instance addresses', function () {
test('brackets the public IPv6 address and keeps IPv4 unchanged', function (string $ipv6) {
config(['app.port' => 8000]);
InstanceSettings::findOrFail(0)->update([
'public_ipv4' => '203.0.113.10',
'public_ipv6' => $ipv6,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSet('ipv4', 'http://203.0.113.10:8000')
->assertSet('ipv6', 'http://[2a01:4f8::1]:8000');
})->with([
'raw' => '2a01:4f8::1',
'bracketed' => '[2a01:4f8::1]',
]);
});
+17
View File
@@ -3679,3 +3679,20 @@ it('does not queue file sync jobs when polling dns checks for compose applicatio
->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1)
->and($component->get('composeServices'))->toBe(['web', 'api']);
});
it('does not suggest a www counterpart for bare IP hosts', function (string $url) {
$wwwCounterpartUrl = new ReflectionMethod(Domains::class, 'wwwCounterpartUrl');
expect($wwwCounterpartUrl->invoke(new Domains, $url))->toBeNull()
->and($wwwCounterpartUrl->invoke(new Domains, $url, true))->toBeNull();
})->with([
'IPv4' => 'http://192.0.2.10:3000',
'IPv6' => 'http://[2a01:4f8::1]',
'IPv6 with port' => 'http://[2a01:4f8::1]:8080/app',
]);
it('still suggests a www counterpart for a hostname', function () {
$wwwCounterpartUrl = new ReflectionMethod(Domains::class, 'wwwCounterpartUrl');
expect($wwwCounterpartUrl->invoke(new Domains, 'https://example.com:8443/app'))->toBe('https://www.example.com:8443/app');
});
@@ -170,6 +170,28 @@ YAML);
expect($application->environment_variables()->where('key', 'SHARED_NETWORK')->exists())->toBeFalse();
});
test('required network name variables are created without their error message as value', function () {
$application = makeComposeApplicationWithNetworks(<<<'YAML'
shared:
external: true
name: ${SHARED_NETWORK?error}
other:
name: ${OTHER_NETWORK:+other-network}
plain:
name: plain-network
YAML);
$application->parse();
$variables = $application->environment_variables()->get()->keyBy('key');
expect($variables->has('SHARED_NETWORK'))->toBeTrue()
->and((string) $variables->get('SHARED_NETWORK')->value)->toBe('')
->and($variables->has('OTHER_NETWORK'))->toBeTrue()
->and((string) $variables->get('OTHER_NETWORK')->value)->toBe('')
->and($application->fresh()->docker_compose)->toContain('${SHARED_NETWORK?error}');
});
test('the deployment .env of a compose application contains the network name variable', function () {
$application = makeComposeApplicationWithNetworks(<<<'YAML'
shared:
+53
View File
@@ -0,0 +1,53 @@
<?php
use App\Support\AtomicFilesystem;
use Illuminate\Foundation\Console\ConfigCacheCommand;
use Illuminate\Foundation\Console\RouteCacheCommand;
beforeEach(function () {
$this->directory = sys_get_temp_dir().'/atomic-filesystem-'.bin2hex(random_bytes(4));
mkdir($this->directory);
});
afterEach(function () {
array_map('unlink', glob($this->directory.'/*'));
rmdir($this->directory);
});
it('writes the config and route caches with the atomic filesystem', function (string $command) {
$files = (fn () => $this->files)->call(app($command));
expect($files)->toBeInstanceOf(AtomicFilesystem::class);
})->with([
'config:cache' => ConfigCacheCommand::class,
'route:cache' => RouteCacheCommand::class,
]);
it('replaces the file with a new complete file and leaves no temporary file', function () {
$path = $this->directory.'/config.php';
file_put_contents($path, '<?php return [];');
$oldInode = fileinode($path);
$previousUmask = umask(0022);
try {
$bytes = (new AtomicFilesystem)->put($path, "<?php return ['app' => ['name' => 'Coolify']];");
} finally {
umask($previousUmask);
}
clearstatcache();
expect($bytes)->toBe(46)
->and(require $path)->toBe(['app' => ['name' => 'Coolify']])
->and(fileinode($path))->not->toBe($oldInode)
->and(fileperms($path) & 0777)->toBe(0644)
->and(glob($this->directory.'/*'))->toBe([$path]);
});
it('does not write the file outside its directory', function () {
$path = $this->directory.'/missing/config.php';
expect(fn () => (new AtomicFilesystem)->put($path, '<?php return [];'))
->toThrow(RuntimeException::class, 'Could not create a temporary file');
expect(file_exists($path))->toBeFalse();
});
@@ -0,0 +1,81 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('cloning a nixpacks application does not duplicate NIXPACKS_NODE_VERSION', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'build_pack' => 'nixpacks',
'redirect' => 'both',
]);
$application->settings->fill(['is_container_label_readonly_enabled' => false])->save();
$application->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->firstOrFail()->update(['value' => '20']);
$newApp = clone_application($application, $this->destination, [
'environment_id' => $this->environment->id,
]);
$production = $newApp->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->get();
$preview = $newApp->environment_variables_preview()->where('key', 'NIXPACKS_NODE_VERSION')->get();
expect($production)->toHaveCount(1)
->and($production->first()->value)->toBe('20')
->and($preview)->toHaveCount(1);
});
test('the migration keeps the most recently changed NIXPACKS_NODE_VERSION of each application and preview flag', function () {
$attributes = [
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'build_pack' => 'nixpacks',
];
$application = Application::factory()->create($attributes);
$other = Application::factory()->create($attributes);
// The rows an earlier clone_application() added next to the defaults of the created hooks.
$copies = collect([false, true])->map(fn (bool $isPreview) => EnvironmentVariable::query()->create([
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '20',
'is_preview' => $isPreview,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]));
EnvironmentVariable::query()->whereIn('id', $copies->pluck('id'))->update(['updated_at' => now()->addMinute()]);
$migration = require database_path('migrations/2026_10_09_124641_remove_duplicate_nixpacks_node_version_environment_variables.php');
$migration->up();
$rows = EnvironmentVariable::query()
->where('resourceable_type', Application::class)
->where('key', 'NIXPACKS_NODE_VERSION');
expect((clone $rows)->where('resourceable_id', $application->id)->pluck('id')->sort()->values()->all())->toBe($copies->pluck('id')->sort()->values()->all())
->and((clone $rows)->where('resourceable_id', $other->id)->count())->toBe(2);
});
+35 -2
View File
@@ -151,6 +151,10 @@ describe('owner resolution', function () {
// Deployed before mid-2024: compose project was the deployment directory, the compose service starts with the UUID.
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => $this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'other-instance-uuid-104512123456'], 'application'))->toBeNull()
// Compose application with a custom start command: the compose project is the deployment directory, the container name is `{service}-{uuid}-{suffix}`.
->and(resolveContainerOwner($applications, ['coolify.applicationId' => '999999', 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-'.$this->application->uuid.'-104512123456'], 'application')?->id)->toBe($this->application->id)
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'com.docker.compose.service' => 'public-api', 'coolify.name' => 'public-api-other-instance-uuid-104512123456'], 'application'))->toBeNull()
->and(resolveContainerOwner($applications, ['coolify.applicationId' => $localId, 'com.docker.compose.project' => 'deployment-dir', 'coolify.name' => 'public-api-'.$this->application->uuid.'x-104512123456'], 'application'))->toBeNull()
->and(implode("\n", dockerPsByOwnerCommands('application', 'app-uuid')))->not->toContain('label=coolify.applicationId=');
});
@@ -163,9 +167,9 @@ describe('owner resolution', function () {
->and($commands[1])->toContain("--filter 'label=coolify.applicationId'")
->toContain("--filter 'label=com.docker.compose.project=app-uuid'")
->toContain("--filter 'label=coolify.pullRequestId=0'")
// Deployed before mid-2024: the container name starts with the application UUID.
// Compose project is the deployment directory: the container name contains the application UUID.
->and($commands[2])->toContain("--filter 'label=coolify.applicationId'")
->toContain("--filter 'name=^app-uuid'")
->toContain("--filter 'name=app-uuid'")
->toContain("--filter 'label=coolify.pullRequestId=0'")
->and(dockerPsByOwnerCommands('service', 'service-uuid'))->toHaveCount(2);
});
@@ -291,6 +295,35 @@ describe('status updates after a server transfer', function () {
expect($this->application->fresh()->status)->toStartWith('running');
});
test('Sentinel push and status check match a 4.3 compose container deployed with a custom start command', function () {
$containerName = 'public-api-'.$this->application->uuid.'-104512123456';
$labels = [
'coolify.managed' => 'true',
'coolify.applicationId' => (string) $this->application->id,
'coolify.pullRequestId' => '0',
'coolify.name' => $containerName,
// Without --project-name, the compose project is the deployment directory.
'com.docker.compose.project' => 'r8wkc0gk0o4ws8gs4s8wwgs4',
'com.docker.compose.service' => 'public-api',
];
$job = new PushServerUpdateJob($this->server, ['containers' => [[
'name' => $containerName, 'state' => 'running', 'health_status' => 'healthy', 'labels' => $labels,
]]]);
$job->handle();
expect($this->application->fresh()->status)->toStartWith('running');
$this->application->update(['status' => 'exited']);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
GetContainersStatus::run($this->server->fresh(), collect([[
'Name' => '/'.$containerName,
'State' => ['Status' => 'running', 'Health' => ['Status' => 'healthy']],
'RestartCount' => 0,
'Config' => ['Labels' => $labels],
]]), collect());
expect($this->application->fresh()->status)->toStartWith('running');
});
test('container status check matches an older application container by compose project', function () {
$container = [
'Name' => '/'.$this->application->uuid,
@@ -0,0 +1,99 @@
<?php
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\DatabaseStartCommandExecutor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'cache.default' => 'array', 'constants.ssh.mux_enabled' => false]);
// The queued ServerStorageSaveJob does not run, as when it runs after the containers start.
Queue::fake();
// Every path on the server is missing, and every path is inside the resource directory.
Process::fake(function ($process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
if (str_contains($command, 'empty-directory')) {
return Process::result(output: collect(range(1, substr_count($command, '/ssl/server.')))->map(fn (int $index) => "{$index}:missing")->implode("\n"));
}
if (str_contains($command, 'readlink -f')) {
return Process::result(output: 'OK');
}
return Process::result(output: str_contains($command, 'test -') ? 'NOK' : '');
});
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->executor = new class
{
public array $commands = [];
public function execute(array $commands, $database, Activity $activity): Activity
{
$this->commands = $commands;
return $activity;
}
};
app()->instance(DatabaseStartCommandExecutor::class, $this->executor);
});
dataset('ssl-database-start-actions-with-certificate-files', [
'postgresql' => ['create_standalone_postgresql', StartPostgresql::class, ['server.crt', 'server.key']],
'mysql' => ['create_standalone_mysql', StartMysql::class, ['server.crt', 'server.key']],
'mariadb' => ['create_standalone_mariadb', StartMariadb::class, ['server.crt', 'server.key']],
'mongodb' => ['create_standalone_mongodb', StartMongodb::class, ['server.pem']],
'redis' => ['create_standalone_redis', StartRedis::class, ['server.crt', 'server.key']],
'keydb' => ['create_standalone_keydb', StartKeydb::class, ['server.crt', 'server.key']],
'dragonfly' => ['create_standalone_dragonfly', StartDragonfly::class, ['server.crt', 'server.key']],
]);
it('writes a new SSL certificate file on the server before the container starts', function (string $createDatabase, string $startAction, array $files) {
$database = $createDatabase($this->environment->id, $this->destination);
$database->update(['enable_ssl' => true]);
$startAction::run($database->fresh(), new Activity);
foreach ($files as $file) {
$path = escapeshellarg($database->workdir()."/ssl/{$file}");
Process::assertRan(fn ($process) => str_contains($process->command, "base64 -d | tee {$path}"));
}
$commands = implode("\n", $this->executor->commands);
expect($commands)->toContain('missing configuration file')
->and(strpos($commands, 'missing configuration file'))->toBeLessThan(strpos($commands, 'docker-compose.yml up -d'));
})->with('ssl-database-start-actions-with-certificate-files');
it('writes the MongoDB server.pem before the certificate owner change mounts it', function () {
$database = create_standalone_mongodb($this->environment->id, $this->destination);
$database->update(['enable_ssl' => true]);
StartMongodb::run($database->fresh(), new Activity);
$commands = implode("\n", $this->executor->commands);
expect($commands)->toContain('Writing 1 missing configuration file.')
->and(strpos($commands, 'Writing 1 missing configuration file.'))->toBeLessThan(strpos($commands, '--entrypoint chown'));
});
@@ -0,0 +1,108 @@
<?php
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\DatabaseStartCommandExecutor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Process::fake();
Queue::fake();
Server::flushIdentityMap();
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->executor = new class
{
public array $commands = [];
public function execute(array $commands, $database, Activity $activity): Activity
{
$this->commands = $commands;
return $activity;
}
};
app()->instance(DatabaseStartCommandExecutor::class, $this->executor);
});
/**
* @return array<int, string>
*/
function databaseStartComposeEnvironment(object $executor, string $uuid): array
{
foreach ($executor->commands as $command) {
if (preg_match("#^echo '([^']+)' \| base64 -d \| tee \S+/docker-compose\.yml#", $command, $matches)) {
return Yaml::parse(base64_decode($matches[1]))['services'][$uuid]['environment'];
}
}
throw new RuntimeException('No docker-compose.yml was written.');
}
it('adds the Postgres defaults when user keys or values only contain the default key name', function () {
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['postgres_password' => 'model-secret', 'postgres_db' => 'appdb']);
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD_FILE', 'value' => '/x']);
$database->runtime_environment_variables()->create(['key' => 'NOTE', 'value' => 'uses POSTGRES_DB and POSTGRES_USER']);
StartPostgresql::run($database->fresh(), new Activity);
expect(databaseStartComposeEnvironment($this->executor, $database->uuid))
->toContain('POSTGRES_PASSWORD_FILE=/x')
->toContain('POSTGRES_PASSWORD=model-secret')
->toContain('POSTGRES_DB=appdb')
->toContain('POSTGRES_USER=postgres');
});
it('does not add a Postgres default when the user sets the exact key', function () {
$database = create_standalone_postgresql($this->environment->id, $this->destination, ['postgres_db' => 'appdb']);
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_DB', 'value' => 'customdb']);
StartPostgresql::run($database->fresh(), new Activity);
$postgresDbEntries = collect(databaseStartComposeEnvironment($this->executor, $database->uuid))
->filter(fn (string $entry) => str($entry)->startsWith('POSTGRES_DB='))
->values()
->all();
expect($postgresDbEntries)->toBe(['POSTGRES_DB=customdb']);
});
it('adds the password default when the user only sets a *_FILE variant', function (string $action, string $factory, array $data, string $userKey, string $expected) {
$database = $factory($this->environment->id, $this->destination, $data);
$database->runtime_environment_variables()->create(['key' => $userKey, 'value' => '/run/secrets/x']);
$action::run($database->fresh(), new Activity);
expect(databaseStartComposeEnvironment($this->executor, $database->uuid))
->toContain("{$userKey}=/run/secrets/x")
->toContain($expected);
})->with([
'mysql' => [StartMysql::class, 'create_standalone_mysql', ['mysql_root_password' => 'root-secret'], 'MYSQL_ROOT_PASSWORD_FILE', 'MYSQL_ROOT_PASSWORD=root-secret'],
'mariadb' => [StartMariadb::class, 'create_standalone_mariadb', ['mariadb_root_password' => 'root-secret'], 'MARIADB_ROOT_PASSWORD_FILE', 'MARIADB_ROOT_PASSWORD=root-secret'],
'mongodb' => [StartMongodb::class, 'create_standalone_mongodb', ['mongo_initdb_root_password' => 'root-secret'], 'MONGO_INITDB_ROOT_PASSWORD_FILE', 'MONGO_INITDB_ROOT_PASSWORD=root-secret'],
'clickhouse' => [StartClickhouse::class, 'create_standalone_clickhouse', ['clickhouse_admin_password' => 'root-secret'], 'CLICKHOUSE_PASSWORD_FILE', 'CLICKHOUSE_PASSWORD=root-secret'],
]);
@@ -120,6 +120,23 @@ test('a cloudflare record is created and tracked as managed by coolify', functio
&& $request->data()['comment'] === $record->ownershipComment());
});
test('cloudflare records are created proxied only when the credential enables proxy mode', function (?array $metadata, bool $proxied) {
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret', 'metadata' => $metadata]);
$zone = DnsProviderZone::factory()->for($token)->create(['provider_zone_id' => 'zone-1', 'name' => 'example.com']);
Http::fake([
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records?*' => Http::response(['success' => true, 'result' => []]),
'https://api.cloudflare.com/client/v4/zones/zone-1/dns_records' => Http::response(['success' => true, 'result' => ['id' => 'record-1']]),
]);
app(CloudflareDnsProvider::class)->createRecord($zone, 'app.example.com', '203.0.113.10');
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->data()['proxied'] === $proxied);
})->with([
'default' => [null, false],
'enabled' => [['proxied_dns' => true], true],
]);
test('queued dns configuration creates the record and broadcasts completion', function () {
Event::fake([DnsRecordConfigurationFinished::class]);
$token = IntegrationToken::factory()->create(['provider' => 'cloudflare', 'token' => 'secret']);
+40
View File
@@ -267,3 +267,43 @@ it('validateDNSEntry treats equivalent IPv6 notations as the same address', func
expect(validateDNSEntry('https://example.com', $server))->toBeTrue();
});
it('passes bracketed IPv6 custom dns servers to the dns query', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(
['id' => 0],
[
'is_dns_validation_enabled' => true,
'custom_dns_servers' => '2606:4700:4700::1111,[2001:db8::53],192.0.2.1',
]
));
$queriedServers = new ArrayObject;
app()->bind(DNSQuery::class, function ($app, array $parameters) use ($queriedServers) {
$queriedServers->append($parameters['server']);
return new class('192.0.2.1') extends DNSQuery
{
public function query(string $question, string $typeName = DNSTypes::NAME_A): false
{
return false;
}
public function hasError(): bool
{
return true;
}
};
});
$action = new class extends CheckDomainDns
{
protected function resolveWithSystemDns(string $host, string $type): array
{
return [];
}
};
$action->handle(['example' => 'https://example.com'], new Server(['ip' => '203.0.113.10']), '203.0.113.10');
expect($queriedServers->getArrayCopy())->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']);
});
@@ -174,6 +174,53 @@ describe('PATCH /api/v1/applications/{uuid}/envs/bulk', function () {
$response->assertStatus(422);
});
test('keeps one row per key and preview flag when the same key is sent more than once', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [
['key' => 'DUPLICATED', 'value' => 'first'],
['key' => 'DUPLICATED', 'value' => 'second'],
['key' => 'PREVIEW_ONLY', 'value' => 'preview-b', 'is_preview' => true],
['key' => 'SHARED', 'value' => 'production-a'],
['key' => 'SHARED', 'value' => 'preview-a', 'is_preview' => true],
],
]);
$response->assertStatus(201);
$rows = EnvironmentVariable::where('resourceable_type', Application::class)
->where('resourceable_id', $application->id)
->whereIn('key', ['DUPLICATED', 'PREVIEW_ONLY', 'SHARED'])
->get();
$counts = $rows->countBy(fn ($env) => $env->key.'|'.($env->is_preview ? 'preview' : 'production'))->all();
ksort($counts);
expect($counts)->toBe([
'DUPLICATED|preview' => 1,
'DUPLICATED|production' => 1,
'PREVIEW_ONLY|preview' => 1,
'SHARED|preview' => 1,
'SHARED|production' => 1,
]);
$valueOf = fn (string $key, bool $isPreview) => $rows
->first(fn ($env) => $env->key === $key && $env->is_preview === $isPreview)
->value;
expect($valueOf('DUPLICATED', false))->toBe('second')
->and($valueOf('SHARED', false))->toBe('production-a')
->and($valueOf('SHARED', true))->toBe('preview-a');
});
});
describe('PATCH /api/v1/services/{uuid}/envs/bulk', function () {
@@ -96,3 +96,22 @@ describe('GitLab source setup view', function () {
->toBe('http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
});
});
describe('GitLab webhook endpoint options for IPv6 instance addresses', function () {
test('brackets the public IPv6 address and keeps IPv4 unchanged', function (string $ipv6) {
config(['app.port' => 8000]);
InstanceSettings::findOrFail(0)->update([
'public_ipv4' => '203.0.113.10',
'public_ipv6' => $ipv6,
]);
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSet('ipv4', 'http://203.0.113.10:8000')
->assertSet('ipv6', 'http://[2a01:4f8::1]:8000');
})->with([
'raw' => '2a01:4f8::1',
'bracketed' => '[2a01:4f8::1]',
]);
});

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