From 5721266010d40a5991e89f22ab3087f610dab0a5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:28:52 +0200 Subject: [PATCH] fix(server-transfer): allow local peer targets in development Route localhost targets through host.docker.internal in containers while continuing to reject other private targets. --- .../ServerTransfer/ServerTransferMigrator.php | 23 +++++++++++- .../ServerTransferMigratorTest.php | 35 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php index c4d103bd80..d65bd7da00 100644 --- a/app/Services/ServerTransfer/ServerTransferMigrator.php +++ b/app/Services/ServerTransfer/ServerTransferMigrator.php @@ -100,6 +100,18 @@ class ServerTransferMigrator throw new RuntimeException('A valid target instance URL is required (e.g. https://coolify.example.com).'); } + if ($this->isLocalDevelopmentTarget($targetUrl)) { + if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) { + return (string) preg_replace( + '#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i', + '$1host.docker.internal', + $targetUrl, + ); + } + + return $targetUrl; + } + Validator::make(['target_url' => $targetUrl], [ 'target_url' => ['required', new SafeWebhookUrl], ])->validate(); @@ -107,6 +119,13 @@ class ServerTransferMigrator return $targetUrl; } + private function isLocalDevelopmentTarget(string $url): bool + { + return isDev() + && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) + && in_array(strtolower((string) parse_url($url, PHP_URL_HOST)), ['localhost', '127.0.0.1', 'host.docker.internal'], true); + } + private function normalizeToken(string $targetToken): string { $token = trim($targetToken); @@ -137,7 +156,9 @@ class ServerTransferMigrator try { $response = Http::timeout(120) - ->withOptions(SafeWebhookUrl::httpClientOptions($importUrl)) + ->withOptions($this->isLocalDevelopmentTarget($importUrl) + ? ['allow_redirects' => false] + : SafeWebhookUrl::httpClientOptions($importUrl)) ->acceptJson() ->withToken($token) ->asJson() diff --git a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php index f2e7aef69e..15244fea02 100644 --- a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php @@ -78,6 +78,7 @@ test('migrate exports imports via http and completes locally', function () { }); test('migrate rejects a private target before sending the bundle', function () { + config()->set('app.env', 'production'); Http::fake(); expect(fn () => app(ServerTransferMigrator::class)->migrate( @@ -89,6 +90,40 @@ test('migrate rejects a private target before sending the bundle', function () { Http::assertNothingSent(); }); +test('migrate reaches a local peer instance in development', function () { + config()->set('app.env', 'local'); + Http::fake(fn () => Http::response([ + 'dry_run' => false, + 'server_uuid' => $this->server->uuid, + 'claimed' => true, + 'warnings' => [], + ], 201)); + + app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://localhost:8001', + 'target-token', + ); + + $targetHost = file_exists('/.dockerenv') || is_file('/run/.containerenv') + ? 'host.docker.internal' + : 'localhost'; + Http::assertSent(fn ($request) => $request->url() === "http://{$targetHost}:8001/api/v1/servers/import"); +}); + +test('migrate still rejects other private targets in development', function () { + config()->set('app.env', 'local'); + Http::fake(); + + expect(fn () => app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://10.0.0.5:8001', + 'token', + ))->toThrow(ValidationException::class); + + Http::assertNothingSent(); +}); + test('migrate fails clearly when target is unreachable', function () { Http::fake([ 'http://8.8.4.4/*' => Http::failedConnection(),