Files
coolify/tests/Feature/NormalizeIpv6ServerIpsMigrationTest.php
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

128 lines
5.2 KiB
PHP

<?php
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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();
});