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.
This commit is contained in:
Andras Bacsai
2026-10-09 13:50:28 +02:00
parent 17957fbb4c
commit f39c884cc7
65 changed files with 1458 additions and 107 deletions
@@ -1,6 +1,7 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Shared\HealthChecks;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
@@ -89,6 +90,56 @@ it('uses escapeshellarg on the constructed URL', function () {
expect($result)->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();