From f39c884cc799fa1908813e82212d3b87a463d7f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:50:28 +0200 Subject: [PATCH] fix(servers): support IPv6 server addresses everywhere Store server IPs in one form (no brackets, short lower-case IPv6) and write each address in the form its context needs: - URLs and host:port (Sentinel, database URLs, SFTP backup download, DNS servers, SMTP, webhook base URLs, links, health checks) get brackets through formatHostForUrl(). - Traefik Host() rules use the address without brackets, so IPv6 domains no longer return 404; saved labels are fixed at deploy. Caddy keeps the brackets. - SSH keeps the plain address. - Hetzner gives an IPv6 /64 network; IPv6-only servers now get the server address (prefix::1) instead of the unusable prefix::64. - sslip labels, registry image names with an IPv6 host, the API domain list, allowlist matching and duplicate checks handle IPv6. A migration normalizes stored IPv6 server addresses, repairs Hetzner servers saved as ::64 that are not reachable, and adds brackets to saved Sentinel URLs. --- app/Actions/Shared/CheckDomainDns.php | 2 +- .../Controllers/Api/HetznerController.php | 2 +- .../Controllers/Api/ServersController.php | 27 +- app/Jobs/ApplicationDeploymentJob.php | 13 +- app/Livewire/Boarding/Index.php | 2 +- app/Livewire/Project/Application/Domains.php | 2 +- app/Livewire/Project/Service/Domains.php | 2 +- app/Livewire/Project/Shared/HealthChecks.php | 44 ++-- app/Livewire/Server/New/ByHetzner.php | 2 +- app/Livewire/Server/New/ByIp.php | 1 + app/Livewire/Server/Show.php | 9 +- app/Livewire/Source/Github/Change.php | 2 +- app/Livewire/Source/Gitlab/Change.php | 2 +- app/Mcp/Tools/GetServerDomains.php | 19 +- app/Models/Server.php | 26 +- app/Models/ServerSetting.php | 4 +- app/Models/ServiceDatabase.php | 2 +- app/Models/StandaloneClickhouse.php | 3 +- app/Models/StandaloneDragonfly.php | 3 +- app/Models/StandaloneKeydb.php | 3 +- app/Models/StandaloneMariadb.php | 3 +- app/Models/StandaloneMongodb.php | 3 +- app/Models/StandaloneMysql.php | 3 +- app/Models/StandalonePostgresql.php | 3 +- app/Models/StandaloneRedis.php | 3 +- app/Rules/SafeWebhookUrl.php | 5 +- app/Rules/ValidServerIp.php | 8 +- app/Services/ConfigurationRepository.php | 2 +- app/Services/DockerRegistryLogins.php | 6 +- app/Services/HetznerService.php | 11 +- .../ServerTransfer/ServerTransferImporter.php | 2 +- app/Support/DomainFavicon.php | 8 + app/Support/SmtpTransportFactory.php | 2 +- app/Support/ValidationPatterns.php | 23 +- bootstrap/helpers/api.php | 6 +- bootstrap/helpers/databases.php | 2 +- bootstrap/helpers/docker.php | 21 +- bootstrap/helpers/shared.php | 86 ++++-- ...10_09_092903_normalize_ipv6_server_ips.php | 93 +++++++ .../components/applications/links.blade.php | 8 +- .../views/livewire/server/navbar.blade.php | 4 +- tests/Feature/Api/DomainsByServerApiTest.php | 17 ++ tests/Feature/Api/HetznerApiTest.php | 41 +++ .../Application/GithubSourceChangeTest.php | 29 +++ tests/Feature/ApplicationDomainsTest.php | 17 ++ tests/Feature/DnsValidationTest.php | 40 +++ tests/Feature/GitlabSourceChangeViewTest.php | 19 ++ tests/Feature/Ipv6ServerAddressUrlTest.php | 125 +++++++++ .../ConfigurationChangeRefreshEventsTest.php | 16 ++ tests/Feature/Mcp/McpReadToolsTest.php | 12 + .../NormalizeIpv6ServerIpsMigrationTest.php | 127 +++++++++ .../Server/ServerIpv6NormalizationTest.php | 245 ++++++++++++++++++ tests/Feature/ServiceDomainsTest.php | 17 ++ tests/Unit/BackupDownloadHelperTest.php | 17 ++ .../ConfigurationRepositoryMailConfigTest.php | 23 ++ .../DockerImageReferenceValidationTest.php | 8 + tests/Unit/DockerRegistryLoginsTest.php | 30 +++ tests/Unit/DomainFaviconTest.php | 8 + .../Unit/HealthCheckCommandInjectionTest.php | 51 ++++ tests/Unit/HetznerServiceTest.php | 55 ++++ tests/Unit/IpAddressFormattingTest.php | 42 +++ tests/Unit/SafeWebhookUrlTest.php | 36 +++ tests/Unit/SmtpTransportFactoryTest.php | 15 ++ tests/Unit/TraefikIpv6HostRuleTest.php | 76 ++++++ tests/Unit/ValidServerIpTest.php | 27 ++ 65 files changed, 1458 insertions(+), 107 deletions(-) create mode 100644 database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php create mode 100644 tests/Feature/Ipv6ServerAddressUrlTest.php create mode 100644 tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php create mode 100644 tests/Feature/Server/ServerIpv6NormalizationTest.php create mode 100644 tests/Unit/IpAddressFormattingTest.php create mode 100644 tests/Unit/TraefikIpv6HostRuleTest.php diff --git a/app/Actions/Shared/CheckDomainDns.php b/app/Actions/Shared/CheckDomainDns.php index d52ba50142..e8a8f5a265 100644 --- a/app/Actions/Shared/CheckDomainDns.php +++ b/app/Actions/Shared/CheckDomainDns.php @@ -77,7 +77,7 @@ class CheckDomainDns try { $query = app()->make(DNSQuery::class, [ - 'server' => $dnsServer, + 'server' => formatHostForUrl($dnsServer), 'port' => 53, 'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)), ]); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 7c2cbd8ace..d432c90811 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -932,7 +932,7 @@ class HetznerController extends Controller if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } if (! $ipAddress) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 5fa1801a5e..69622d8255 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -358,11 +358,7 @@ class ServersController extends Controller if ($applications->count() > 0) { foreach ($applications as $application) { $ip = $application->destination->server->ip; - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); @@ -401,11 +397,7 @@ class ServersController extends Controller $service_applications = $service->applications; if ($service_applications->count() > 0) { foreach ($service_applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(function (Stringable $fqdn) { + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(function (Stringable $fqdn) { return $fqdn->isNotEmpty(); }); $serviceIp = $server->ip; @@ -597,7 +589,7 @@ class ServersController extends Controller if (! $privateKey) { return response()->json(['message' => 'Private key not found.'], 404); } - $foundServer = ModelsServer::whereIp($request->ip)->first(); + $foundServer = ModelsServer::whereIp(ModelsServer::normalizeIp($request->ip))->first(); if ($foundServer) { return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } @@ -1125,4 +1117,17 @@ class ServersController extends Controller return response()->json(['message' => $message], 201); } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 4077d311c9..54d0c8f533 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1323,7 +1323,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, ], ); @@ -1332,12 +1332,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, ], @@ -1543,7 +1543,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, ]); @@ -3926,6 +3926,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); @@ -4227,7 +4228,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); - $host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost'); + $host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host) + ? formatHostForUrl($this->application->health_check_host) + : 'localhost'; $path = $this->application->health_check_path ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/') : null; diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ca305905a3..50bad00678 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -302,7 +302,7 @@ class Index extends Component $this->validate(); - $foundServer = Server::whereIp($this->remoteServerHost)->first(); + $foundServer = Server::whereIp(Server::normalizeIp($this->remoteServerHost))->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); } diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index f1f897b855..fe7f99aa3c 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -2064,7 +2064,7 @@ class Domains extends Component $lowerHost = strtolower($host); // Always skip bare IPs and localhost. - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 7452a13f1a..7349b63c27 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -1780,7 +1780,7 @@ class Domains extends Component $lowerHost = strtolower($host); - if (filter_var($host, FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { + if (filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false || $lowerHost === 'localhost') { return null; } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 70633fe030..3d9e2431ac 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,6 +2,8 @@ namespace App\Livewire\Project\Shared; +use App\Support\ValidationPatterns; +use Closure; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -28,7 +30,6 @@ class HealthChecks extends Component #[Validate(['required', 'string', 'in:http,https'])] public string $healthCheckScheme; - #[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])] public string $healthCheckHost; #[Validate(['nullable', 'integer', 'min:1', 'max:65535'])] @@ -58,23 +59,30 @@ class HealthChecks extends Component #[Validate(['boolean'])] public bool $customHealthcheckFound = false; - protected $rules = [ - 'healthCheckEnabled' => 'boolean', - 'healthCheckType' => 'string|in:http,cmd', - 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], - 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], - 'healthCheckPort' => 'nullable|integer|min:1|max:65535', - 'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], - 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', - 'healthCheckReturnCode' => 'integer', - 'healthCheckScheme' => 'required|string|in:http,https', - 'healthCheckResponseText' => 'nullable|string', - 'healthCheckInterval' => 'integer|min:1', - 'healthCheckTimeout' => 'integer|min:1', - 'healthCheckRetries' => 'integer|min:1', - 'healthCheckStartPeriod' => 'integer', - 'customHealthcheckFound' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'healthCheckEnabled' => 'boolean', + 'healthCheckType' => 'string|in:http,cmd', + 'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], + 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], + 'healthCheckPort' => 'nullable|integer|min:1|max:65535', + 'healthCheckHost' => ['required', 'string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname, an IPv4 address, or an IPv6 address.'); + } + }], + 'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS', + 'healthCheckReturnCode' => 'integer', + 'healthCheckScheme' => 'required|string|in:http,https', + 'healthCheckResponseText' => 'nullable|string', + 'healthCheckInterval' => 'integer|min:1', + 'healthCheckTimeout' => 'integer|min:1', + 'healthCheckRetries' => 'integer|min:1', + 'healthCheckStartPeriod' => 'integer', + 'customHealthcheckFound' => 'boolean', + ]; + } public function mount() { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index aadcb7b0c1..868b9e44b3 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -721,7 +721,7 @@ class ByHetzner extends Component if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { $ipAddress = $hetznerServer['public_net']['ipv4']['ip']; } elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) { - $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; + $ipAddress = hetznerServerIpv6($hetznerServer['public_net']['ipv6']['ip']); } // Create server in Coolify database immediately so the Hetzner diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 6d35d73df8..d3bb69ae64 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -143,6 +143,7 @@ class ByIp extends Component $this->validate(); try { $this->authorize('create', Server::class); + $this->ip = Server::normalizeIp($this->ip); $foundServer = Server::whereIp($this->ip)->first(); if ($foundServer) { return $this->dispatch('error', 'A server with this IP/Domain already exists.'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 8357e89257..95be378cfa 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -231,9 +231,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.'); @@ -586,7 +589,7 @@ class Show extends Component $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } - $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($serverData, 'public_net.ipv6.ip')); if ($this->server->backfillPlaceholderIp($assignedIp)) { $this->ip = $this->server->ip; } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index a5f60fc0e8..feba6f3bea 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -325,7 +325,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } if ($this->github_app->installation_id && session('from')) { $source_id = data_get(session('from'), 'source_id'); diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php index a0e2049e01..128374efc1 100644 --- a/app/Livewire/Source/Gitlab/Change.php +++ b/app/Livewire/Source/Gitlab/Change.php @@ -156,7 +156,7 @@ class Change extends Component $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); } if ($settings->public_ipv6) { - $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + $this->ipv6 = 'http://'.formatHostForUrl($settings->public_ipv6).':'.config('app.port'); } $this->parameters = get_route_parameters(); diff --git a/app/Mcp/Tools/GetServerDomains.php b/app/Mcp/Tools/GetServerDomains.php index 7780f59715..9378549220 100644 --- a/app/Mcp/Tools/GetServerDomains.php +++ b/app/Mcp/Tools/GetServerDomains.php @@ -62,11 +62,7 @@ class GetServerDomains extends Tool $domains = collect(); foreach ($applications as $application) { - $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { - $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); - - return str(str($f[0])->explode(':')[0]); - })->filter(fn (Stringable $f) => $f->isNotEmpty()); + $fqdn = str($application->fqdn)->explode(',')->map(fn ($fqdn) => self::domainHostWithoutPort((string) $fqdn))->filter(fn (Stringable $f) => $f->isNotEmpty()); if ($fqdn->isNotEmpty()) { $domains->push([ @@ -90,4 +86,17 @@ class GetServerDomains extends Tool 'uuid' => $schema->string()->description('Server UUID.')->required(), ]; } + + /** + * The host of a domain without the port: "[2a01:4f8::1]:8080" gives "[2a01:4f8::1]", "example.com:3000" gives "example.com". + */ + private static function domainHostWithoutPort(string $fqdn): Stringable + { + $authority = (string) str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/')[0]; + if (str_starts_with($authority, '[') && str_contains($authority, ']')) { + return str(substr($authority, 0, strpos($authority, ']') + 1)); + } + + return str(str($authority)->explode(':')[0]); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 38068b17f6..8bbbc4a439 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -384,6 +384,7 @@ class Server extends BaseModel */ public function backfillPlaceholderIp(?string $ip): bool { + $ip = blank($ip) ? null : self::normalizeIp($ip); if (self::isPlaceholderIp($ip)) { return false; } @@ -434,7 +435,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) { @@ -725,7 +726,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' => [ @@ -914,6 +915,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) { @@ -1523,12 +1532,19 @@ $siteAddress { get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); }, - set: function ($value) { - return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); - } + set: fn ($value) => self::normalizeIp($value), ); } + /** + * The form a server IP is stored in: unsafe characters removed (including IPv6 brackets) and + * IPv6 addresses in their short lower-case form (2A01:04F8::0001 -> 2a01:4f8::1). + */ + public static function normalizeIp(?string $value): string + { + return normalizeIpAddress(preg_replace('/[^0-9a-zA-Z.:%-]/', '', (string) $value)); + } + public function getIp(): Attribute { return Attribute::make( diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c7b28fb4ef..8aa3dedf6b 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -330,7 +330,7 @@ class ServerSetting extends Model return match (true) { filled($fqdn) => $fqdn, filled($publicIpv4) => 'http://'.$publicIpv4.':8000', - filled($publicIpv6) => 'http://'.$publicIpv6.':8000', + filled($publicIpv6) => 'http://'.formatHostForUrl($publicIpv6).':8000', default => null, }; } @@ -392,7 +392,7 @@ class ServerSetting extends Model } $request = request(); - $host = strtolower($request->getHost()); + $host = normalizeIpAddress(strtolower($request->getHost())); if ( $host === 'localhost' || diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 959c3a69c5..33f43561ec 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -151,7 +151,7 @@ class ServiceDatabase extends BaseModel $realIp = base_ip(); } - return "{$realIp}:{$port}"; + return formatHostForUrl((string) $realIp).":{$port}"; } public function team() diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 8c15e6853b..24455ffdd0 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -379,11 +379,12 @@ class StandaloneClickhouse extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->clickhouse_admin_user); $encodedPass = rawurlencode($this->clickhouse_admin_password); $database = $this->clickhouse_db ?? 'default'; - return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}"; + return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$database}"; } return null; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 98f9ff5afc..2f03815b25 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -333,9 +333,10 @@ class StandaloneDragonfly extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index bc1e045335..ca2c4174c1 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -334,9 +334,10 @@ class StandaloneKeydb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $scheme = $this->enable_ssl ? 'rediss' : 'redis'; $encodedPass = rawurlencode($this->connectionPassword()); - $url = "{$scheme}://:{$encodedPass}@{$serverIp}:{$this->public_port}/0"; + $url = "{$scheme}://:{$encodedPass}@{$serverHost}:{$this->public_port}/0"; if ($this->enable_ssl && $this->ssl_mode === 'verify-ca') { $url .= '?cacert=/etc/ssl/certs/coolify-ca.crt'; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index c923b489bd..05aac1b42d 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -321,10 +321,11 @@ class StandaloneMariadb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mariadb_user); $encodedPass = rawurlencode($this->mariadb_password); - return "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mariadb_database}"; + return "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mariadb_database}"; } return null; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 70b108087a..a273132871 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -354,9 +354,10 @@ class StandaloneMongodb extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mongo_initdb_root_username); $encodedPass = rawurlencode($this->mongo_initdb_root_password); - $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/?directConnection=true"; + $url = "mongodb://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/?directConnection=true"; if ($this->enable_ssl) { $url .= '&tls=true&tlsCAFile=/etc/mongo/certs/ca.pem'; if (in_array($this->ssl_mode, ['verify-full'])) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 6a08a4dc45..7475421ba1 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -335,9 +335,10 @@ class StandaloneMysql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->mysql_user); $encodedPass = rawurlencode($this->mysql_password); - $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->mysql_database}"; + $url = "mysql://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->mysql_database}"; if ($this->enable_ssl) { $url .= "?ssl-mode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['VERIFY_CA', 'VERIFY_IDENTITY'])) { diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index f8dc5c0caa..1cb5c4ec41 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -346,9 +346,10 @@ class StandalonePostgresql extends BaseModel if (empty($serverIp)) { return null; } + $serverHost = formatHostForUrl($serverIp); $encodedUser = rawurlencode($this->postgres_user); $encodedPass = rawurlencode($this->postgres_password); - $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->postgres_db}"; + $url = "postgres://{$encodedUser}:{$encodedPass}@{$serverHost}:{$this->public_port}/{$this->postgres_db}"; if ($this->enable_ssl) { $url .= "?sslmode={$this->ssl_mode}"; if (in_array($this->ssl_mode, ['verify-ca', 'verify-full'])) { diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index b8bdbef49b..53f54ebe28 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -339,11 +339,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'; diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 25bc84449b..f7350da027 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -11,7 +11,6 @@ use Illuminate\Support\Facades\Log; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; -use PurplePixie\PhpDns\DNSQuery; use PurplePixie\PhpDns\DNSTypes; use Throwable; @@ -362,7 +361,7 @@ class SafeWebhookUrl implements ValidationRule foreach ($dnsServers as $dnsServer) { foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) { try { - $query = new DNSQuery($dnsServer, 53, 5); + $query = createDnsQuery($dnsServer); $records = $query->query($host, $type); if ($records === false || $query->hasError()) { @@ -395,7 +394,7 @@ class SafeWebhookUrl implements ValidationRule } return array_values(array_filter(array_map( - fn (string $server): string => trim($server), + fn (string $server): string => normalizeIpAddress($server), explode(',', $servers), ), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false)); } diff --git a/app/Rules/ValidServerIp.php b/app/Rules/ValidServerIp.php index 270ff1c348..cdf53b727f 100644 --- a/app/Rules/ValidServerIp.php +++ b/app/Rules/ValidServerIp.php @@ -8,7 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; class ValidServerIp implements ValidationRule { /** - * Accepts a valid IPv4 address, IPv6 address, or RFC 1123 hostname. + * Accepts a valid IPv4 address, IPv6 address (optionally in brackets), or RFC 1123 hostname. */ public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -26,6 +26,12 @@ class ValidServerIp implements ValidationRule return; } + // A bracketed IPv6 address ([2a01:4f8::1]); it is stored without brackets. + if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']') + && filter_var(substr($trimmed, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return; + } + // Delegate hostname validation to ValidHostname $hostnameRule = new ValidHostname; $failed = false; diff --git a/app/Services/ConfigurationRepository.php b/app/Services/ConfigurationRepository.php index 660191859e..41905fecef 100644 --- a/app/Services/ConfigurationRepository.php +++ b/app/Services/ConfigurationRepository.php @@ -37,7 +37,7 @@ class ConfigurationRepository $this->config->set('mail.mailers.smtp', [ 'transport' => 'smtp', 'scheme' => $mailerOptions['scheme'], - 'host' => $settings->smtp_host, + 'host' => filled($settings->smtp_host) ? formatHostForUrl($settings->smtp_host) : $settings->smtp_host, 'port' => $settings->smtp_port, 'encryption' => $mailerOptions['encryption'], 'username' => $settings->smtp_username, diff --git a/app/Services/DockerRegistryLogins.php b/app/Services/DockerRegistryLogins.php index 6b95d56393..a165df7e7b 100644 --- a/app/Services/DockerRegistryLogins.php +++ b/app/Services/DockerRegistryLogins.php @@ -15,7 +15,11 @@ class DockerRegistryLogins { public const DOCKER_HUB = 'docker.io'; - public const REGISTRY_PATTERN = '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/'; + /** + * A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only + * hex digits, ':' and '.', so the value stays safe in shell commands. + */ + public const REGISTRY_PATTERN = '/^(?:[a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(:[0-9]{1,5})?$/'; /** * Read the registries a server is logged in to from the Docker config that deployments mount. diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index 27dbc4a83a..c9675a24c8 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -179,18 +179,19 @@ class HetznerService public function findServerByIp(string $ip): ?array { + $ip = normalizeIpAddress($ip); + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; $servers = $this->getServers(); foreach ($servers as $server) { - // Check IPv4 $ipv4 = data_get($server, 'public_net.ipv4.ip'); - if ($ipv4 === $ip) { + if (filled($ipv4) && normalizeIpAddress($ipv4) === $ip) { return $server; } - // Check IPv6 (Hetzner returns the full /64 block) - $ipv6 = data_get($server, 'public_net.ipv6.ip'); - if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) { + // Hetzner returns the IPv6 network (2a01:4f8:c016:bd23::/64), not the server address. + $ipv6Network = data_get($server, 'public_net.ipv6.ip'); + if ($isIpv6 && filled($ipv6Network) && str_contains($ipv6Network, '/') && ipMatch($ip, [$ipv6Network])) { return $server; } } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 90fe5954d0..c8af0ab002 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -128,7 +128,7 @@ class ServerTransferImporter $serverIp = (string) data_get($bundle, 'server.ip'); $uuidConflict = Server::withTrashed()->where('uuid', $serverUuid)->exists() && $preserveUuids; - $existingIp = Server::where('ip', $serverIp)->first(); + $existingIp = Server::where('ip', Server::normalizeIp($serverIp))->first(); $ipConflict = $existingIp !== null; if (! $dryRun && $uuidConflict) { diff --git a/app/Support/DomainFavicon.php b/app/Support/DomainFavicon.php index e872a14ac7..b80b44de29 100644 --- a/app/Support/DomainFavicon.php +++ b/app/Support/DomainFavicon.php @@ -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; } } diff --git a/app/Support/SmtpTransportFactory.php b/app/Support/SmtpTransportFactory.php index 43a0c5c065..f64dcd7659 100644 --- a/app/Support/SmtpTransportFactory.php +++ b/app/Support/SmtpTransportFactory.php @@ -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, diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 4f5a77a140..53bc945c18 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -137,10 +137,11 @@ class ValidationPatterns * Pattern for Docker image repository names without a tag. * * Allows an optional registry host/port followed by lowercase repository - * path components. A trailing @sha256 marker is accepted for existing + * path components. A bracketed IPv6 registry host ([2a01:4f8::1]:5000) may + * only hold hex digits, ':' and '.'. A trailing @sha256 marker is accepted for existing * digest-based dockerimage records that store the digest hash separately. */ - public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; + public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/'; /** * Pattern for Docker image tags. @@ -327,6 +328,24 @@ class ValidationPatterns return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1; } + /** + * Check if a string is a safe health check host: a hostname or IPv4 address, or an IPv6 address + * with or without brackets (::1, [::1]). + */ + public static function isValidHealthCheckHost(?string $value): bool + { + if (! is_string($value)) { + return false; + } + + if (preg_match('/^[a-zA-Z0-9.\-_]+$/', $value) === 1) { + return true; + } + + return preg_match('/^(?:\[[0-9a-fA-F:.]+\]|[0-9a-fA-F:.]+)$/', $value) === 1 + && filter_var(trim($value, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + /** * Check if a string is a valid Docker image tag. */ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index ee89f52c6f..0c17dab4e3 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -239,7 +239,11 @@ function sharedDataApplications(?Application $application = null): array 'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'], 'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'], 'health_check_port' => 'integer|nullable|min:1|max:65535', - 'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'], + 'health_check_host' => ['string', function (string $attribute, mixed $value, Closure $fail): void { + if (! ValidationPatterns::isValidHealthCheckHost($value)) { + $fail('The health check host must be a hostname or an IP address.'); + } + }], 'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS', 'health_check_return_code' => 'numeric', 'health_check_scheme' => 'string|in:http,https', diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index f4a97be5ce..ea30954350 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -233,7 +233,7 @@ function streamBackupFromServer(Server $server, string $filename, string $conten $disk = Storage::build([ 'driver' => 'sftp', - 'host' => $server->ip, + 'host' => formatHostForUrl($server->ip), 'port' => (int) $server->port, 'username' => $server->user, 'privateKey' => $privateKey->getKeyLocation(), diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index d410696e3a..076d03d40c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -911,6 +911,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 = []) { $labels = collect([]); @@ -964,6 +977,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); @@ -1008,7 +1023,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}"); @@ -1080,7 +1095,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"); @@ -1100,7 +1115,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ } } else { // Set labels for http - $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)"); + $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index bc707a2327..242ed14c91 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1494,19 +1494,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 @@ -2379,7 +2369,7 @@ function validateDNSEntry(string $fqdn, Server $server) function createDnsQuery(string $dnsServer): DNSQuery { return app()->make(DNSQuery::class, [ - 'server' => $dnsServer, + 'server' => formatHostForUrl($dnsServer), 'port' => 53, 'timeout' => 5, ]); @@ -2400,6 +2390,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 +2564,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; } } diff --git a/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php new file mode 100644 index 0000000000..8563a607ad --- /dev/null +++ b/database/migrations/2026_10_09_092903_normalize_ipv6_server_ips.php @@ -0,0 +1,93 @@ +select(['id', 'team_id', 'ip', 'hetzner_server_id']) + ->where('ip', 'like', '%:%') + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + $this->normalizeServerIp($server); + } + }); + + DB::table('server_settings') + ->select(['id', 'sentinel_custom_url']) + ->where('sentinel_custom_url', 'like', '%://%:%:%') + ->orderBy('id') + ->chunkById(100, function ($settings): void { + foreach ($settings as $setting) { + $this->bracketSentinelUrlHost($setting); + } + }); + } + + public function down(): void + { + // + } + + private function normalizeServerIp(object $server): void + { + $ip = (string) $server->ip; + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + $newIp = normalizeIpAddress($ip); + // A Hetzner server that really uses ::64 is reachable; the broken network address is not. + $isReachable = (bool) DB::table('server_settings')->where('server_id', $server->id)->value('is_reachable'); + if (! is_null($server->hetzner_server_id) && str_ends_with($ip, '::64') && ! $isReachable) { + $newIp = hetznerServerIpv6(substr($ip, 0, -strlen('::64')).'::/64') ?? $newIp; + } + + if ($newIp === $ip) { + return; + } + + // Coolify does not allow the same server IP twice, in any team. + $duplicateExists = DB::table('servers') + ->whereNull('deleted_at') + ->where('id', '!=', $server->id) + ->where('ip', $newIp) + ->exists(); + if ($duplicateExists) { + Log::warning('Skipped normalizing a server IPv6 address because another server already uses it.', [ + 'server_id' => $server->id, + 'team_id' => $server->team_id, + 'ip' => $ip, + 'normalized_ip' => $newIp, + ]); + + return; + } + + DB::table('servers')->where('id', $server->id)->update(['ip' => $newIp]); + } + + private function bracketSentinelUrlHost(object $setting): void + { + if (preg_match('#\A(https?)://([0-9A-Fa-f:.]+):(\d+)(/.*)?\z#', (string) $setting->sentinel_custom_url, $matches) !== 1) { + return; + } + if (filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return; + } + + DB::table('server_settings')->where('id', $setting->id)->update([ + 'sentinel_custom_url' => $matches[1].'://'.formatHostForUrl($matches[2]).':'.$matches[3].($matches[4] ?? ''), + ]); + } +}; diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 761d88fede..94f7c56efa 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -117,14 +117,14 @@ @else - {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($application->destination->server->ip) }}:{{ explode(':', $port)[0] }} @if (count($application->additional_servers) > 0) @foreach ($application->additional_servers as $server) - {{ $server->ip }}:{{ explode(':', $port)[0] }} + href="http://{{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }}"> + {{ formatHostForUrl($server->ip) }}:{{ explode(':', $port)[0] }} @endforeach @endif diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 46289f4ce6..be2414fbe8 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -166,7 +166,7 @@ @if ($traefikDashboardAvailable) - Traefik @@ -282,7 +282,7 @@ @if ($traefikDashboardAvailable)
+ href="http://{{ formatHostForUrl((string) $serverIp) }}:8080"> Traefik Dashboard diff --git a/tests/Feature/Api/DomainsByServerApiTest.php b/tests/Feature/Api/DomainsByServerApiTest.php index c64f33d6f4..9f6f507b94 100644 --- a/tests/Feature/Api/DomainsByServerApiTest.php +++ b/tests/Feature/Api/DomainsByServerApiTest.php @@ -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']); +}); diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 8c1856f026..4246eb7878 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -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(); diff --git a/tests/Feature/Application/GithubSourceChangeTest.php b/tests/Feature/Application/GithubSourceChangeTest.php index ccbd47005b..c3912ead7d 100644 --- a/tests/Feature/Application/GithubSourceChangeTest.php +++ b/tests/Feature/Application/GithubSourceChangeTest.php @@ -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]', + ]); +}); diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index f468dcfc88..e157269ef8 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -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'); +}); diff --git a/tests/Feature/DnsValidationTest.php b/tests/Feature/DnsValidationTest.php index d505ea9359..d068c02b19 100644 --- a/tests/Feature/DnsValidationTest.php +++ b/tests/Feature/DnsValidationTest.php @@ -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']); +}); diff --git a/tests/Feature/GitlabSourceChangeViewTest.php b/tests/Feature/GitlabSourceChangeViewTest.php index 0e4cddd822..7384262442 100644 --- a/tests/Feature/GitlabSourceChangeViewTest.php +++ b/tests/Feature/GitlabSourceChangeViewTest.php @@ -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]', + ]); +}); diff --git a/tests/Feature/Ipv6ServerAddressUrlTest.php b/tests/Feature/Ipv6ServerAddressUrlTest.php new file mode 100644 index 0000000000..05b4483419 --- /dev/null +++ b/tests/Feature/Ipv6ServerAddressUrlTest.php @@ -0,0 +1,125 @@ +toBe('http://[2a01:4f8::1]:8000'); + })->with([ + 'raw' => '2a01:4f8::1', + 'bracketed' => '[2a01:4f8::1]', + ]); + + it('keeps an IPv4 public address unchanged', function () { + expect(ServerSetting::instanceSentinelUrl(null, '203.0.113.10', '2a01:4f8::1'))->toBe('http://203.0.113.10:8000'); + }); +}); + +describe('sentinel url from the current request', function () { + beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0, 'fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null]); + }); + + function sentinelUrlForRequestHost(string $url): ?string + { + app()->instance('request', Request::create($url)); + + $settings = new ServerSetting; + $settings->setRelation('server', new Server(['ip' => '203.0.113.20'])); + + return $settings->generateSentinelUrl(save: false); + } + + it('ignores IPv6 loopback request hosts', function (string $url) { + expect(sentinelUrlForRequestHost($url))->toBeNull(); + })->with([ + 'loopback' => 'http://[::1]:8000/', + 'unspecified' => 'http://[::]:8000/', + 'long loopback' => 'http://[0:0:0:0:0:0:0:1]:8000/', + ]); + + it('keeps a public IPv6 request host', function () { + expect(sentinelUrlForRequestHost('http://[2a01:4f8::1]:8000/'))->toBe('http://[2a01:4f8::1]:8000'); + }); + + it('still ignores IPv4 loopback and keeps public IPv4 hosts', function () { + expect(sentinelUrlForRequestHost('http://127.0.0.1:8000/'))->toBeNull() + ->and(sentinelUrlForRequestHost('http://203.0.113.10:8000/'))->toBe('http://203.0.113.10:8000'); + }); +}); + +describe('public database urls', function () { + function publicDatabaseUrl(string $modelClass, array $attributes, string $serverIp): ?string + { + if ($modelClass === StandaloneMongodb::class) { + $attributes['mongo_initdb_root_password'] = encrypt($attributes['mongo_initdb_root_password']); + } + $database = new $modelClass; + $database->forceFill(array_merge(['is_public' => true, 'public_port' => 15432], $attributes)); + $database->setRelation('destination', (object) ['server' => (object) ['getIp' => $serverIp]]); + + return $database->external_db_url; + } + + $databases = [ + 'postgresql' => [StandalonePostgresql::class, ['postgres_user' => 'user', 'postgres_password' => 'pass', 'postgres_db' => 'app'], 'postgres://user:pass@%s:15432/app'], + 'mysql' => [StandaloneMysql::class, ['mysql_user' => 'user', 'mysql_password' => 'pass', 'mysql_database' => 'app'], 'mysql://user:pass@%s:15432/app'], + 'mariadb' => [StandaloneMariadb::class, ['mariadb_user' => 'user', 'mariadb_password' => 'pass', 'mariadb_database' => 'app'], 'mysql://user:pass@%s:15432/app'], + 'mongodb' => [StandaloneMongodb::class, ['mongo_initdb_root_username' => 'user', 'mongo_initdb_root_password' => 'pass'], 'mongodb://user:pass@%s:15432/?directConnection=true'], + 'redis' => [StandaloneRedis::class, ['image' => 'redis:5.0'], 'redis://@%s:15432/0'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'pass'], 'redis://:pass@%s:15432/0'], + 'dragonfly' => [StandaloneDragonfly::class, ['dragonfly_password' => 'pass'], 'redis://:pass@%s:15432/0'], + 'clickhouse' => [StandaloneClickhouse::class, ['clickhouse_admin_user' => 'user', 'clickhouse_admin_password' => 'pass', 'clickhouse_db' => 'app'], 'clickhouse://user:pass@%s:15432/app'], + ]; + + it('brackets an IPv6 server address', function (string $modelClass, array $attributes, string $expected) { + expect(publicDatabaseUrl($modelClass, $attributes, '2a01:4f8::1'))->toBe(sprintf($expected, '[2a01:4f8::1]')) + ->and(publicDatabaseUrl($modelClass, $attributes, '[2a01:4f8::1]'))->toBe(sprintf($expected, '[2a01:4f8::1]')); + })->with($databases); + + it('keeps an IPv4 server address unchanged', function (string $modelClass, array $attributes, string $expected) { + expect(publicDatabaseUrl($modelClass, $attributes, '203.0.113.10'))->toBe(sprintf($expected, '203.0.113.10')); + })->with($databases); +}); + +describe('public service database url', function () { + function serviceDatabaseUrl(string $serverIp): string + { + $server = new Server(['ip' => $serverIp]); + $server->id = 5; + $service = new Service; + $service->setRelation('server', $server); + $database = new ServiceDatabase; + $database->public_port = 15432; + $database->setRelation('service', $service); + + return $database->getServiceDatabaseUrl(); + } + + it('brackets an IPv6 server address', function (string $ipv6) { + expect(serviceDatabaseUrl($ipv6))->toBe('[2a01:4f8::1]:15432'); + })->with([ + 'raw' => '2a01:4f8::1', + 'bracketed' => '[2a01:4f8::1]', + ]); + + it('keeps an IPv4 server address unchanged', function () { + expect(serviceDatabaseUrl('203.0.113.10'))->toBe('203.0.113.10:15432'); + }); +}); diff --git a/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php b/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php index 32fb969c95..18216f62c4 100644 --- a/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php +++ b/tests/Feature/Livewire/Project/Application/ConfigurationChangeRefreshEventsTest.php @@ -60,3 +60,19 @@ it('persists environment variable order as an application setting', function () expect($this->application->settings->fresh()->is_env_sorting_enabled)->toBeTrue(); }); + +it('saves an IPv6 health check host and rejects an invalid one', function () { + Livewire::test(HealthChecks::class, ['resource' => $this->application]) + ->set('healthCheckHost', '[::1]') + ->call('submit') + ->assertHasNoErrors(); + + expect($this->application->fresh()->health_check_host)->toBe('[::1]'); + + Livewire::test(HealthChecks::class, ['resource' => $this->application->fresh()]) + ->set('healthCheckHost', '[::1];id') + ->call('submit') + ->assertNotDispatched('configurationChanged'); + + expect($this->application->fresh()->health_check_host)->toBe('[::1]'); +}); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 21df936c94..618a0b9cb6 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -671,6 +671,18 @@ test('get_server_domains and get_server_resources are team scoped', function () expect(mcpReadCall('get_server_resources', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue(); }); +test('get_server_domains returns IPv6 hosts with brackets and without the port', function () { + Application::query()->whereKey($this->application->id)->update([ + 'fqdn' => 'http://[2a01:4f8::1]:8080,https://app.example.com:8443/api,http://192.0.2.10:3000', + ]); + + $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]); + $domains->assertOk(); + + expect(mcpReadJson($domains)['data']['domains'][0]['domains']) + ->toBe(['[2a01:4f8::1]', 'app.example.com', '192.0.2.10']); +}); + test('get_server_domains filters polymorphic destinations by type and id', function () { // Other server gets the next standalone_dockers id (typically 2). $otherServer = Server::factory()->create(['team_id' => $this->team->id]); diff --git a/tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php b/tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php new file mode 100644 index 0000000000..dc7876b85c --- /dev/null +++ b/tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php @@ -0,0 +1,127 @@ + 0]); + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +/** + * Creates a server and writes the raw IP past the model setter, like rows saved by older versions. + */ +function createServerWithRawIpForMigrationTest(Team $team, PrivateKey $privateKey, string $rawIp, array $attributes = []): Server +{ + $server = Server::factory()->create(array_merge([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.250', + ], $attributes)); + DB::table('servers')->where('id', $server->id)->update(['ip' => $rawIp]); + + return $server; +} + +function runNormalizeIpv6ServerIpsMigration(): void +{ + (require database_path('migrations/2026_10_09_092903_normalize_ipv6_server_ips.php'))->up(); +} + +function rawServerIp(Server $server): ?string +{ + return DB::table('servers')->where('id', $server->id)->value('ip'); +} + +it('repairs Hetzner IPv6 networks saved with the old setter', function () { + $server = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2a01:4f8:c016:bd23::64', [ + 'hetzner_server_id' => 123, + ]); + $server->settings()->update(['is_reachable' => false]); + + runNormalizeIpv6ServerIpsMigration(); + + expect(rawServerIp($server))->toBe('2a01:4f8:c016:bd23::1'); +}); + +it('keeps a reachable Hetzner server whose address really ends in ::64', function () { + $server = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2a01:4f8:c016:bd23::64', [ + 'hetzner_server_id' => 123, + ]); + $server->settings()->update(['is_reachable' => true]); + + runNormalizeIpv6ServerIpsMigration(); + + expect(rawServerIp($server))->toBe('2a01:4f8:c016:bd23::64'); +}); + +it('repairs Sentinel URLs that were saved with a bare IPv6 address', function () { + $server = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2a01:4f8:c016:bd23::1'); + $server->settings()->update(['sentinel_custom_url' => 'http://2a01:4f8:c016:bd23::1:8000']); + $other = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '198.51.100.8'); + $other->settings()->update(['sentinel_custom_url' => 'https://coolify.example.com']); + + runNormalizeIpv6ServerIpsMigration(); + + expect($server->settings()->value('sentinel_custom_url'))->toBe('http://[2a01:4f8:c016:bd23::1]:8000') + ->and($other->settings()->value('sentinel_custom_url'))->toBe('https://coolify.example.com'); +}); + +it('ignores deleted servers when it checks for duplicates', function () { + $deleted = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2001:db8::9'); + $deleted->delete(); + $server = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2001:0DB8::9'); + + runNormalizeIpv6ServerIpsMigration(); + + expect(rawServerIp($server))->toBe('2001:db8::9'); +}); + +it('normalizes IPv6 addresses and leaves IPv4 addresses and hostnames untouched', function () { + $ipv6 = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2A01:04F8:0000:0000:0000:0000:0000:0001'); + $notHetzner = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2001:db8::64'); + $ipv4 = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '198.51.100.7'); + $hostname = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, 'Server.Example.com'); + + runNormalizeIpv6ServerIpsMigration(); + + expect(rawServerIp($ipv6))->toBe('2a01:4f8::1') + ->and(rawServerIp($notHetzner))->toBe('2001:db8::64') + ->and(rawServerIp($ipv4))->toBe('198.51.100.7') + ->and(rawServerIp($hostname))->toBe('Server.Example.com'); +}); + +it('includes the server with id 0', function () { + createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2001:0DB8::9', ['id' => 0]); + + runNormalizeIpv6ServerIpsMigration(); + + expect(DB::table('servers')->where('id', 0)->value('ip'))->toBe('2001:db8::9'); +}); + +it('skips a server when the new address belongs to another server', function () { + Log::spy(); + $existing = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2a01:4f8:c016:bd23::1'); + $broken = createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2a01:4f8:c016:bd23::64', [ + 'hetzner_server_id' => 123, + ]); + $broken->settings()->update(['is_reachable' => false]); + $otherTeam = Team::factory()->create(); + $otherTeamServer = createServerWithRawIpForMigrationTest($otherTeam, PrivateKey::factory()->create(['team_id' => $otherTeam->id]), '2001:0db8::1'); + createServerWithRawIpForMigrationTest($this->team, $this->privateKey, '2001:db8::1'); + + runNormalizeIpv6ServerIpsMigration(); + + expect(rawServerIp($existing))->toBe('2a01:4f8:c016:bd23::1') + ->and(rawServerIp($broken))->toBe('2a01:4f8:c016:bd23::64') + ->and(rawServerIp($otherTeamServer))->toBe('2001:0db8::1'); + Log::shouldHaveReceived('warning')->twice(); +}); diff --git a/tests/Feature/Server/ServerIpv6NormalizationTest.php b/tests/Feature/Server/ServerIpv6NormalizationTest.php new file mode 100644 index 0000000000..8beaa3921e --- /dev/null +++ b/tests/Feature/Server/ServerIpv6NormalizationTest.php @@ -0,0 +1,245 @@ + 'array', + 'session.driver' => 'array', + ]); + InstanceSettings::forceCreate(['id' => 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function createServerForIpv6NormalizationTest(Team $team, PrivateKey $privateKey, string $ip, array $attributes = []): Server +{ + return Server::factory()->create(array_merge([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => $ip, + ], $attributes)); +} + +it('stores IPv6 addresses in the short lower-case form', function (string $input, string $expected) { + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, $input); + + expect($server->fresh()->ip)->toBe($expected); +})->with([ + 'upper case with zeros' => ['2A01:04F8:C016:BD23:0000:0000:0000:0001', '2a01:4f8:c016:bd23::1'], + 'in brackets' => ['[2a01:4f8:c016:bd23::1]', '2a01:4f8:c016:bd23::1'], + 'IPv4' => ['203.0.113.5', '203.0.113.5'], + 'hostname' => ['Server.Example.com', 'Server.Example.com'], +]); + +it('stores a normalized address when backfilling a placeholder IP', function () { + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, Server::PLACEHOLDER_IP); + + expect($server->backfillPlaceholderIp('2A01:04F8::0001'))->toBeTrue() + ->and($server->ip)->toBe('2a01:4f8::1') + ->and($server->fresh()->ip)->toBe('2a01:4f8::1'); +}); + +it('builds Traefik dashboard host rules without IPv6 brackets', function () { + $server = new Server; + + expect($server->dashboardTraefikHost('[2a01:4f8:c016:bd23::1]'))->toBe('2a01:4f8:c016:bd23::1') + ->and($server->dashboardTraefikHost('dashboard.example.com'))->toBe('dashboard.example.com'); +}); + +it('treats IPv6 notations as the same IP when adding a server', function () { + createServerForIpv6NormalizationTest($this->team, $this->privateKey, '2001:db8::1'); + + Livewire::test(ByIp::class, [ + 'private_keys' => collect([$this->privateKey]), + 'limit_reached' => false, + ]) + ->set('ip', '2001:0DB8::1') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertDispatched('error', 'A server with this IP/Domain already exists.'); + + expect(Server::query()->where('ip', '2001:db8::1')->count())->toBe(1); +}); + +it('adds a server with a bracketed IPv6 address in the stored form', function () { + Livewire::test(ByIp::class, [ + 'private_keys' => collect([$this->privateKey]), + 'limit_reached' => false, + ]) + ->set('name', 'ipv6-server') + ->set('ip', '[2001:0DB8::5]') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('name', 'ipv6-server')->value('ip'))->toBe('2001:db8::5'); +}); + +it('treats IPv6 notations as the same IP when editing a server', function () { + createServerForIpv6NormalizationTest($this->team, $this->privateKey, '2001:db8::1'); + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, '203.0.113.5'); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('ip', '2001:0DB8::1') + ->call('submit') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + expect($server->fresh()->ip)->toBe('203.0.113.5'); +}); + +it('saves other server fields when the unchanged IP is also used by another server', function () { + InstanceSettings::get()->update(['fqdn' => 'https://coolify.example.com']); + createServerForIpv6NormalizationTest($this->team, $this->privateKey, '2001:db8::1'); + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, '203.0.113.5'); + DB::table('servers')->where('id', $server->id)->update(['ip' => '2001:db8::1']); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'renamed-server') + ->call('submit') + ->assertNotDispatched('error'); + + expect($server->fresh()->name)->toBe('renamed-server'); +}); + +it('saves the normalized IPv6 address when editing a server', function () { + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, '203.0.113.5'); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('ip', '[2001:0DB8::7]') + ->call('submit') + ->assertSet('ip', '2001:db8::7'); + + expect($server->fresh()->ip)->toBe('2001:db8::7'); +}); + +it('stores the Hetzner IPv6 server address when creating an IPv6-only server', function () { + $token = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Test Hetzner Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([ + 'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'], + ], 201), + 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response(['ssh_keys' => []], 200), + 'https://api.hetzner.cloud/v1/servers' => Http::response([ + 'server' => [ + 'id' => 777, + 'status' => 'running', + 'public_net' => [ + 'ipv4' => null, + 'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'], + ], + ], + ], 201), + ]); + + Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $token->uuid]) + ->set('server_name', 'hetzner-ipv6') + ->set('selected_location', 'fsn1') + ->set('selected_server_type', 'cx22') + ->set('selected_image', 114690387) + ->set('private_key_id', $this->privateKey->id) + ->set('enable_ipv4', false) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hetzner_server_id', '777')->value('ip'))->toBe('2a01:4f8:c016:bd23::1'); +}); + +it('backfills the Hetzner IPv6 server address during the status sync', function () { + $token = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Hetzner', + ]); + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, Server::PLACEHOLDER_IP, [ + 'cloud_provider_token_id' => $token->id, + 'hetzner_server_id' => 123, + 'hetzner_server_status' => 'starting', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers/123' => Http::response([ + 'server' => [ + 'id' => 123, + 'status' => 'running', + 'public_net' => [ + 'ipv4' => null, + 'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'], + ], + ], + ]), + ]); + + (new ServerCloudProviderStatusCheckJob($server))->handle(); + + expect($server->fresh()->ip)->toBe('2a01:4f8:c016:bd23::1'); +}); + +it('backfills the Hetzner IPv6 server address from the server page', function () { + $token = CloudProviderToken::create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-token', + 'name' => 'Hetzner', + ]); + $server = createServerForIpv6NormalizationTest($this->team, $this->privateKey, Server::PLACEHOLDER_IP, [ + 'cloud_provider_token_id' => $token->id, + 'hetzner_server_id' => 123, + 'hetzner_server_status' => 'starting', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers/123' => Http::response([ + 'server' => [ + 'id' => 123, + 'status' => 'running', + 'public_net' => [ + 'ipv4' => null, + 'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'], + ], + ], + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->call('checkHetznerServerStatus', true) + ->assertSet('ip', '2a01:4f8:c016:bd23::1'); + + expect($server->fresh()->ip)->toBe('2a01:4f8:c016:bd23::1'); +}); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index 4d25eb6a77..99b1793318 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1213,3 +1213,20 @@ it('restarts a service dns check when the domain already has a completed result' Queue::assertPushed(CheckDomainDnsJob::class); }); + +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'); +}); diff --git a/tests/Unit/BackupDownloadHelperTest.php b/tests/Unit/BackupDownloadHelperTest.php index 9db77c642a..5ac95f7a29 100644 --- a/tests/Unit/BackupDownloadHelperTest.php +++ b/tests/Unit/BackupDownloadHelperTest.php @@ -81,3 +81,20 @@ it('streams a backup file with the requested content type', function () { expect($contents)->toBe('backup contents'); }); + +it('brackets an IPv6 server address for the sftp host', function (string $ip) { + $server = backupDownloadServer(); + $server->forceFill(['ip' => $ip]); + + $disk = Mockery::mock(); + $disk->shouldReceive('exists')->once()->andReturnFalse(); + Storage::shouldReceive('build') + ->once() + ->withArgs(fn (array $config): bool => $config['host'] === '[2a01:4f8::1]') + ->andReturn($disk); + + streamBackupFromServer($server, '/backups/archive.tar.gz', 'application/gzip'); +})->with([ + 'raw' => '2a01:4f8::1', + 'bracketed' => '[2a01:4f8::1]', +])->throws(FileNotFoundException::class); diff --git a/tests/Unit/ConfigurationRepositoryMailConfigTest.php b/tests/Unit/ConfigurationRepositoryMailConfigTest.php index ebecda64ae..87d4e456bc 100644 --- a/tests/Unit/ConfigurationRepositoryMailConfigTest.php +++ b/tests/Unit/ConfigurationRepositoryMailConfigTest.php @@ -53,3 +53,26 @@ it('preserves the configured smtp ehlo domain', function () { expect($config->get('mail.mailers.smtp.local_domain'))->toBe('coolify.example.com'); }); + +it('brackets an IPv6 smtp host in the laravel mail config', function (string $host, string $expected) { + $config = new Repository; + + (new ConfigurationRepository($config))->updateMailConfig((object) [ + 'resend_enabled' => false, + 'smtp_enabled' => true, + 'smtp_encryption' => 'starttls', + 'smtp_host' => $host, + 'smtp_port' => 587, + 'smtp_username' => null, + 'smtp_password' => null, + 'smtp_timeout' => null, + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + expect($config->get('mail.mailers.smtp.host'))->toBe($expected); +})->with([ + 'raw IPv6' => ['2001:db8::25', '[2001:db8::25]'], + 'bracketed IPv6' => ['[2001:db8::25]', '[2001:db8::25]'], + 'hostname' => ['smtp.example.com', 'smtp.example.com'], +]); diff --git a/tests/Unit/DockerImageReferenceValidationTest.php b/tests/Unit/DockerImageReferenceValidationTest.php index 5ee85f24b2..05f2bce7ff 100644 --- a/tests/Unit/DockerImageReferenceValidationTest.php +++ b/tests/Unit/DockerImageReferenceValidationTest.php @@ -15,6 +15,9 @@ it('accepts valid docker registry image names', function (string $imageName) { 'ghcr image' => 'ghcr.io/coollabsio/coolify', 'repository component with repeated hyphens' => 'ghcr.io/acme/my--service', 'registry with port' => 'registry.example.com:5000/team/app', + 'IPv6 registry' => '[2a01:4f8::1]/team/app', + 'IPv6 registry with port' => '[2a01:4f8::1]:5000/app', + 'IPv4-mapped IPv6 registry' => '[::ffff:192.0.2.1]:5000/app', 'digest marker used by existing dockerimage records' => 'nginx@sha256', ]); @@ -29,6 +32,10 @@ it('rejects docker registry image names with shell metacharacters', function (st 'newline' => "coolify/poc\nid", 'space' => 'coolify/poc image', 'tag in image-name-only field' => 'coolify/poc:latest', + 'IPv6 registry with command substitution' => '[2a01:4f8::1$(id)]:5000/app', + 'IPv6 registry with letters outside hex' => '[2a01:4f8::zz]/app', + 'unclosed IPv6 registry' => '[2a01:4f8::1:5000/app', + 'bare IPv6 registry' => '2a01:4f8::1/app', ]); it('accepts valid docker registry image tags', function (string $tag) { @@ -66,6 +73,7 @@ it('accepts supported full docker image reference formats', function (string $im 'image with sha256 digest' => 'nginx@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'registry image with sha256 digest' => 'ghcr.io/user/app@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'registry port image with tag' => 'localhost:5000/app:latest', + 'IPv6 registry port image with tag' => '[2a01:4f8::1]:5000/app:latest', ]); it('rejects unsupported full docker image reference formats', function (string $imageReference) { diff --git a/tests/Unit/DockerRegistryLoginsTest.php b/tests/Unit/DockerRegistryLoginsTest.php index a3c9fee56e..e87226fa12 100644 --- a/tests/Unit/DockerRegistryLoginsTest.php +++ b/tests/Unit/DockerRegistryLoginsTest.php @@ -55,3 +55,33 @@ it('reads the username from the auth value without keeping the password', functi 'no colon' => [['auth' => base64_encode('pa:ss')], 'pa'], 'empty entry' => [[], null], ]); + +it('accepts registry hosts with the registry pattern', function (string $registry) { + expect(preg_match(DockerRegistryLogins::REGISTRY_PATTERN, $registry))->toBe(1); +})->with([ + 'ghcr.io', + 'registry.example.com:5000', + 'localhost', + '192.0.2.10:5000', + '[2a01:4f8::1]', + '[2a01:4f8::1]:5000', + '[::ffff:192.0.2.1]:5000', +]); + +it('rejects unsafe registry hosts with the registry pattern', function (string $registry) { + expect(preg_match(DockerRegistryLogins::REGISTRY_PATTERN, $registry))->toBe(0); +})->with([ + 'ghcr.io;id', + '[2a01:4f8::1$(id)]:5000', + '[2a01:4f8::1 ]', + '[2a01:4f8::zz]', + '[2a01:4f8::1', + '2a01:4f8::1]', + '[]', +]); + +it('reads IPv6 registries from the docker config and image references', function () { + expect(DockerRegistryLogins::parseConfig(json_encode(['auths' => ['[2a01:4f8::1]:5000' => []]]))) + ->toBe(['[2a01:4f8::1]:5000' => ['source' => 'auths', 'username' => null]]) + ->and(DockerRegistryLogins::registryFromImage('[2a01:4f8::1]:5000/team/app:1'))->toBe('[2a01:4f8::1]:5000'); +}); diff --git a/tests/Unit/DomainFaviconTest.php b/tests/Unit/DomainFaviconTest.php index 1aeeb28ae1..30e39e0037 100644 --- a/tests/Unit/DomainFaviconTest.php +++ b/tests/Unit/DomainFaviconTest.php @@ -9,6 +9,9 @@ it('builds the favicon URL for a public domain', function (string $url, string $ 'domain with port' => ['https://app.example.com:8443', 'https://app.example.com:8443/favicon.ico'], 'public IP' => ['http://8.8.8.8', 'http://8.8.8.8/favicon.ico'], 'public sslip.io' => ['http://app.1.2.3.4.sslip.io', 'http://app.1.2.3.4.sslip.io/favicon.ico'], + 'public IPv6' => ['http://[2a01:4f8::1]:8080', 'http://[2a01:4f8::1]:8080/favicon.ico'], + 'public IPv6 sslip.io' => ['http://app.2a01-4f8-c016-bd23--1.sslip.io', 'http://app.2a01-4f8-c016-bd23--1.sslip.io/favicon.ico'], + 'hostname with dashes before sslip.io' => ['http://my-app.sslip.io', 'http://my-app.sslip.io/favicon.ico'], ]); it('skips the favicon for a private network host', function (string $url) { @@ -23,6 +26,11 @@ it('skips the favicon for a private network host', function (string $url) { 'internal name' => ['http://app.internal'], 'private sslip.io' => ['http://app.10.0.0.5.sslip.io'], 'private nip.io with dashes' => ['http://10-0-0-5.nip.io'], + 'IPv6 loopback' => ['http://[::1]:3000'], + 'unique local IPv6 sslip.io' => ['http://fd00--1.sslip.io'], + 'unique local IPv6 sslip.io with label' => ['http://app.fd00-221-1--20.sslip.io'], + 'link-local IPv6 sslip.io' => ['http://fe80--1.sslip.io'], + 'IPv6 loopback sslip.io' => ['http://0--1.sslip.io'], 'invalid URL' => ['not a url'], ]); diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php index 88361c3d9e..a6e9e1b41f 100644 --- a/tests/Unit/HealthCheckCommandInjectionTest.php +++ b/tests/Unit/HealthCheckCommandInjectionTest.php @@ -1,6 +1,7 @@ toContain("'http://my-app.local:80/api/health'"); }); +it('builds the healthcheck URL with brackets for an IPv6 host', function (string $host, string $expectedUrl) { + $result = callGenerateHealthcheckCommands([ + 'health_check_host' => $host, + 'health_check_port' => '8080', + 'health_check_path' => '/health', + ]); + + expect($result)->toContain("'{$expectedUrl}'") + ->and($result)->not->toContain('localhost'); +})->with([ + 'loopback' => ['::1', 'http://[::1]:8080/health'], + 'bracketed loopback' => ['[::1]', 'http://[::1]:8080/health'], + 'global address' => ['2A01:4F8:0::1', 'http://[2a01:4f8::1]:8080/health'], +]); + +it('falls back to localhost for an invalid IPv6-like healthcheck host', function (string $host) { + $result = callGenerateHealthcheckCommands(['health_check_host' => $host]); + + expect($result)->toContain("'http://localhost:80/'"); +})->with([ + 'too many colons' => [':::1'], + 'shell injection in brackets' => ['[::1];id'], + 'unclosed bracket' => ['[::1'], +]); + +it('keeps IPv4 and hostname healthcheck hosts unchanged', function (string $host) { + $result = callGenerateHealthcheckCommands(['health_check_host' => $host, 'health_check_port' => '3000']); + + expect($result)->toContain("'http://{$host}:3000/'"); +})->with(['localhost', '127.0.0.1', 'my-app.local']); + +it('validates the health check host in the health checks form', function (string $host, bool $passes) { + $rules = (new ReflectionMethod(HealthChecks::class, 'rules'))->invoke(new HealthChecks); + + $validator = Validator::make(['healthCheckHost' => $host], ['healthCheckHost' => $rules['healthCheckHost']]); + + expect($validator->passes())->toBe($passes); +})->with([ + 'localhost' => ['localhost', true], + 'hostname' => ['my-app.local', true], + 'IPv4' => ['127.0.0.1', true], + 'IPv6 loopback' => ['::1', true], + 'bracketed IPv6 loopback' => ['[::1]', true], + 'IPv6 address' => ['2a01:4f8::1', true], + 'invalid IPv6' => [':::1', false], + 'unclosed bracket' => ['[::1', false], + 'shell metacharacters' => ['localhost; id #', false], + 'IPv6 with shell metacharacters' => ['[::1];id', false], +]); + it('validates health_check_host rejects shell metacharacters via API rules', function () { $rules = sharedDataApplications(); diff --git a/tests/Unit/HetznerServiceTest.php b/tests/Unit/HetznerServiceTest.php index b5f5b73229..da07d83bb1 100644 --- a/tests/Unit/HetznerServiceTest.php +++ b/tests/Unit/HetznerServiceTest.php @@ -153,3 +153,58 @@ it('findServerByIp matches correct server among multiple', function () { ->and($result['id'])->toBe(22222) ->and($result['name'])->toBe('server-b'); }); + +it('findServerByIp matches an IPv6 server address inside the Hetzner network', function (string $ip) { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [ + [ + 'id' => 11111, + 'name' => 'other-server', + 'public_net' => [ + 'ipv4' => ['ip' => '10.0.0.1'], + 'ipv6' => ['ip' => '2a01:4f8:c016:aaaa::/64'], + ], + ], + [ + 'id' => 22222, + 'name' => 'ipv6-server', + 'public_net' => [ + 'ipv4' => null, + 'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $result = (new HetznerService('fake-token'))->findServerByIp($ip); + + expect($result)->not->toBeNull() + ->and($result['id'])->toBe(22222); +})->with([ + 'server address' => '2a01:4f8:c016:bd23::1', + 'long upper-case form' => '2A01:04F8:C016:BD23:0000:0000:0000:0001', + 'in brackets' => '[2a01:4f8:c016:bd23::1]', +]); + +it('findServerByIp does not match an IPv6 address outside the Hetzner network', function () { + Http::fake([ + 'api.hetzner.cloud/v1/servers*' => Http::response([ + 'servers' => [ + [ + 'id' => 22222, + 'public_net' => [ + 'ipv4' => ['ip' => '10.0.0.1'], + 'ipv6' => ['ip' => '2a01:4f8:c016:bd23::/64'], + ], + ], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + expect((new HetznerService('fake-token'))->findServerByIp('2a01:4f8:c016:bd24::1'))->toBeNull() + ->and((new HetznerService('fake-token'))->findServerByIp('10.0.0.2'))->toBeNull(); +}); diff --git a/tests/Unit/IpAddressFormattingTest.php b/tests/Unit/IpAddressFormattingTest.php new file mode 100644 index 0000000000..d3625993a6 --- /dev/null +++ b/tests/Unit/IpAddressFormattingTest.php @@ -0,0 +1,42 @@ +toBe($expected); +})->with([ + 'IPv4' => ['188.245.12.182', '188.245.12.182'], + 'IPv4 with spaces' => [' 188.245.12.182 ', '188.245.12.182'], + 'hostname' => ['server.example.com', 'server.example.com'], + 'IPv6 short form' => ['2a01:4f8:c016:bd23::1', '2a01:4f8:c016:bd23::1'], + 'IPv6 upper case with zeros' => ['2A01:04F8:C016:BD23:0000:0000:0000:0001', '2a01:4f8:c016:bd23::1'], + 'IPv6 in brackets' => ['[2a01:4f8:c016:bd23::1]', '2a01:4f8:c016:bd23::1'], + 'not an IP in brackets' => ['[not-an-ip]', '[not-an-ip]'], +]); + +it('adds brackets only to IPv6 hosts for URLs', function (string $input, string $expected) { + expect(formatHostForUrl($input))->toBe($expected) + ->and(formatHostForUrl(formatHostForUrl($input)))->toBe($expected); +})->with([ + 'IPv4' => ['188.245.12.182', '188.245.12.182'], + 'hostname' => ['coolify.example.com', 'coolify.example.com'], + 'IPv6' => ['2a01:4f8:c016:bd23::1', '[2a01:4f8:c016:bd23::1]'], + 'IPv6 already in brackets' => ['[2a01:4f8:c016:bd23::1]', '[2a01:4f8:c016:bd23::1]'], + 'IPv6 long form' => ['2a01:04f8:c016:bd23:0:0:0:1', '[2a01:4f8:c016:bd23::1]'], +]); + +it('turns the Hetzner IPv6 network into the server address', function (?string $network, ?string $expected) { + expect(hetznerServerIpv6($network))->toBe($expected); +})->with([ + 'network' => ['2a01:4f8:c016:bd23::/64', '2a01:4f8:c016:bd23::1'], + 'address without prefix length' => ['2a01:4f8:c016:bd23::1', '2a01:4f8:c016:bd23::1'], + 'empty' => [null, null], + 'not IPv6' => ['188.245.12.182', null], +]); + +it('builds sslip host labels that are valid for every IPv6 address', function (string $ip, string $expected) { + expect(sslipHostLabel($ip))->toBe($expected); +})->with([ + 'IPv4' => ['188.245.12.182', '188.245.12.182'], + 'IPv6' => ['2a01:4f8:c016:bd23::1', '2a01-4f8-c016-bd23--1'], + 'IPv6 that ends with ::' => ['2a01:4f8:c016:bd23::', '2a01-4f8-c016-bd23--0'], + 'IPv6 that starts with ::' => ['::1', '0--1'], +]); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index 3c1a9e106e..b4b678a247 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -4,6 +4,8 @@ use App\Models\InstanceSettings; use App\Rules\SafeWebhookUrl; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Validator; +use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class); @@ -449,3 +451,37 @@ it('still rejects private targets after system DNS fallback when host is not all expect($validator->fails())->toBeTrue('Expected private IP rejection without allowlist after fallback'); }); + +it('passes bracketed IPv6 custom dns servers to the dns query', function () { + $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; + } + }; + }); + + $method = new ReflectionMethod(SafeWebhookUrl::class, 'resolveHostWithCustomDnsServers'); + $method->invoke(new SafeWebhookUrl, 'hooks.example.com', ['2606:4700:4700::1111', '[2001:db8::53]', '192.0.2.1']); + + expect(array_values(array_unique($queriedServers->getArrayCopy()))) + ->toBe(['[2606:4700:4700::1111]', '[2001:db8::53]', '192.0.2.1']); +}); + +it('accepts bracketed IPv6 custom dns servers from instance settings', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['custom_dns_servers' => '1.1.1.1, [2606:4700:4700::1111]'])); + + $method = new ReflectionMethod(SafeWebhookUrl::class, 'customDnsServers'); + + expect($method->invoke(new SafeWebhookUrl))->toBe(['1.1.1.1', '2606:4700:4700::1111']); +}); diff --git a/tests/Unit/SmtpTransportFactoryTest.php b/tests/Unit/SmtpTransportFactoryTest.php index 4ace54cfa4..33b2c668bd 100644 --- a/tests/Unit/SmtpTransportFactoryTest.php +++ b/tests/Unit/SmtpTransportFactoryTest.php @@ -128,3 +128,18 @@ it('maps encryption to laravel mailer options', function (?string $mode, ?string 'tls' => ['tls', 'smtps', 'tls'], 'unset' => [null, null, null], ]); + +it('brackets an IPv6 smtp host', function (string $host) { + $transport = SmtpTransportFactory::fromSettings(smtpSettings(['smtp_host' => $host])); + + expect($transport->getStream()->getHost())->toBe('[2001:db8::25]'); +})->with([ + 'raw' => '2001:db8::25', + 'bracketed' => '[2001:db8::25]', +]); + +it('keeps a hostname smtp host unchanged', function () { + $transport = SmtpTransportFactory::fromSettings(smtpSettings(['smtp_host' => 'smtp.example.com'])); + + expect($transport->getStream()->getHost())->toBe('smtp.example.com'); +}); diff --git a/tests/Unit/TraefikIpv6HostRuleTest.php b/tests/Unit/TraefikIpv6HostRuleTest.php new file mode 100644 index 0000000000..29b515efb1 --- /dev/null +++ b/tests/Unit/TraefikIpv6HostRuleTest.php @@ -0,0 +1,76 @@ +filter(fn (string $label) => str_contains($label, '.rule='))->values()->all(); +} + +it('writes IPv6 hosts without brackets in Traefik Host rules', function (string $domain, string $expectedRule) { + $rules = traefikRuleLabels([$domain]); + + expect($rules)->not->toBeEmpty(); + foreach ($rules as $rule) { + expect($rule)->toContain($expectedRule)->not->toContain('['); + } +})->with([ + 'http' => ['http://[2a01:4f8:c016:bd23::1]', 'Host(`2a01:4f8:c016:bd23::1`) && PathPrefix(`/`)'], + 'https' => ['https://[2a01:4f8:c016:bd23::1]', 'Host(`2a01:4f8:c016:bd23::1`) && PathPrefix(`/`)'], + 'with port and path' => ['http://[2a01:4f8::1]:8080/api', 'Host(`2a01:4f8::1`) && PathPrefix(`/api`)'], +]); + +it('writes IPv6 hosts without brackets when https is forced', function () { + $rules = traefikRuleLabels(['https://[2a01:4f8::1]'], forceHttps: true); + + expect($rules)->toHaveCount(2); + foreach ($rules as $rule) { + expect($rule)->toContain('Host(`2a01:4f8::1`)'); + } +}); + +it('keeps IPv4 and hostname Traefik Host rules unchanged', function (string $domain, string $host) { + foreach (traefikRuleLabels([$domain]) as $rule) { + expect($rule)->toContain("Host(`{$host}`) && PathPrefix(`/`)"); + } +})->with([ + 'hostname' => ['https://app.example.com', 'app.example.com'], + 'IPv4' => ['http://192.0.2.10', '192.0.2.10'], + 'sslip' => ['http://app.2a01-4f8--1.sslip.io', 'app.2a01-4f8--1.sslip.io'], +]); + +it('keeps brackets for IPv6 hosts in Caddy labels', function () { + $labels = fqdnLabelsForCaddy( + network: 'testnetwork', + uuid: 'testuuid', + domains: collect(['http://[2a01:4f8::1]']), + onlyPort: 3000, + )->values()->all(); + + expect($labels)->toContain('caddy_0=http://[2a01:4f8::1]'); +}); + +it('removes IPv6 brackets from Traefik Host rules in saved labels', function () { + $labels = collect([ + 'traefik.http.routers.http-0-app.rule=Host(`[2a01:4f8:c016:bd23::1]`) && PathPrefix(`/`)', + 'traefik.http.routers.http-1-app.rule=Host(`app.example.com`) && PathPrefix(`/`)', + 'traefik.http.routers.http-2-app.rule=Host(`188.245.12.182`)', + 'traefik.http.routers.custom.rule=Host(`[not-an-ip]`)', + 'caddy_0=http://[2a01:4f8:c016:bd23::1]', + ]); + + expect(traefikHostRulesWithoutIpv6Brackets($labels)->all())->toBe([ + 'traefik.http.routers.http-0-app.rule=Host(`2a01:4f8:c016:bd23::1`) && PathPrefix(`/`)', + 'traefik.http.routers.http-1-app.rule=Host(`app.example.com`) && PathPrefix(`/`)', + 'traefik.http.routers.http-2-app.rule=Host(`188.245.12.182`)', + 'traefik.http.routers.custom.rule=Host(`[not-an-ip]`)', + 'caddy_0=http://[2a01:4f8:c016:bd23::1]', + ]); +}); diff --git a/tests/Unit/ValidServerIpTest.php b/tests/Unit/ValidServerIpTest.php index af44286e6e..5f93631748 100644 --- a/tests/Unit/ValidServerIpTest.php +++ b/tests/Unit/ValidServerIpTest.php @@ -21,3 +21,30 @@ it('accepts private and reserved IP addresses like the next branch', function (s 'loopback IPv6' => '::1', 'unique local IPv6' => 'fd00::1', ]); + +it('accepts an IPv6 address in brackets', function (string $ip) { + $failCalled = false; + + (new ValidServerIp)->validate('ip', $ip, function () use (&$failCalled): void { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +})->with([ + 'short form' => '[2a01:4f8::1]', + 'long form' => '[2A01:04F8:0000:0000:0000:0000:0000:0001]', +]); + +it('rejects brackets around anything other than an IPv6 address', function (string $ip) { + $failCalled = false; + + (new ValidServerIp)->validate('ip', $ip, function () use (&$failCalled): void { + $failCalled = true; + }); + + expect($failCalled)->toBeTrue(); +})->with([ + 'IPv4' => '[192.168.1.10]', + 'hostname' => '[server.example.com]', + 'missing closing bracket' => '[2a01:4f8::1', +]);