From 7fc8bc665fe9eeda790e8dcfffe84270ca0ab40a Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Thu, 5 Mar 2026 11:33:31 -0600 Subject: [PATCH] support master-domain routing for remote apps and database proxies --- app/Actions/Database/StartDatabaseProxy.php | 234 ++++-- app/Actions/Database/StopDatabaseProxy.php | 62 +- app/Jobs/ApplicationDeploymentJob.php | 17 + app/Jobs/DeleteResourceJob.php | 9 + app/Livewire/Server/Show.php | 62 ++ app/Models/ServerSetting.php | 26 + app/Services/EdgeProxyRemoteRouteService.php | 448 ++++++++++- ...aster_domain_router_to_server_settings.php | 26 + .../views/livewire/server/show.blade.php | 14 + .../ServerMasterDomainRouterSettingTest.php | 68 ++ tests/Unit/DatabaseProxyMasterRoutingTest.php | 696 ++++++++++++++++++ .../Unit/EdgeProxyRemoteRouteServiceTest.php | 403 ++++++++++ 12 files changed, 1987 insertions(+), 78 deletions(-) create mode 100644 database/migrations/2026_03_05_000001_add_master_domain_router_to_server_settings.php create mode 100644 tests/Feature/ServerMasterDomainRouterSettingTest.php create mode 100644 tests/Unit/DatabaseProxyMasterRoutingTest.php diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 4331c6ae7..a2f4d0987 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -3,6 +3,7 @@ namespace App\Actions\Database; use App\Models\ServiceDatabase; +use App\Models\Server; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -24,7 +25,7 @@ class StartDatabaseProxy { $databaseType = $database->database_type; $network = data_get($database, 'destination.network'); - $server = data_get($database, 'destination.server'); + $deploymentServer = data_get($database, 'destination.server'); $containerName = data_get($database, 'uuid'); $proxyContainerName = "{$database->uuid}-proxy"; $isSSLEnabled = $database->enable_ssl ?? false; @@ -32,7 +33,7 @@ class StartDatabaseProxy if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { $databaseType = $database->databaseType(); $network = $database->service->uuid; - $server = data_get($database, 'service.destination.server'); + $deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server'); $containerName = "{$database->name}-{$database->service->uuid}"; } $internalPort = match ($databaseType) { @@ -50,10 +51,30 @@ class StartDatabaseProxy }; } - $configuration_dir = database_proxy_dir($database->uuid); - if (isDev()) { - $configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + if (! $deploymentServer instanceof Server) { + throw new \RuntimeException('Cannot start database proxy: deployment server is missing.'); } + + $proxyServer = $deploymentServer; + $upstreamTarget = "{$containerName}:{$internalPort}"; + $proxyNetwork = $network; + + $edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database)); + if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) { + $remoteHost = $this->resolveRemoteHost($deploymentServer); + if (! is_null($remoteHost)) { + $proxyServer = $edgeProxyServer; + $upstreamTarget = "{$remoteHost}:{$internalPort}"; + $proxyNetwork = null; + } else { + $this->logWarning(sprintf( + 'Database proxy for %s is falling back to deployment server because remote host for edge forwarding is missing.', + $database->uuid + )); + } + } + + $configuration_dir = $this->resolveConfigurationDirectory($database->uuid); $nginxconf = <<public_port; - proxy_pass $containerName:$internalPort; + proxy_pass $upstreamTarget; } } EOF; - $docker_compose = [ - 'services' => [ - $proxyContainerName => [ - 'image' => 'nginx:stable-alpine', - 'container_name' => $proxyContainerName, - 'restart' => RESTART_MODE, - 'ports' => [ - "$database->public_port:$database->public_port", - ], - 'networks' => [ - $network, - ], - 'volumes' => [ - [ - 'type' => 'bind', - 'source' => "$configuration_dir/nginx.conf", - 'target' => '/etc/nginx/nginx.conf', - ], - ], - 'healthcheck' => [ - 'test' => [ - 'CMD-SHELL', - 'stat /etc/nginx/nginx.conf || exit 1', - ], - 'interval' => '5s', - 'timeout' => '5s', - 'retries' => 3, - 'start_period' => '1s', - ], + $proxyServiceCompose = [ + 'image' => 'nginx:stable-alpine', + 'container_name' => $proxyContainerName, + 'restart' => RESTART_MODE, + 'ports' => [ + "$database->public_port:$database->public_port", + ], + 'volumes' => [ + [ + 'type' => 'bind', + 'source' => "$configuration_dir/nginx.conf", + 'target' => '/etc/nginx/nginx.conf', ], ], - 'networks' => [ - $network => [ - 'external' => true, - 'name' => $network, - 'attachable' => true, + 'healthcheck' => [ + 'test' => [ + 'CMD-SHELL', + 'stat /etc/nginx/nginx.conf || exit 1', ], + 'interval' => '5s', + 'timeout' => '5s', + 'retries' => 3, + 'start_period' => '1s', ], ]; + + $docker_compose = [ + 'services' => [ + $proxyContainerName => $proxyServiceCompose, + ], + ]; + + if (filled($proxyNetwork)) { + $docker_compose['services'][$proxyContainerName]['networks'] = [$proxyNetwork]; + $docker_compose['networks'] = [ + $proxyNetwork => [ + 'external' => true, + 'name' => $proxyNetwork, + 'attachable' => true, + ], + ]; + } + $dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2)); $nginxconf_base64 = base64_encode($nginxconf); - instant_remote_process(["docker rm -f $proxyContainerName"], $server, false); + $this->runRemoteCommands(["docker rm -f $proxyContainerName"], $deploymentServer, false); + if ($proxyServer->id !== $deploymentServer->id) { + $this->runRemoteCommands(["docker rm -f $proxyContainerName"], $proxyServer, false); + } try { - instant_remote_process([ + $this->runRemoteCommands([ "mkdir -p $configuration_dir", "echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null", "echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null", "docker compose --project-directory {$configuration_dir} pull", "docker compose --project-directory {$configuration_dir} up -d", - ], $server); + ], $proxyServer); } catch (\RuntimeException $e) { if ($this->isNonTransientError($e->getMessage())) { $database->update(['is_public' => false]); @@ -131,7 +159,7 @@ class StartDatabaseProxy $team?->notify( new \App\Notifications\Container\ContainerRestarted( "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", - $server, + $proxyServer, ) ); @@ -144,6 +172,124 @@ class StartDatabaseProxy } } + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + return instant_remote_process($commands, $server, $throwError); + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + $configurationDirectory = database_proxy_dir($databaseUuid); + if (isDev()) { + $configurationDirectory = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$databaseUuid.'/proxy'; + } + + return $configurationDirectory; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + if (is_null($teamId)) { + return null; + } + + return Server::query() + ->where('team_id', $teamId) + ->whereRelation('settings', 'is_master_domain_router_enabled', true) + ->orderBy('id') + ->first(); + } + + private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database): ?int + { + if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + $teamId = data_get($database, 'service.environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + $teamId = data_get($database, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + $teamId = data_get($database, 'team.id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + return null; + } + + private function resolveRemoteHost(Server $deploymentServer): ?string + { + $candidates = [ + data_get($deploymentServer, 'proxy.wireguard_ip'), + data_get($deploymentServer, 'proxy.wg_ip'), + data_get($deploymentServer, 'proxy.tunnel_ip'), + data_get($deploymentServer, 'proxy.tunnel_host'), + data_get($deploymentServer, 'proxy.tunnel_domain'), + data_get($deploymentServer, 'ip'), + ]; + + foreach ($candidates as $candidate) { + $normalizedHost = $this->normalizeRemoteHost((string) $candidate); + if (! is_null($normalizedHost)) { + return $normalizedHost; + } + } + + return null; + } + + private function normalizeRemoteHost(string $rawHost): ?string + { + $host = trim($rawHost); + if ($host === '') { + return null; + } + + if (str_starts_with($host, 'http://') || str_starts_with($host, 'https://')) { + $parsedHost = parse_url($host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } elseif (str_contains($host, '/')) { + $parsedHost = parse_url('http://'.$host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } + + $host = trim($host, '[]'); + if ($host === '') { + return null; + } + + if (str_contains($host, ':') && ! filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $parsedHost = parse_url('http://'.$host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : ''; + } + + if ($host === '') { + return null; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return '['.$host.']'; + } + + return $host; + } + + protected function logWarning(string $message): void + { + if (app()->bound('log')) { + app('log')->warning($message); + + return; + } + + error_log($message); + } + private function isNonTransientError(string $message): bool { $nonTransientPatterns = [ diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a109766..62b8a4779 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -4,6 +4,7 @@ namespace App\Actions\Database; use App\Events\DatabaseProxyStopped; use App\Models\ServiceDatabase; +use App\Models\Server; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -22,16 +23,69 @@ class StopDatabaseProxy public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database) { - $server = data_get($database, 'destination.server'); + $deploymentServer = data_get($database, 'destination.server'); $uuid = $database->uuid; if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { - $server = data_get($database, 'service.server'); + $deploymentServer = data_get($database, 'service.destination.server') ?? data_get($database, 'service.server'); + } + if (! $deploymentServer instanceof Server) { + return; + } + + $this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $deploymentServer, false); + $edgeProxyServer = $this->resolveEdgeProxyServerForTeamId($this->resolveDatabaseTeamId($database)); + if ($edgeProxyServer instanceof Server && $edgeProxyServer->id !== $deploymentServer->id) { + $this->runRemoteCommands(["docker rm -f {$uuid}-proxy"], $edgeProxyServer, false); } - instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); $database->save(); - DatabaseProxyStopped::dispatch(); + $this->dispatchDatabaseProxyStoppedEvent(); } + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + return instant_remote_process($commands, $server, $throwError); + } + + protected function dispatchDatabaseProxyStoppedEvent(): void + { + DatabaseProxyStopped::dispatch(); + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + if (is_null($teamId)) { + return null; + } + + return Server::query() + ->where('team_id', $teamId) + ->whereRelation('settings', 'is_master_domain_router_enabled', true) + ->orderBy('id') + ->first(); + } + + private function resolveDatabaseTeamId(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database): ?int + { + if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + $teamId = data_get($database, 'service.environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + $teamId = data_get($database, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + $teamId = data_get($database, 'team.id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + return null; + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index dfcf9ee09..b21b3daed 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Services\EdgeProxyRemoteRouteService; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; use Carbon\Carbon; @@ -508,6 +509,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } catch (\Exception $e) { \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } + + if ($this->pull_request_id === 0) { + try { + $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class) + ->syncApplicationOnDeploymentServer($this->application, $this->mainServer); + foreach ($edgeRoutingWarnings as $warning) { + $this->application_deployment_queue->addLogEntry("Edge proxy routing warning: {$warning}", 'stderr'); + } + } catch (\Throwable $e) { + \Log::warning('Failed to sync edge proxy route for application deployment '.$this->deployment_uuid.': '.$e->getMessage()); + $this->application_deployment_queue->addLogEntry( + 'Edge proxy routing warning: Failed to sync edge proxy route configuration. Check master domain server settings and edge proxy connectivity.', + 'stderr' + ); + } + } } private function deploy_simple_dockerfile() diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 825604910..cff312716 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -7,6 +7,7 @@ use App\Actions\Database\StopDatabase; use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; +use App\Services\EdgeProxyRemoteRouteService; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Service; @@ -102,6 +103,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } catch (\Throwable $e) { throw $e; } finally { + if ($this->resource instanceof Application) { + try { + app(EdgeProxyRemoteRouteService::class)->deleteApplication($this->resource); + } catch (\Throwable $e) { + \Log::warning('Failed to delete edge proxy route file for application '.$this->resource->uuid.': '.$e->getMessage()); + } + } + $this->resource->forceDelete(); if ($this->dockerCleanup) { $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..00bcb243c 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; +use App\Enums\ProxyTypes; use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; @@ -45,9 +46,17 @@ class Show extends Component public bool $isBuildServer; + public bool $isMasterDomainRouterEnabled; + #[Locked] public bool $isBuildServerLocked = false; + #[Locked] + public bool $isMasterDomainRouterLocked = false; + + #[Locked] + public ?string $masterDomainRouterLockMessage = null; + public bool $isMetricsEnabled; public string $sentinelToken; @@ -116,6 +125,7 @@ class Show extends Component 'isSwarmManager' => 'required', 'isSwarmWorker' => 'required', 'isBuildServer' => 'required', + 'isMasterDomainRouterEnabled' => 'required', 'isMetricsEnabled' => 'required', 'sentinelToken' => 'required', 'sentinelUpdatedAt' => 'nullable', @@ -162,6 +172,7 @@ class Show extends Component if (! $this->server->isEmpty()) { $this->isBuildServerLocked = true; } + $this->refreshMasterDomainRouterLockState(); // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->isValidating = $this->server->is_validating ?? false; @@ -213,6 +224,7 @@ class Show extends Component $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; $this->server->settings->is_build_server = $this->isBuildServer; + $this->server->settings->is_master_domain_router_enabled = $this->isMasterDomainRouterEnabled; $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; @@ -243,6 +255,7 @@ class Show extends Component $this->isSwarmManager = $this->server->settings->is_swarm_manager; $this->isSwarmWorker = $this->server->settings->is_swarm_worker; $this->isBuildServer = $this->server->settings->is_build_server; + $this->isMasterDomainRouterEnabled = $this->server->settings->is_master_domain_router_enabled; $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; @@ -255,6 +268,8 @@ class Show extends Component $this->serverTimezone = $this->server->settings->server_timezone; $this->isValidating = $this->server->is_validating ?? false; } + + $this->refreshMasterDomainRouterLockState(); } public function refresh() @@ -353,6 +368,25 @@ class Show extends Component } } + public function updatedIsMasterDomainRouterEnabled($value) + { + try { + $this->authorize('update', $this->server); + $this->refreshMasterDomainRouterLockState(); + + if ($value === true && $this->isMasterDomainRouterLocked) { + $this->isMasterDomainRouterEnabled = false; + $this->dispatch('error', $this->masterDomainRouterLockMessage ?? 'Another server in this team is already selected as the master domain router.'); + + return; + } + + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function updatedIsSentinelEnabled($value) { try { @@ -492,6 +526,34 @@ class Show extends Component } } + private function refreshMasterDomainRouterLockState(): void + { + $this->isMasterDomainRouterLocked = false; + $this->masterDomainRouterLockMessage = null; + + if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) { + return; + } + + $this->server->loadMissing('settings'); + if ($this->server->settings->is_master_domain_router_enabled) { + return; + } + + $masterDomainServerExists = Server::query() + ->where('team_id', $this->server->team_id) + ->where('id', '!=', $this->server->id) + ->whereRelation('settings', 'is_master_domain_router_enabled', true) + ->exists(); + + if (! $masterDomainServerExists) { + return; + } + + $this->isMasterDomainRouterLocked = true; + $this->masterDomainRouterLockMessage = 'Disabled because another server in this team is already selected as the master domain router. Disable it there first.'; + } + public function loadHetznerTokens(): void { $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam() diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0ad0fcf84..d3e2f0692 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -19,6 +19,7 @@ use OpenApi\Attributes as OA; 'force_server_cleanup' => ['type' => 'boolean'], 'is_build_server' => ['type' => 'boolean'], 'is_cloudflare_tunnel' => ['type' => 'boolean'], + 'is_master_domain_router_enabled' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], 'is_logdrain_custom_enabled' => ['type' => 'boolean'], @@ -62,6 +63,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', + 'is_master_domain_router_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', ]; @@ -90,6 +92,30 @@ class ServerSetting extends Model $settings->server->restartSentinel(); } }); + static::saving(function ($setting) { + $setting->ensureSingleMasterDomainRouterEnabled(); + }); + } + + private function ensureSingleMasterDomainRouterEnabled(): void + { + if (! $this->is_master_domain_router_enabled || is_null($this->server_id)) { + return; + } + + $teamId = Server::query() + ->whereKey($this->server_id) + ->value('team_id'); + + if (is_null($teamId)) { + return; + } + + static::query() + ->where('server_id', '!=', $this->server_id) + ->where('is_master_domain_router_enabled', true) + ->whereHas('server', fn ($query) => $query->where('team_id', $teamId)) + ->update(['is_master_domain_router_enabled' => false]); } public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false) diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php index f7ce93202..a92425696 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Enums\ProxyTypes; +use App\Models\Application; use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; @@ -14,11 +15,14 @@ use Symfony\Component\Yaml\Yaml; class EdgeProxyRemoteRouteService { - private const string ROUTE_FILE_PREFIX = 'service-remote-'; + private const string SERVICE_ROUTE_FILE_PREFIX = 'service-remote-'; + + private const string APPLICATION_ROUTE_FILE_PREFIX = 'application-remote-'; public function syncService(Service $service): array { - $edgeProxyServer = $this->resolveEdgeProxyServer($service); + $teamId = $this->extractServiceTeamId($service); + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId); $deploymentServer = $this->resolveDeploymentServer($service); if (! $deploymentServer instanceof Server) { @@ -26,16 +30,6 @@ class EdgeProxyRemoteRouteService } if (! $edgeProxyServer instanceof Server) { - if ($deploymentServer->id !== 0) { - $warning = sprintf( - 'Edge proxy route skipped for service %s: edge proxy server (id=0) was not found for the current team.', - $service->uuid - ); - $this->logWarning($warning); - - return [$warning]; - } - return []; } @@ -79,7 +73,7 @@ class EdgeProxyRemoteRouteService $routes = []; $warnings = []; - $networkOverlapWarning = $this->detectDockerNetworkOverlapWarning($service, $edgeProxyServer, $tunnelHost); + $networkOverlapWarning = $this->detectDockerNetworkOverlapWarningForResource('service', $service->uuid, $edgeProxyServer, $tunnelHost); if (! is_null($networkOverlapWarning)) { $warnings[] = $networkOverlapWarning; } @@ -165,9 +159,156 @@ class EdgeProxyRemoteRouteService return $warnings; } + public function syncApplication(Application $application): array + { + $teamId = $this->extractApplicationTeamId($application); + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId); + $deploymentServer = $this->resolveApplicationDeploymentServer($application); + + if (! $deploymentServer instanceof Server || ! $edgeProxyServer instanceof Server) { + return []; + } + + return $this->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + } + + public function syncApplicationOnDeploymentServer(Application $application, Server $deploymentServer): array + { + $teamId = $this->extractApplicationTeamId($application); + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId); + if (! $edgeProxyServer instanceof Server) { + return []; + } + + return $this->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + } + + public function syncApplicationWithServers(Application $application, Server $edgeProxyServer, Server $deploymentServer): array + { + if ($edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + if ($deploymentServer->id === $edgeProxyServer->id) { + $this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX); + + return []; + } + + $domains = $this->getApplicationDomains($application); + if ($domains->isEmpty()) { + $this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX); + + return []; + } + + $tunnelHost = $this->resolveTunnelHost($deploymentServer); + if (blank($tunnelHost)) { + $warning = sprintf( + 'Edge proxy route skipped for application %s: remote host is missing. Configure a tunnel host (proxy.wireguard_ip/proxy.wg_ip/proxy.tunnel_ip/proxy.tunnel_host) or set the server IP/domain.', + $application->uuid + ); + + $this->logWarning($warning); + $this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX); + + return [$warning]; + } + + $routes = []; + $warnings = []; + $networkOverlapWarning = $this->detectDockerNetworkOverlapWarningForResource('application', $application->uuid, $edgeProxyServer, $tunnelHost); + if (! is_null($networkOverlapWarning)) { + $warnings[] = $networkOverlapWarning; + } + + $compose = $this->parseApplicationCompose($application); + $environmentMap = $this->applicationEnvironmentMap($application); + + foreach ($domains as $domainData) { + $domain = data_get($domainData, 'domain'); + $composeServiceName = data_get($domainData, 'service_name'); + + $unsupportedProtocol = $this->detectUnsupportedDomainProtocol($domain); + if (! is_null($unsupportedProtocol)) { + $warnings[] = sprintf( + 'Edge proxy route skipped for application %s (domain %s): protocol "%s" is not supported for edge remote routing. Only http:// and https:// domains are currently supported.', + $application->uuid, + $domain, + $unsupportedProtocol + ); + + continue; + } + + $url = $this->parseDomainUrl($domain); + if (! $url instanceof Url) { + $warnings[] = sprintf( + 'Edge proxy route skipped for application %s (domain %s): domain format is invalid. Use a valid hostname/domain with optional scheme, port and path.', + $application->uuid, + $domain + ); + + continue; + } + + $requestedInternalPort = $url->getPort(); + $publishedPort = $this->resolvePublishedPortForApplication( + $application, + $requestedInternalPort, + $composeServiceName, + $compose, + $environmentMap + ); + + if (is_null($publishedPort)) { + $warnings[] = sprintf( + 'Edge proxy route skipped for application %s (domain %s): published host port could not be resolved. Expose the application port in host mappings/compose ports and/or include an explicit port in the domain.', + $application->uuid, + $domain + ); + + continue; + } + + $routes[] = [ + 'host' => $url->getHost(), + 'path' => $url->getPath(), + 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ]; + } + + if (! empty($warnings)) { + foreach ($warnings as $warning) { + $this->logWarning($warning); + } + } + + if (empty($routes)) { + $this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX); + + return $warnings; + } + + $config = $this->generateTraefikConfig($application->uuid, $routes); + try { + $this->writeRouteFile($edgeProxyServer, $application->uuid, $config, self::APPLICATION_ROUTE_FILE_PREFIX); + } catch (\Throwable $exception) { + $warning = sprintf( + 'Edge proxy route partially applied for application %s: failed to write dynamic route configuration on edge proxy (%s).', + $application->uuid, + $exception->getMessage() + ); + $this->logWarning($warning); + $warnings[] = $warning; + } + + return $warnings; + } + public function deleteService(Service $service): void { - $edgeProxyServer = $this->resolveEdgeProxyServer($service); + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service)); if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { return; } @@ -180,6 +321,21 @@ class EdgeProxyRemoteRouteService $this->deleteRouteFile($edgeProxyServer, $service->uuid); } + public function deleteApplication(Application $application): void + { + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)); + if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return; + } + + $this->deleteApplicationWithServer($application, $edgeProxyServer); + } + + public function deleteApplicationWithServer(Application $application, Server $edgeProxyServer): void + { + $this->deleteRouteFile($edgeProxyServer, $application->uuid, self::APPLICATION_ROUTE_FILE_PREFIX); + } + public function generateTraefikConfig(string $serviceUuid, array $routes): array { $serviceKey = Str::slug($serviceUuid); @@ -240,12 +396,22 @@ class EdgeProxyRemoteRouteService } public function routeFilePath(Server $edgeProxyServer, string $serviceUuid): string + { + return $this->resourceRouteFilePath($edgeProxyServer, self::SERVICE_ROUTE_FILE_PREFIX, $serviceUuid); + } + + public function applicationRouteFilePath(Server $edgeProxyServer, string $applicationUuid): string + { + return $this->resourceRouteFilePath($edgeProxyServer, self::APPLICATION_ROUTE_FILE_PREFIX, $applicationUuid); + } + + private function resourceRouteFilePath(Server $edgeProxyServer, string $prefix, string $resourceUuid): string { return sprintf( '%s/%s%s.yaml', $this->routeDirectoryPath($edgeProxyServer), - self::ROUTE_FILE_PREFIX, - $serviceUuid + $prefix, + $resourceUuid ); } @@ -259,13 +425,13 @@ class EdgeProxyRemoteRouteService return rtrim($edgeProxyServer->proxyPath(), '/').'/dynamic'; } - private function writeRouteFile(Server $edgeProxyServer, string $serviceUuid, array $config): void + private function writeRouteFile(Server $edgeProxyServer, string $resourceUuid, array $config, string $filePrefix = self::SERVICE_ROUTE_FILE_PREFIX): void { $yaml = Yaml::dump($config, 12, 2); $banner = "# This file is generated by Coolify, do not edit it manually.\n\n"; $payload = base64_encode($banner.$yaml); - $routeFilePath = $this->routeFilePath($edgeProxyServer, $serviceUuid); + $routeFilePath = $this->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid); $temporaryRouteFilePath = $routeFilePath.'.tmp'; $escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer)); @@ -279,10 +445,11 @@ class EdgeProxyRemoteRouteService ]); } - private function deleteRouteFile(Server $edgeProxyServer, string $serviceUuid): void + private function deleteRouteFile(Server $edgeProxyServer, string $resourceUuid, string $filePrefix = self::SERVICE_ROUTE_FILE_PREFIX): void { - $escapedFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid)); - $escapedTemporaryFilePath = escapeshellarg($this->routeFilePath($edgeProxyServer, $serviceUuid).'.tmp'); + $routeFilePath = $this->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid); + $escapedFilePath = escapeshellarg($routeFilePath); + $escapedTemporaryFilePath = escapeshellarg($routeFilePath.'.tmp'); $this->runRemoteCommands($edgeProxyServer, [ "rm -f $escapedFilePath $escapedTemporaryFilePath", @@ -300,16 +467,16 @@ class EdgeProxyRemoteRouteService return $rule; } - private function resolveEdgeProxyServer(Service $service): ?Server + private function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server { - $teamId = $this->extractTeamId($service); if (is_null($teamId)) { return null; } return Server::query() ->where('team_id', $teamId) - ->where('id', 0) + ->whereRelation('settings', 'is_master_domain_router_enabled', true) + ->orderBy('id') ->first(); } @@ -332,7 +499,31 @@ class EdgeProxyRemoteRouteService return null; } - private function extractTeamId(Service $service): ?int + private function resolveApplicationDeploymentServer(Application $application): ?Server + { + $server = data_get($application, 'server'); + if ($server instanceof Server) { + return $server; + } + + $server = data_get($application, 'destination.server'); + if ($server instanceof Server) { + return $server; + } + + if ($application->exists) { + $application->loadMissing('destination.server'); + + $server = data_get($application, 'destination.server'); + if ($server instanceof Server) { + return $server; + } + } + + return null; + } + + private function extractServiceTeamId(Service $service): ?int { $teamId = data_get($service, 'environment.project.team_id'); if (! is_null($teamId)) { @@ -350,6 +541,24 @@ class EdgeProxyRemoteRouteService return null; } + private function extractApplicationTeamId(Application $application): ?int + { + $teamId = data_get($application, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + + if ($application->exists) { + $application->loadMissing('environment.project'); + $teamId = data_get($application, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + return null; + } + private function getServiceApplicationsWithDomains(Service $service): Collection { $applications = collect([]); @@ -365,6 +574,48 @@ class EdgeProxyRemoteRouteService ->values(); } + private function getApplicationDomains(Application $application): Collection + { + if ($application->build_pack === 'dockercompose') { + $domains = collect(json_decode((string) $application->docker_compose_domains, true)); + if ($domains->isEmpty()) { + return collect([]); + } + + return $domains + ->map(function (mixed $domainConfig, string $serviceName) { + $domain = data_get($domainConfig, 'domain'); + if (is_string($domainConfig)) { + $domain = $domainConfig; + } + + return [ + 'service_name' => $serviceName, + 'domain' => (string) $domain, + ]; + }) + ->flatMap(function (array $domainData) { + return collect(explode(',', $domainData['domain'])) + ->map(fn (string $domain) => trim($domain)) + ->filter() + ->map(fn (string $domain) => [ + 'service_name' => $domainData['service_name'], + 'domain' => $domain, + ]); + }) + ->values(); + } + + return collect(explode(',', (string) $application->fqdn)) + ->map(fn (string $domain) => trim($domain)) + ->filter() + ->map(fn (string $domain) => [ + 'service_name' => null, + 'domain' => $domain, + ]) + ->values(); + } + private function resolveTunnelHost(Server $deploymentServer): ?string { $candidates = [ @@ -419,6 +670,82 @@ class EdgeProxyRemoteRouteService ->all(); } + private function parseApplicationCompose(Application $application): array + { + if ($application->build_pack !== 'dockercompose') { + return []; + } + + $rawCompose = filled($application->docker_compose_raw) + ? $application->docker_compose_raw + : $application->docker_compose; + + if (blank($rawCompose)) { + return []; + } + + try { + $parsedCompose = Yaml::parse($rawCompose); + + return is_array($parsedCompose) ? $parsedCompose : []; + } catch (\Throwable) { + return []; + } + } + + private function applicationEnvironmentMap(Application $application): array + { + if ($application->relationLoaded('environment_variables')) { + return $application->environment_variables + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + if (! $application->exists) { + return []; + } + + return $application->environment_variables() + ->get() + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + private function resolvePublishedPortForApplication( + Application $application, + ?int $requestedInternalPort, + ?string $composeServiceName, + array $compose, + array $environmentMap + ): ?int { + if ($application->build_pack === 'dockercompose') { + $serviceName = $composeServiceName; + if (blank($serviceName)) { + $serviceName = $application->uuid; + } + + return $this->resolvePublishedPort($compose, $serviceName, $requestedInternalPort, $environmentMap); + } + + $portMappings = $this->parsePortMappings($application->ports_mappings_array, $environmentMap) + ->filter(fn (array $mapping) => ! is_null($mapping['published'])) + ->values(); + + if ($portMappings->isEmpty()) { + return null; + } + + $fallbackInternalPort = null; + if (is_null($requestedInternalPort)) { + $mainPorts = $application->main_port(); + if (is_array($mainPorts) && count($mainPorts) === 1 && is_numeric($mainPorts[0])) { + $fallbackInternalPort = (int) $mainPorts[0]; + } + } + + return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort, $fallbackInternalPort); + } + private function detectUnsupportedDomainProtocol(string $domain): ?string { $trimmedDomain = trim($domain); @@ -434,7 +761,7 @@ class EdgeProxyRemoteRouteService return $protocol; } - private function detectDockerNetworkOverlapWarning(Service $service, Server $edgeProxyServer, string $tunnelHost): ?string + private function detectDockerNetworkOverlapWarningForResource(string $resourceType, string $resourceUuid, Server $edgeProxyServer, string $tunnelHost): ?string { // Only run this for persisted server models (normal runtime) to avoid noisy checks in synthetic test stubs. if (! $edgeProxyServer->exists) { @@ -449,8 +776,9 @@ class EdgeProxyRemoteRouteService foreach ($this->resolveEdgeDockerSubnets($edgeProxyServer) as $subnet) { if ($this->ipv4InCidr($normalizedTunnelHost, $subnet)) { return sprintf( - 'Edge proxy route warning for service %s: remote host %s overlaps edge Docker network subnet %s. This can break VPN/WireGuard routing. Configure Docker default-address-pools to a non-overlapping range (for example base 172.20.0.0/16 with size 24) and recreate overlapping networks.', - $service->uuid, + 'Edge proxy route warning for %s %s: remote host %s overlaps edge Docker network subnet %s. This can break VPN/WireGuard routing. Configure Docker default-address-pools to a non-overlapping range (for example base 172.20.0.0/16 with size 24) and recreate overlapping networks.', + $resourceType, + $resourceUuid, $normalizedTunnelHost, $subnet ); @@ -554,6 +882,11 @@ class EdgeProxyRemoteRouteService return null; } + return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort); + } + + private function selectPublishedPortFromMappings(Collection $portMappings, ?int $requestedInternalPort, ?int $fallbackInternalPort = null): ?int + { if (! is_null($requestedInternalPort)) { $matchingTarget = $portMappings->first(fn (array $mapping) => $mapping['target'] === $requestedInternalPort); if ($matchingTarget) { @@ -566,6 +899,13 @@ class EdgeProxyRemoteRouteService } } + if (! is_null($fallbackInternalPort)) { + $matchingTarget = $portMappings->first(fn (array $mapping) => $mapping['target'] === $fallbackInternalPort); + if ($matchingTarget) { + return $matchingTarget['published']; + } + } + if ($portMappings->count() === 1) { return $portMappings->first()['published']; } @@ -586,6 +926,18 @@ class EdgeProxyRemoteRouteService return is_array($ports) ? $ports : null; } + $normalizedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + foreach ($services as $composeServiceName => $serviceConfig) { + $normalizedComposeServiceName = str((string) $composeServiceName)->replace('-', '_')->replace('.', '_')->value(); + if ($normalizedComposeServiceName !== $normalizedServiceName) { + continue; + } + + $ports = data_get($serviceConfig, 'ports'); + + return is_array($ports) ? $ports : null; + } + // Defensive fallback for templates where application name does not match compose key. $servicesWithPorts = collect($services) ->filter(fn (mixed $serviceConfig) => is_array($serviceConfig) && is_array(data_get($serviceConfig, 'ports')) && ! empty(data_get($serviceConfig, 'ports'))) @@ -649,7 +1001,7 @@ class EdgeProxyRemoteRouteService } if (str_contains($normalizedPortDefinition, ':')) { - $segments = explode(':', $normalizedPortDefinition); + $segments = $this->splitPortDefinitionSegments($normalizedPortDefinition); if (count($segments) < 2) { return null; } @@ -669,6 +1021,37 @@ class EdgeProxyRemoteRouteService ]; } + private function splitPortDefinitionSegments(string $portDefinition): array + { + $segments = []; + $currentSegment = ''; + $braceDepth = 0; + $length = strlen($portDefinition); + + for ($index = 0; $index < $length; $index++) { + $character = $portDefinition[$index]; + + if ($character === '{') { + $braceDepth++; + } elseif ($character === '}' && $braceDepth > 0) { + $braceDepth--; + } + + if ($character === ':' && $braceDepth === 0) { + $segments[] = $currentSegment; + $currentSegment = ''; + + continue; + } + + $currentSegment .= $character; + } + + $segments[] = $currentSegment; + + return $segments; + } + private function resolvePortValue(mixed $rawPortValue, array $environmentMap): ?int { if (is_int($rawPortValue)) { @@ -676,7 +1059,12 @@ class EdgeProxyRemoteRouteService } $normalizedPortValue = trim((string) $rawPortValue); - if ($normalizedPortValue === '' || str_contains($normalizedPortValue, '-')) { + if ($normalizedPortValue === '') { + return null; + } + + // Ignore numeric port ranges (for example 8000-8010), which cannot be mapped deterministically. + if (preg_match('/^\d+\s*-\s*\d+$/', $normalizedPortValue) === 1) { return null; } diff --git a/database/migrations/2026_03_05_000001_add_master_domain_router_to_server_settings.php b/database/migrations/2026_03_05_000001_add_master_domain_router_to_server_settings.php new file mode 100644 index 000000000..10fc382b2 --- /dev/null +++ b/database/migrations/2026_03_05_000001_add_master_domain_router_to_server_settings.php @@ -0,0 +1,26 @@ +boolean('is_master_domain_router_enabled')->default(false)->after('is_cloudflare_tunnel'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('server_settings', 'is_master_domain_router_enabled')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('is_master_domain_router_enabled'); + }); + } + } +}; diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index f58dc058b..f619ca501 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -286,6 +286,20 @@ @endif + @if ($server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value) +
+ @if ($isMasterDomainRouterLocked) + + @else + + @endif +
+ @endif diff --git a/tests/Feature/ServerMasterDomainRouterSettingTest.php b/tests/Feature/ServerMasterDomainRouterSettingTest.php new file mode 100644 index 000000000..99c5129f3 --- /dev/null +++ b/tests/Feature/ServerMasterDomainRouterSettingTest.php @@ -0,0 +1,68 @@ +create(); + $team = $user->teams()->first(); + + $firstServer = Server::factory()->create(['team_id' => $team->id]); + $secondServer = Server::factory()->create(['team_id' => $team->id]); + + $firstServer->settings->update(['is_master_domain_router_enabled' => true]); + + expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue() + ->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse(); + + $secondServer->settings->update(['is_master_domain_router_enabled' => true]); + + expect($firstServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse() + ->and($secondServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue(); +}); + +it('does not disable master domain router setting on servers from other teams', function () { + $teamOneUser = User::factory()->create(); + $teamTwoUser = User::factory()->create(); + + $teamOneServer = Server::factory()->create(['team_id' => $teamOneUser->teams()->first()->id]); + $teamTwoServer = Server::factory()->create(['team_id' => $teamTwoUser->teams()->first()->id]); + + $teamOneServer->settings->update(['is_master_domain_router_enabled' => true]); + $teamTwoServer->settings->update(['is_master_domain_router_enabled' => true]); + + expect($teamOneServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue() + ->and($teamTwoServer->settings->fresh()->is_master_domain_router_enabled)->toBeTrue(); +}); + +it('locks master domain router toggle when another server in the same team is already selected', function () { + $user = User::factory()->create(); + $team = $user->teams()->first(); + $this->actingAs($user); + refreshSession($team); + + $masterServer = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $otherServer = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + + $masterServer->settings->update(['is_master_domain_router_enabled' => true]); + + Livewire::test(Show::class, ['server_uuid' => $otherServer->uuid]) + ->assertSet('isMasterDomainRouterLocked', true) + ->assertSet('isMasterDomainRouterEnabled', false) + ->set('isMasterDomainRouterEnabled', true) + ->assertSet('isMasterDomainRouterEnabled', false); + + expect($otherServer->settings->fresh()->is_master_domain_router_enabled)->toBeFalse(); +}); diff --git a/tests/Unit/DatabaseProxyMasterRoutingTest.php b/tests/Unit/DatabaseProxyMasterRoutingTest.php new file mode 100644 index 000000000..63a01e525 --- /dev/null +++ b/tests/Unit/DatabaseProxyMasterRoutingTest.php @@ -0,0 +1,696 @@ +makePartial(); + $edgeServer->id = 1; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 2; + $deploymentServer->ip = '10.8.0.15'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = new StandalonePostgresql; + $database->uuid = 'standalone-db-uuid'; + $database->name = 'standalone-db'; + $database->public_port = 15432; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'standalone-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 123], + ]); + + $action = new class($edgeServer) extends StartDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(3) + ->and($action->calls[0]['server_id'])->toBe(2) + ->and($action->calls[1]['server_id'])->toBe(1) + ->and($action->calls[2]['server_id'])->toBe(1); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 15432;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.15:5432;'); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[2]['commands'][2], $composeMatches); + $dockerCompose = base64_decode($composeMatches[1] ?? ''); + expect($dockerCompose)->not->toContain('external: true') + ->and($dockerCompose)->not->toContain('standalone-network'); +}); + +it('keeps standalone database proxy on deployment server when no master domain router server is configured', function () { + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 2; + $deploymentServer->ip = '10.8.0.25'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = new StandalonePostgresql; + $database->uuid = 'standalone-db-local-uuid'; + $database->name = 'standalone-db-local'; + $database->public_port = 15433; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'standalone-network-local', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 456], + ]); + + $action = new class extends StartDatabaseProxy + { + public array $calls = []; + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return null; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[0]['server_id'])->toBe(2) + ->and($action->calls[1]['server_id'])->toBe(2); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[1]['commands'][1], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('proxy_pass standalone-db-local-uuid:5432;'); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $action->calls[1]['commands'][2], $composeMatches); + $dockerCompose = base64_decode($composeMatches[1] ?? ''); + expect($dockerCompose)->toContain('standalone-network-local') + ->and($dockerCompose)->toContain('external: true'); +}); + +it('runs service database proxy on master domain router server for remote deployments', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 11; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 22; + $deploymentServer->ip = '10.8.0.35'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial(); + $serviceDatabase->uuid = 'service-db-uuid'; + $serviceDatabase->name = 'postgres'; + $serviceDatabase->public_port = 15434; + $serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class); + $serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql'); + $serviceDatabase->setRelation('service', (object) [ + 'uuid' => 'service-uuid', + 'destination' => (object) ['server' => $deploymentServer], + 'environment' => (object) ['project' => (object) ['team_id' => 789]], + ]); + + $action = new class($edgeServer) extends StartDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($serviceDatabase); + + expect($action->calls)->toHaveCount(3) + ->and($action->calls[0]['server_id'])->toBe(22) + ->and($action->calls[1]['server_id'])->toBe(11) + ->and($action->calls[2]['server_id'])->toBe(11); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 15434;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.35:5432;') + ->and($nginxConf)->not->toContain('postgres-service-uuid'); +}); + +it('stops database proxy containers on deployment and master domain router servers', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 101; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 202; + + $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); + $database->uuid = 'stopped-db-uuid'; + $database->shouldReceive('save')->once()->andReturnTrue(); + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 555], + ]); + + $action = new class($edgeServer) extends StopDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function dispatchDatabaseProxyStoppedEvent(): void + { + // No-op in isolated unit test environment. + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[0]['server_id'])->toBe(202) + ->and($action->calls[1]['server_id'])->toBe(101) + ->and($action->calls[0]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy') + ->and($action->calls[1]['commands'][0])->toBe('docker rm -f stopped-db-uuid-proxy'); +}); + +it('falls back to deployment server when master domain router exists but remote host is missing', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 41; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 42; + $deploymentServer->ip = ''; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = new StandalonePostgresql; + $database->uuid = 'standalone-db-fallback-uuid'; + $database->name = 'standalone-db-fallback'; + $database->public_port = 15440; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'fallback-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 321], + ]); + + $action = new class($edgeServer) extends StartDatabaseProxy + { + public array $calls = []; + public array $warnings = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + + protected function logWarning(string $message): void + { + $this->warnings[] = $message; + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[0]['server_id'])->toBe(42) + ->and($action->calls[1]['server_id'])->toBe(42) + ->and($action->warnings)->not->toBeEmpty() + ->and($action->warnings[0])->toContain('falling back to deployment server'); +}); + +it('keeps proxy on deployment server when master router server is the same server', function () { + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 51; + $deploymentServer->ip = '10.8.0.51'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = new StandalonePostgresql; + $database->uuid = 'standalone-db-same-edge-uuid'; + $database->name = 'standalone-db-same-edge'; + $database->public_port = 15451; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'same-edge-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 654], + ]); + + $action = new class($deploymentServer) extends StartDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[0]['server_id'])->toBe(51) + ->and($action->calls[1]['server_id'])->toBe(51); +}); + +it('supports service database deployment server fallback from service.server when destination is missing', function () { + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 61; + $deploymentServer->ip = '10.8.0.61'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $serviceDatabase = \Mockery::mock(ServiceDatabase::class)->makePartial(); + $serviceDatabase->uuid = 'service-db-fallback-server-uuid'; + $serviceDatabase->name = 'postgres'; + $serviceDatabase->public_port = 15461; + $serviceDatabase->shouldReceive('getMorphClass')->andReturn(\App\Models\ServiceDatabase::class); + $serviceDatabase->shouldReceive('databaseType')->andReturn('standalone-postgresql'); + $serviceDatabase->setRelation('service', (object) [ + 'uuid' => 'service-fallback-uuid', + 'server' => $deploymentServer, + 'environment' => (object) ['project' => (object) ['team_id' => 111]], + ]); + + $action = new class extends StartDatabaseProxy + { + public array $calls = []; + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return null; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($serviceDatabase); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[0]['server_id'])->toBe(61) + ->and($action->calls[1]['server_id'])->toBe(61); +}); + +it('uses ssl internal redis port 6380 for remote database proxy upstream target', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 71; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 72; + $deploymentServer->ip = '10.8.0.72'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = \Mockery::mock(\App\Models\StandaloneRedis::class)->makePartial(); + $database->uuid = 'redis-ssl-db-uuid'; + $database->name = 'redis-ssl-db'; + $database->public_port = 16379; + $database->enable_ssl = true; + $database->database_type = 'standalone-redis'; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'redis-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 222], + ]); + + $action = new class($edgeServer) extends StartDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + + expect($nginxConf)->toContain('listen 16379;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.72:6380;'); +}); + +it('disables public database proxy on non-transient startup errors', function () { + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 81; + $deploymentServer->ip = '10.8.0.81'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); + $database->uuid = 'standalone-db-error-uuid'; + $database->name = 'standalone-db-error'; + $database->public_port = 15481; + $database->shouldReceive('update')->once()->with(['is_public' => false])->andReturnTrue(); + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'error-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 333], + ]); + + $action = new class extends StartDatabaseProxy + { + public array $calls = []; + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + if (str_contains(implode("\n", $commands), 'up -d')) { + throw new \RuntimeException('Bind for 0.0.0.0:15481 failed: port is already allocated'); + } + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return null; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(2) + ->and($action->calls[1]['server_id'])->toBe(81); +}); + +it('does not try to stop database proxy when deployment server is missing', function () { + $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); + $database->uuid = 'stop-missing-server-db-uuid'; + $database->setRelation('destination', (object) [ + 'server' => null, + ]); + + $action = new class extends StopDatabaseProxy + { + public array $calls = []; + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function dispatchDatabaseProxyStoppedEvent(): void + { + // No-op in isolated unit test environment. + } + }; + + $action->handle($database); + + expect($action->calls)->toBe([]); +}); + +it('stops proxy only once when edge and deployment server are the same', function () { + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 91; + + $database = \Mockery::mock(StandalonePostgresql::class)->makePartial(); + $database->uuid = 'stop-same-edge-db-uuid'; + $database->shouldReceive('save')->once()->andReturnTrue(); + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 444], + ]); + + $action = new class($deploymentServer) extends StopDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function dispatchDatabaseProxyStoppedEvent(): void + { + // No-op in isolated unit test environment. + } + }; + + $action->handle($database); + + expect($action->calls)->toHaveCount(1) + ->and($action->calls[0]['server_id'])->toBe(91) + ->and($action->calls[0]['commands'][0])->toBe('docker rm -f stop-same-edge-db-uuid-proxy'); +}); + +it('throws clear error when starting database proxy without a deployment server', function () { + $database = new StandalonePostgresql; + $database->uuid = 'missing-deployment-server-db-uuid'; + $database->name = 'missing-deployment-server-db'; + $database->public_port = 15500; + $database->setRelation('destination', (object) [ + 'server' => null, + 'network' => 'missing-network', + ]); + + $action = new class extends StartDatabaseProxy + { + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + expect(fn () => $action->handle($database)) + ->toThrow(\RuntimeException::class, 'deployment server is missing'); +}); + +it('normalizes remote host with scheme and path before building database upstream target', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 111; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 112; + $deploymentServer->ip = ''; + $deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.112:8443/path']; + + $database = new StandalonePostgresql; + $database->uuid = 'normalized-remote-host-db-uuid'; + $database->name = 'normalized-remote-host-db'; + $database->public_port = 15501; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'normalized-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 987], + ]); + + $action = new class($edgeServer) extends StartDatabaseProxy + { + public array $calls = []; + + public function __construct(private ?Server $edgeServer) {} + + protected function runRemoteCommands(array $commands, Server $server, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + + protected function resolveEdgeProxyServerForTeamId(?int $teamId): ?Server + { + return $this->edgeServer; + } + + protected function resolveConfigurationDirectory(string $databaseUuid): string + { + return "/tmp/database-proxy/{$databaseUuid}"; + } + }; + + $action->handle($database); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $action->calls[2]['commands'][1], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + + expect($nginxConf)->toContain('proxy_pass 10.8.0.112:5432;') + ->and($nginxConf)->not->toContain('8443'); +}); diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php index c3585b8f3..575188da3 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -1,5 +1,6 @@ toContain("rm -f '$expectedPath' '$expectedTempPath'"); }); +it('creates, updates, and deletes a stable edge route file per application uuid', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'server_id' => $server->id, + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 30; + $deploymentServer->ip = '10.8.0.30'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-test-uuid'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://app.example.com:3000'; + $application->ports_mappings = '9010:3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1); + + $expectedPath = '/tmp/proxy/dynamic/application-remote-application-test-uuid.yaml'; + $expectedTempPath = '/tmp/proxy/dynamic/application-remote-application-test-uuid.yaml.tmp'; + $firstWriteCommands = implode("\n", $manager->calls[0]['commands']); + + expect($firstWriteCommands)->toContain($expectedPath) + ->and($firstWriteCommands)->toContain($expectedTempPath) + ->and($firstWriteCommands)->toContain('tee') + ->and($firstWriteCommands)->toContain('mv'); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $firstPayloadMatches); + $firstPayload = base64_decode($firstPayloadMatches[1]); + expect($firstPayload)->toContain('http://10.8.0.30:9010'); + + $application->ports_mappings = '9020:3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(2); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches); + $secondPayload = base64_decode($secondPayloadMatches[1]); + expect($secondPayload)->toContain('http://10.8.0.30:9020'); + + $manager->deleteApplicationWithServer($application, $edgeProxyServer); + + expect($manager->calls)->toHaveCount(3); + $deleteCommands = implode("\n", $manager->calls[2]['commands']); + expect($deleteCommands)->toContain("rm -f '$expectedPath' '$expectedTempPath'"); +}); + +it('creates edge route for docker compose application domains using compose service ports', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 31; + $deploymentServer->ip = '10.8.0.31'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-compose-route'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_domains = json_encode([ + 'web' => ['domain' => 'https://compose-app.example.com:3000'], + ]); + $application->docker_compose_raw = <<<'YAML' +services: + web: + ports: + - "9030:3000" +YAML; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`compose-app.example.com`)') + ->and($payload)->toContain('http://10.8.0.31:9030'); +}); + +it('returns actionable warning and does not write route file when application published host port is missing', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 32; + $deploymentServer->ip = '10.8.0.32'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-missing-port'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://missing-port.example.com:3000'; + $application->ports_mappings = null; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('published host port could not be resolved') + ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/application-remote-application-missing-port.yaml') + ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); +}); + it('does not generate edge route file when published port cannot be resolved and returns actionable warning', function () { $manager = new class extends EdgeProxyRemoteRouteService { @@ -676,3 +837,245 @@ YAML; expect($payload)->toContain('Host(`overlap.example.com`)') ->and($payload)->toContain('http://10.8.0.40:9060'); }); + +it('generates path prefix rule when route contains a non-root path', function () { + $service = new EdgeProxyRemoteRouteService; + + $config = $service->generateTraefikConfig('service-path-uuid', [[ + 'host' => 'api.example.com', + 'path' => '/v1', + 'upstream_url' => 'http://10.8.0.41:9070', + ]]); + + expect(data_get($config, 'http.routers.edge-service-path-uuid-http-1.rule')) + ->toBe('Host(`api.example.com`) && PathPrefix(`/v1`)') + ->and(data_get($config, 'http.routers.edge-service-path-uuid-https-1.rule')) + ->toBe('Host(`api.example.com`) && PathPrefix(`/v1`)'); +}); + +it('deletes application edge route when deployment server is the same as edge proxy server', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 33; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 33; + $deploymentServer->ip = '10.8.0.33'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-edge-self'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://self.example.com:3000'; + $application->ports_mappings = '9071:3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1) + ->and(implode("\n", $manager->calls[0]['commands']))->toContain("/tmp/proxy/dynamic/application-remote-application-edge-self.yaml") + ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); +}); + +it('normalizes ipv6 tunnel host for application upstream urls', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 34; + $deploymentServer->ip = '2001:db8::1'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-ipv6-host'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://ipv6.example.com:3000'; + $application->ports_mappings = '9072:3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + expect($warnings)->toBe([]); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('http://[2001:db8::1]:9072'); +}); + +it('resolves docker compose application ports when domain service uses dashed name and compose uses underscore', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 35; + $deploymentServer->ip = '10.8.0.35'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-compose-normalized-service'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_domains = json_encode([ + 'my-app' => ['domain' => 'https://normalized-service.example.com:3000'], + ]); + $application->docker_compose_raw = <<<'YAML' +services: + my_app: + ports: + - "9073:3000" +YAML; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + expect($warnings)->toBe([]); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`normalized-service.example.com`)') + ->and($payload)->toContain('http://10.8.0.35:9073'); +}); + +it('resolves compose application published port from environment variables', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 36; + $deploymentServer->ip = '10.8.0.36'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-compose-env-port'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_domains = json_encode([ + 'web' => ['domain' => 'https://compose-env.example.com:3000'], + ]); + $application->docker_compose_raw = <<<'YAML' +services: + web: + ports: + - "${APP_HOST_PORT:-9080}:3000" +YAML; + $application->setRelation('environment_variables', collect([ + (object) ['key' => 'APP_HOST_PORT', 'value' => '9091'], + ])); + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + expect($warnings)->toBe([]); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('http://10.8.0.36:9091'); +}); + +it('returns warning for unsupported application domain protocol while preserving valid http route', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + public array $calls = []; + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + $this->calls[] = [ + 'commands' => $commands, + 'throw_error' => $throwError, + ]; + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 37; + $deploymentServer->ip = '10.8.0.37'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-unsupported-protocol'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://valid-app.example.com:3000,tcp://minecraft.example.com:25565'; + $application->ports_mappings = '9074:3000,25565:25565'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'protocol "tcp" is not supported for edge remote routing'))) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`valid-app.example.com`)') + ->and($payload)->toContain('http://10.8.0.37:9074') + ->and($payload)->not->toContain('minecraft.example.com'); +});