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)