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.
This commit is contained in:
Andras Bacsai
2026-09-24 21:28:52 +02:00
parent 24123c0ceb
commit 5721266010
2 changed files with 57 additions and 1 deletions
@@ -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()
@@ -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(),