Files
coolify/tests/Unit/TraefikIpv6HostRuleTest.php
T
Andras Bacsai f39c884cc7 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.
2026-10-09 13:50:28 +02:00

77 lines
3.1 KiB
PHP

<?php
/**
* Traefik matches the Host header of an IPv6 request against the address without brackets:
* Host(`[2a01:4f8::1]`) gives 404, Host(`2a01:4f8::1`) matches. Caddy needs the brackets.
*/
function traefikRuleLabels(array $domains, bool $forceHttps = false): array
{
return fqdnLabelsForTraefik(
uuid: 'testuuid',
domains: collect($domains),
is_force_https_enabled: $forceHttps,
onlyPort: 3000,
)->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]',
]);
});