diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index fa39f7909..f48d15817 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,11 +51,36 @@ class StartDatabaseProxy }; } - $configuration_dir = database_proxy_dir($database->uuid); - $host_configuration_dir = $configuration_dir; - if (isDev()) { - $host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy'; + if (! $deploymentServer instanceof Server) { + $this->logWarning(sprintf( + 'Database proxy for %s is skipped because deployment server is missing.', + $database->uuid + )); + + return; } + + $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); + $host_configuration_dir = $this->resolveHostConfigurationDirectory($database->uuid, $configuration_dir); $timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout); $nginxconf = <<public_port; - proxy_pass $containerName:$internalPort; + proxy_pass $upstreamTarget; $timeoutConfig } } 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' => "$host_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' => "$host_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]); @@ -134,7 +167,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, ) ); @@ -147,6 +180,140 @@ 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 + { + return database_proxy_dir($databaseUuid); + } + + protected function resolveHostConfigurationDirectory(string $databaseUuid, ?string $configurationDirectory = null): string + { + $configurationDirectory ??= $this->resolveConfigurationDirectory($databaseUuid); + if ($this->isDevelopmentEnvironment()) { + return '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$databaseUuid.'/proxy'; + } + + return $configurationDirectory; + } + + protected function isDevelopmentEnvironment(): bool + { + if (app()->bound('config')) { + return isDev(); + } + + $appEnv = $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? getenv('APP_ENV'); + + return $appEnv === 'local'; + } + + 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/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 8790901cd..4c07c1393 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -4,6 +4,8 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; use App\Models\Service; +use App\Services\EdgeProxyRemotePortForwardService; +use App\Services\EdgeProxyRemoteRouteService; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; @@ -55,6 +57,23 @@ class DeleteService } catch (\Exception $e) { throw new \RuntimeException($e->getMessage()); } finally { + try { + app(EdgeProxyRemoteRouteService::class)->deleteService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to delete edge proxy route file for service.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + } + try { + app(EdgeProxyRemotePortForwardService::class)->deleteService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to delete edge port proxy for service.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + } + if ($deleteConfigurations) { $service->deleteConfigurations(); } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 6b5e1d4ac..0e0498fc0 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -3,6 +3,9 @@ namespace App\Actions\Service; use App\Models\Service; +use App\Services\EdgeProxyRemotePortForwardService; +use App\Services\EdgeProxyRemoteRouteService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -15,6 +18,26 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); + $edgeRoutingWarnings = []; + $edgePortForwardWarnings = []; + try { + $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to sync edge proxy route for service start.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + $edgeRoutingWarnings[] = 'Failed to sync edge proxy route configuration. Check edge proxy connectivity and server settings.'; + } + try { + $edgePortForwardWarnings = app(EdgeProxyRemotePortForwardService::class)->syncService($service); + } catch (\Throwable $exception) { + Log::warning('Failed to sync edge port forwarding for service start.', [ + 'service_uuid' => $service->uuid, + 'error' => $exception->getMessage(), + ]); + $edgePortForwardWarnings[] = 'Failed to sync edge port forwarding configuration. Check edge proxy connectivity, published ports, and server settings.'; + } if ($stopBeforeStart) { StopService::run(service: $service, dockerCleanup: false); } @@ -23,6 +46,12 @@ class StartService $workdir = $service->workdir(); // $commands[] = "cd {$workdir}"; $commands[] = "echo 'Saved configuration files to {$workdir}.'"; + foreach ($edgeRoutingWarnings as $warning) { + $commands[] = 'echo '.escapeshellarg("Edge proxy routing warning: {$warning}"); + } + foreach ($edgePortForwardWarnings as $warning) { + $commands[] = 'echo '.escapeshellarg("Edge port forwarding warning: {$warning}"); + } // Ensure .env exists in the correct directory before docker compose tries to load it // This is defensive programming - saveComposeConfigs() already creates it, // but we guarantee it here in case of any edge cases or manual deployments diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c9f0f1eef..eecd2e418 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,8 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Services\EdgeProxyRemotePortForwardService; +use App\Services\EdgeProxyRemoteRouteService; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; use Carbon\Carbon; @@ -508,6 +510,37 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } catch (\Exception $e) { \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } + + // Intentionally skip preview deployments here; preview routing has separate lifecycle/hostnames. + 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' + ); + } + + try { + $edgePortForwardWarnings = app(EdgeProxyRemotePortForwardService::class) + ->syncApplicationOnDeploymentServer($this->application, $this->mainServer); + foreach ($edgePortForwardWarnings as $warning) { + $this->application_deployment_queue->addLogEntry("Edge port forwarding warning: {$warning}", 'stderr'); + } + } catch (\Throwable $e) { + \Log::warning('Failed to sync edge port forwarding for application deployment '.$this->deployment_uuid.': '.$e->getMessage()); + $this->application_deployment_queue->addLogEntry( + 'Edge port forwarding warning: Failed to sync edge port forwarding configuration. Check master domain server settings, published ports, and edge proxy connectivity.', + 'stderr' + ); + } + } } private function deploy_simple_dockerfile() diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 825604910..0006c895c 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -18,6 +18,8 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Services\EdgeProxyRemotePortForwardService; +use App\Services\EdgeProxyRemoteRouteService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -102,6 +104,19 @@ 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()); + } + try { + app(EdgeProxyRemotePortForwardService::class)->deleteApplication($this->resource); + } catch (\Throwable $e) { + \Log::warning('Failed to delete edge port proxy 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 edc17004c..6e93d70c3 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; @@ -46,9 +47,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; @@ -117,6 +126,7 @@ class Show extends Component 'isSwarmManager' => 'required', 'isSwarmWorker' => 'required', 'isBuildServer' => 'required', + 'isMasterDomainRouterEnabled' => 'required', 'isMetricsEnabled' => 'required', 'sentinelToken' => 'required', 'sentinelUpdatedAt' => 'nullable', @@ -163,6 +173,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; @@ -214,6 +225,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; @@ -244,6 +256,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; @@ -256,6 +269,8 @@ class Show extends Component $this->serverTimezone = $this->server->settings->server_timezone; $this->isValidating = $this->server->is_validating ?? false; } + + $this->refreshMasterDomainRouterLockState(); } public function refresh() @@ -354,6 +369,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 { @@ -493,6 +527,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/EdgeProxyRemotePortForwardService.php b/app/Services/EdgeProxyRemotePortForwardService.php new file mode 100644 index 000000000..fad919e79 --- /dev/null +++ b/app/Services/EdgeProxyRemotePortForwardService.php @@ -0,0 +1,868 @@ +resolveServiceDeploymentServer($service); + if (! $deploymentServer instanceof Server) { + return []; + } + + return $this->syncServiceWithServers( + $service, + $this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service)), + $deploymentServer + ); + } + + public function syncServiceWithServers(Service $service, ?Server $edgeProxyServer, Server $deploymentServer): array + { + return $this->syncResourcePortProxy( + resourceType: 'service', + resourceUuid: $service->uuid, + edgeProxyServer: $edgeProxyServer, + deploymentServer: $deploymentServer, + publishedPortMappings: $this->servicePublishedPortMappings($service) + ); + } + + public function syncApplication(Application $application): array + { + $deploymentServer = $this->resolveApplicationDeploymentServer($application); + if (! $deploymentServer instanceof Server) { + return []; + } + + return $this->syncApplicationWithServers( + $application, + $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)), + $deploymentServer + ); + } + + public function syncApplicationOnDeploymentServer(Application $application, Server $deploymentServer): array + { + return $this->syncApplicationWithServers( + $application, + $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)), + $deploymentServer + ); + } + + public function syncApplicationWithServers(Application $application, ?Server $edgeProxyServer, Server $deploymentServer): array + { + return $this->syncResourcePortProxy( + resourceType: 'application', + resourceUuid: $application->uuid, + edgeProxyServer: $edgeProxyServer, + deploymentServer: $deploymentServer, + publishedPortMappings: $this->applicationPublishedPortMappings($application) + ); + } + + public function deleteService(Service $service): void + { + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service)); + if (! $edgeProxyServer instanceof Server) { + return; + } + + $this->deleteServiceWithServer($service, $edgeProxyServer); + } + + public function deleteServiceWithServer(Service $service, Server $edgeProxyServer): void + { + $this->deleteResourcePortProxy($edgeProxyServer, 'service', $service->uuid); + } + + public function deleteApplication(Application $application): void + { + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($this->extractApplicationTeamId($application)); + if (! $edgeProxyServer instanceof Server) { + return; + } + + $this->deleteApplicationWithServer($application, $edgeProxyServer); + } + + public function deleteApplicationWithServer(Application $application, Server $edgeProxyServer): void + { + $this->deleteResourcePortProxy($edgeProxyServer, 'application', $application->uuid); + } + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + return instant_remote_process($commands, $server, $throwError); + } + + protected function resolveEdgeProxyServerByTeamId(?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(); + } + + protected function logWarning(string $message): void + { + if (app()->bound('log')) { + app('log')->warning($message); + + return; + } + + error_log($message); + } + + private function syncResourcePortProxy( + string $resourceType, + string $resourceUuid, + ?Server $edgeProxyServer, + Server $deploymentServer, + Collection $publishedPortMappings + ): array { + if (! $edgeProxyServer instanceof Server) { + return []; + } + + if ($edgeProxyServer->id === $deploymentServer->id) { + $this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid); + + return []; + } + + if ($publishedPortMappings->isEmpty()) { + $this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid); + + return []; + } + + $warnings = []; + $publishedPortMappings = $this->filterReservedEdgePorts($resourceType, $resourceUuid, $publishedPortMappings, $warnings); + if ($publishedPortMappings->isEmpty()) { + $this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid); + + foreach ($warnings as $warning) { + $this->logWarning($warning); + } + + return $warnings; + } + + $remoteHost = $this->resolveRemoteHost($deploymentServer); + if (is_null($remoteHost)) { + $warning = sprintf( + 'Edge port forwarding skipped for %s %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.', + $resourceType, + $resourceUuid + ); + $this->logWarning($warning); + $this->deleteResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid); + $warnings[] = $warning; + + return $warnings; + } + + try { + $this->writeResourcePortProxy($edgeProxyServer, $resourceType, $resourceUuid, $remoteHost, $publishedPortMappings); + } catch (\Throwable $exception) { + $warning = sprintf( + 'Edge port forwarding warning for %s %s: failed to start edge port proxy (%s). Check whether one of the published ports is already in use on the edge server.', + $resourceType, + $resourceUuid, + $exception->getMessage() + ); + $this->logWarning($warning); + $warnings[] = $warning; + } + + foreach ($warnings as $warning) { + $this->logWarning($warning); + } + + return $warnings; + } + + private function servicePublishedPortMappings(Service $service): Collection + { + return $this->composePublishedPortMappings( + $this->parseServiceCompose($service), + $this->serviceEnvironmentMap($service) + ); + } + + private function applicationPublishedPortMappings(Application $application): Collection + { + $environmentMap = $this->applicationEnvironmentMap($application); + + if ($application->build_pack === 'dockercompose') { + return $this->composePublishedPortMappings( + $this->parseApplicationCompose($application), + $environmentMap + ); + } + + return $this->parsePublishedPortMappings($application->ports_mappings_array ?? [], $environmentMap); + } + + private function composePublishedPortMappings(array $compose, array $environmentMap): Collection + { + $services = data_get($compose, 'services', []); + if (! is_array($services) || empty($services)) { + return collect(); + } + + return collect($services) + ->filter(fn (mixed $serviceConfig) => is_array($serviceConfig)) + ->flatMap(function (array $serviceConfig) use ($environmentMap) { + return $this->parsePublishedPortMappings( + (array) data_get($serviceConfig, 'ports', []), + $this->mergeComposeEnvironmentMap($serviceConfig, $environmentMap) + ); + }) + ->unique(fn (array $mapping) => $mapping['protocol'].':'.$mapping['published']) + ->sortBy(fn (array $mapping) => sprintf('%05d:%s', $mapping['published'], $mapping['protocol'])) + ->values(); + } + + private function parsePublishedPortMappings(array $ports, array $environmentMap): Collection + { + $mappings = collect(); + + foreach ($ports as $portDefinition) { + if (is_array($portDefinition)) { + $protocol = strtolower((string) data_get($portDefinition, 'protocol', 'tcp')); + if (! in_array($protocol, ['tcp', 'udp'], true)) { + continue; + } + + $published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap); + if (! is_null($published)) { + $mappings->push([ + 'published' => $published, + 'protocol' => $protocol, + ]); + } + + continue; + } + + if (is_string($portDefinition) || is_int($portDefinition)) { + $mapping = $this->parsePublishedPortMappingFromString((string) $portDefinition, $environmentMap); + if (! is_null($mapping) && ! is_null($mapping['published'])) { + $mappings->push($mapping); + } + } + } + + return $mappings->values(); + } + + private function parsePublishedPortMappingFromString(string $portDefinition, array $environmentMap): ?array + { + $normalizedPortDefinition = trim($portDefinition); + if ($normalizedPortDefinition === '') { + return null; + } + + $protocol = 'tcp'; + if (preg_match('/\/(tcp|udp)$/i', $normalizedPortDefinition, $protocolMatches)) { + $protocol = strtolower($protocolMatches[1]); + $normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition; + } + + if (! str_contains($normalizedPortDefinition, ':')) { + return [ + 'published' => null, + 'protocol' => $protocol, + ]; + } + + $segments = $this->splitPortDefinitionSegments($normalizedPortDefinition); + if (count($segments) < 2) { + return null; + } + + array_pop($segments); + $publishedPort = $this->resolvePortValue(array_pop($segments), $environmentMap); + + return [ + 'published' => $publishedPort, + 'protocol' => $protocol, + ]; + } + + private function filterReservedEdgePorts(string $resourceType, string $resourceUuid, Collection $publishedPortMappings, array &$warnings): Collection + { + return $publishedPortMappings + ->reject(function (array $mapping) use ($resourceType, $resourceUuid, &$warnings) { + if (! in_array($mapping['published'], self::RESERVED_EDGE_PORTS, true)) { + return false; + } + + $warnings[] = sprintf( + 'Edge port forwarding skipped for %s %s on port %d/%s: this port is reserved on the edge server for HTTP/HTTPS proxying.', + $resourceType, + $resourceUuid, + $mapping['published'], + $mapping['protocol'] + ); + + return true; + }) + ->values(); + } + + private function writeResourcePortProxy( + Server $edgeProxyServer, + string $resourceType, + string $resourceUuid, + string $remoteHost, + Collection $publishedPortMappings + ): void { + $containerName = $this->proxyContainerName($resourceType, $resourceUuid); + $configurationDirectory = $this->configurationDirectory($resourceType, $resourceUuid); + $escapedConfigurationDirectory = escapeshellarg($configurationDirectory); + $escapedNginxPath = escapeshellarg($configurationDirectory.'/nginx.conf'); + $escapedComposePath = escapeshellarg($configurationDirectory.'/docker-compose.yaml'); + $escapedContainerName = escapeshellarg($containerName); + + $nginxConfig = base64_encode($this->generateNginxStreamConfig($remoteHost, $publishedPortMappings)); + $dockerCompose = base64_encode(Yaml::dump( + $this->generateDockerCompose($containerName, $configurationDirectory, $publishedPortMappings), + 6, + 2 + )); + + $this->runRemoteCommands($edgeProxyServer, [ + "docker rm -f $escapedContainerName >/dev/null 2>&1 || true", + "mkdir -p $escapedConfigurationDirectory", + "echo '{$nginxConfig}' | base64 -d | tee $escapedNginxPath > /dev/null", + "echo '{$dockerCompose}' | base64 -d | tee $escapedComposePath > /dev/null", + "docker compose --project-directory $escapedConfigurationDirectory pull", + "docker compose --project-directory $escapedConfigurationDirectory up -d", + ]); + } + + private function deleteResourcePortProxy(Server $edgeProxyServer, string $resourceType, string $resourceUuid): void + { + $escapedContainerName = escapeshellarg($this->proxyContainerName($resourceType, $resourceUuid)); + + $this->runRemoteCommands($edgeProxyServer, [ + "docker rm -f $escapedContainerName", + ], false); + } + + private function generateNginxStreamConfig(string $remoteHost, Collection $publishedPortMappings): string + { + $serverBlocks = $publishedPortMappings + ->map(function (array $mapping) use ($remoteHost) { + $listen = (string) $mapping['published']; + if ($mapping['protocol'] === 'udp') { + $listen .= ' udp'; + } + + return <<implode("\n"); + + return <<map(function (array $mapping) { + $port = "{$mapping['published']}:{$mapping['published']}"; + + return $mapping['protocol'] === 'udp' ? "{$port}/udp" : $port; + }) + ->values() + ->all(); + + return [ + 'services' => [ + $containerName => [ + 'image' => 'nginx:stable-alpine', + 'container_name' => $containerName, + 'restart' => RESTART_MODE, + 'ports' => $ports, + 'volumes' => [[ + 'type' => 'bind', + 'source' => $configurationDirectory.'/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', + ], + ], + ], + ]; + } + + private function configurationDirectory(string $resourceType, string $resourceUuid): string + { + return match ($resourceType) { + 'application' => application_configuration_dir()."/{$resourceUuid}/edge-port-proxy", + 'service' => service_configuration_dir()."/{$resourceUuid}/edge-port-proxy", + default => base_configuration_dir()."/{$resourceType}/{$resourceUuid}/edge-port-proxy", + }; + } + + private function proxyContainerName(string $resourceType, string $resourceUuid): string + { + return "{$resourceType}-{$resourceUuid}-edge-port-proxy"; + } + + private function resolveServiceDeploymentServer(Service $service): ?Server + { + $server = data_get($service, 'server'); + if ($server instanceof Server) { + return $server; + } + + $server = data_get($service, 'destination.server'); + if ($server instanceof Server) { + return $server; + } + + if ($service->exists && ! is_null($service->server_id)) { + return Server::query()->find($service->server_id); + } + + return null; + } + + 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)) { + return (int) $teamId; + } + + if ($service->exists) { + $service->loadMissing('environment.project'); + $teamId = data_get($service, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + 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 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 parseServiceCompose(Service $service): array + { + if (blank($service->docker_compose_raw)) { + return []; + } + + try { + $parsedCompose = Yaml::parse($service->docker_compose_raw); + + return is_array($parsedCompose) ? $parsedCompose : []; + } catch (\Throwable) { + return []; + } + } + + private function serviceEnvironmentMap(Service $service): array + { + if ($service->relationLoaded('environment_variables')) { + return $service->environment_variables + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + if (! $service->exists) { + return []; + } + + return $service->environment_variables() + ->get() + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->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 mergeComposeEnvironmentMap(array $serviceConfig, array $environmentMap): array + { + $resolvedEnvironmentMap = $environmentMap; + + foreach ($this->composeEnvironmentDefinitions($serviceConfig) as $environmentKey => $rawValue) { + if ( + array_key_exists($environmentKey, $environmentMap) && + trim((string) $environmentMap[$environmentKey]) !== '' + ) { + continue; + } + + $resolvedValue = $this->resolveEnvironmentValue($rawValue, $resolvedEnvironmentMap); + if (! is_null($resolvedValue)) { + $resolvedEnvironmentMap[$environmentKey] = $resolvedValue; + } + } + + return $resolvedEnvironmentMap; + } + + private function composeEnvironmentDefinitions(array $serviceConfig): array + { + $environmentDefinitions = []; + $environment = data_get($serviceConfig, 'environment', []); + + if (! is_array($environment)) { + return $environmentDefinitions; + } + + foreach ($environment as $key => $value) { + if (is_int($key)) { + $environmentPair = explode('=', (string) $value, 2); + if (count($environmentPair) !== 2 || trim($environmentPair[0]) === '') { + continue; + } + + $environmentDefinitions[trim($environmentPair[0])] = $environmentPair[1]; + + continue; + } + + if (! is_string($key) || trim($key) === '') { + continue; + } + + if (! is_scalar($value) && ! is_null($value)) { + continue; + } + + $environmentDefinitions[trim($key)] = $value; + } + + return $environmentDefinitions; + } + + private function resolveEnvironmentValue(mixed $rawValue, array $environmentMap): ?string + { + if (is_null($rawValue)) { + return null; + } + + if (is_bool($rawValue)) { + return $rawValue ? 'true' : 'false'; + } + + if (is_int($rawValue) || is_float($rawValue)) { + return (string) $rawValue; + } + + $normalizedValue = trim((string) $rawValue); + if ($normalizedValue === '') { + return ''; + } + + if ( + preg_match( + '/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/', + $normalizedValue, + $matches + ) + ) { + $environmentKey = $matches[1]; + $defaultValue = trim((string) ($matches[3] ?? '')); + $resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null; + + if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') { + return trim((string) $resolvedEnvironmentValue); + } + + return $defaultValue !== '' ? $defaultValue : null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedValue, $matches)) { + $environmentKey = $matches[1]; + $resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null; + + if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') { + return trim((string) $resolvedEnvironmentValue); + } + + return null; + } + + if (array_key_exists($normalizedValue, $environmentMap)) { + $resolvedEnvironmentValue = trim((string) $environmentMap[$normalizedValue]); + + return $resolvedEnvironmentValue !== '' ? $resolvedEnvironmentValue : null; + } + + return $normalizedValue; + } + + 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)) { + return $rawPortValue; + } + + $normalizedPortValue = trim((string) $rawPortValue); + if ($normalizedPortValue === '') { + return null; + } + + if (preg_match('/^\d+\s*-\s*\d+$/', $normalizedPortValue) === 1) { + return null; + } + + if (preg_match('/^\d+$/', $normalizedPortValue)) { + return (int) $normalizedPortValue; + } + + if ( + preg_match( + '/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/', + $normalizedPortValue, + $matches + ) + ) { + $environmentKey = $matches[1]; + $defaultPort = trim((string) ($matches[3] ?? '')); + + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + if ($defaultPort !== '' && is_numeric($defaultPort)) { + return (int) $defaultPort; + } + + return null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedPortValue, $matches)) { + $environmentKey = $matches[1]; + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + return null; + } + + if (array_key_exists($normalizedPortValue, $environmentMap) && is_numeric(trim((string) $environmentMap[$normalizedPortValue]))) { + return (int) trim((string) $environmentMap[$normalizedPortValue]); + } + + 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; + } +} diff --git a/app/Services/EdgeProxyRemoteRouteService.php b/app/Services/EdgeProxyRemoteRouteService.php new file mode 100644 index 000000000..be4ceeeea --- /dev/null +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -0,0 +1,1562 @@ +extractServiceTeamId($service); + $deploymentServer = $this->resolveDeploymentServer($service); + + if (! $deploymentServer instanceof Server) { + return []; + } + + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId); + if (! $edgeProxyServer instanceof Server) { + $warning = $this->missingMasterDomainRouterWarning('service', $service->uuid, $teamId); + if (! is_null($warning)) { + $this->logWarning($warning); + + return [$warning]; + } + + return []; + } + + return $this->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + } + + public function syncServiceWithServers(Service $service, Server $edgeProxyServer, Server $deploymentServer): array + { + if ($edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return []; + } + + if ($deploymentServer->id === $edgeProxyServer->id) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return []; + } + + $applications = $this->getServiceApplicationsWithDomains($service); + if ($applications->isEmpty()) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return []; + } + + $tunnelHost = $this->resolveTunnelHost($deploymentServer); + if (blank($tunnelHost)) { + $warning = sprintf( + 'Edge proxy route skipped for service %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.', + $service->uuid + ); + + $this->logWarning($warning); + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return [$warning]; + } + + $compose = $this->parseServiceCompose($service); + $environmentMap = $this->serviceEnvironmentMap($service); + + $routes = []; + $warnings = []; + $networkOverlapWarning = $this->detectDockerNetworkOverlapWarningForResource('service', $service->uuid, $edgeProxyServer, $tunnelHost); + if (! is_null($networkOverlapWarning)) { + $warnings[] = $networkOverlapWarning; + } + + foreach ($applications as $application) { + $domains = collect(explode(',', (string) $application->fqdn)) + ->map(fn (string $domain) => trim($domain)) + ->filter(); + + foreach ($domains as $domain) { + $unsupportedProtocol = $this->detectUnsupportedDomainProtocol($domain); + if (! is_null($unsupportedProtocol)) { + $warnings[] = sprintf( + 'Edge proxy route skipped for service %s (%s, domain %s): protocol "%s" is not supported for edge remote routing. Only http:// and https:// domains are currently supported.', + $service->uuid, + $application->name, + $domain, + $unsupportedProtocol + ); + + continue; + } + + $url = $this->parseDomainUrl($domain); + if (! $url instanceof Url) { + $warnings[] = sprintf( + 'Edge proxy route skipped for service %s (%s, domain %s): domain format is invalid. Use a valid hostname/domain with optional scheme, port and path.', + $service->uuid, + $application->name, + $domain + ); + + continue; + } + + if ( + $this->hasUnsafeTraefikRuleValue($url->getHost()) || + $this->hasUnsafeTraefikRuleValue($url->getPath()) + ) { + $warnings[] = sprintf( + 'Edge proxy route skipped for service %s (%s, domain %s): domain contains unsupported characters for Traefik host/path rules.', + $service->uuid, + $application->name, + $domain + ); + + continue; + } + + $requestedInternalPort = $url->getPort() ?? $application->getRequiredPort(); + $publishedPort = $this->resolvePublishedPort($compose, $application->name, $requestedInternalPort, $environmentMap); + + $upstream = $this->resolveRouteUpstream( + $deploymentServer, + $tunnelHost, + $publishedPort, + $this->canFallbackToDeploymentProxyForServiceApplication( + $application, + $requestedInternalPort, + $compose, + $environmentMap + ) + ); + + if (is_null($upstream)) { + $warnings[] = sprintf( + 'Edge proxy route skipped for service %s (%s, domain %s): published host port could not be resolved. Expose the container port in docker-compose "ports:" and/or include an explicit port in the domain.', + $service->uuid, + $application->name, + $domain + ); + + continue; + } + + if ($this->isDeploymentProxyFallbackUpstream($upstream)) { + $warnings[] = sprintf( + 'Edge proxy route fallback for service %s (%s, domain %s): published host port could not be resolved, so traffic will be forwarded to the deployment server HTTPS proxy instead.', + $service->uuid, + $application->name, + $domain + ); + } + + $routes[] = [ + 'host' => $url->getHost(), + 'path' => $url->getPath(), + ...$upstream, + ]; + } + } + + if (! empty($warnings)) { + foreach ($warnings as $warning) { + $this->logWarning($warning); + } + } + + if (empty($routes)) { + $this->deleteRouteFile($edgeProxyServer, $service->uuid); + + return $warnings; + } + + $config = $this->generateTraefikConfig($service->uuid, $routes); + try { + $this->writeRouteFile($edgeProxyServer, $service->uuid, $config); + } catch (\Throwable $exception) { + $warning = sprintf( + 'Edge proxy route partially applied for service %s: failed to write dynamic route configuration on edge proxy (%s).', + $service->uuid, + $exception->getMessage() + ); + $this->logWarning($warning); + $warnings[] = $warning; + } + + return $warnings; + } + + public function syncApplication(Application $application): array + { + $teamId = $this->extractApplicationTeamId($application); + $deploymentServer = $this->resolveApplicationDeploymentServer($application); + + if (! $deploymentServer instanceof Server) { + return []; + } + + $edgeProxyServer = $this->resolveEdgeProxyServerByTeamId($teamId); + if (! $edgeProxyServer instanceof Server) { + $warning = $this->missingMasterDomainRouterWarning('application', $application->uuid, $teamId); + if (! is_null($warning)) { + $this->logWarning($warning); + + return [$warning]; + } + + 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) { + $warning = $this->missingMasterDomainRouterWarning('application', $application->uuid, $teamId); + if (! is_null($warning)) { + $this->logWarning($warning); + + return [$warning]; + } + + 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; + } + + if ( + $this->hasUnsafeTraefikRuleValue($url->getHost()) || + $this->hasUnsafeTraefikRuleValue($url->getPath()) + ) { + $warnings[] = sprintf( + 'Edge proxy route skipped for application %s (domain %s): domain contains unsupported characters for Traefik host/path rules.', + $application->uuid, + $domain + ); + + continue; + } + + $requestedInternalPort = $url->getPort(); + $publishedPort = $this->resolvePublishedPortForApplication( + $application, + $requestedInternalPort, + $composeServiceName, + $compose, + $environmentMap + ); + + $upstream = $this->resolveRouteUpstream( + $deploymentServer, + $tunnelHost, + $publishedPort, + $this->canFallbackToDeploymentProxyForApplication( + $application, + $requestedInternalPort, + $composeServiceName, + $compose, + $environmentMap + ) + ); + + if (is_null($upstream)) { + $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; + } + + if ($this->isDeploymentProxyFallbackUpstream($upstream)) { + $warnings[] = sprintf( + 'Edge proxy route fallback for application %s (domain %s): published host port could not be resolved, so traffic will be forwarded to the deployment server HTTPS proxy instead.', + $application->uuid, + $domain + ); + } + + $routes[] = [ + 'host' => $url->getHost(), + 'path' => $url->getPath(), + ...$upstream, + ]; + } + + 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->resolveEdgeProxyServerByTeamId($this->extractServiceTeamId($service)); + if (! $edgeProxyServer instanceof Server || $edgeProxyServer->proxyType() !== ProxyTypes::TRAEFIK->value) { + return; + } + + $this->deleteServiceWithServer($service, $edgeProxyServer); + } + + public function deleteServiceWithServer(Service $service, Server $edgeProxyServer): void + { + $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); + if ($serviceKey === '') { + $serviceKey = 'service'; + } + + $redirectMiddlewareName = "edge-{$serviceKey}-redirect-to-https"; + + $config = [ + 'http' => [ + 'middlewares' => [ + $redirectMiddlewareName => [ + 'redirectScheme' => [ + 'scheme' => 'https', + ], + ], + ], + 'routers' => [], + 'services' => [], + ], + ]; + + foreach ($routes as $index => $route) { + $suffix = $index + 1; + + $httpRouterName = "edge-{$serviceKey}-http-{$suffix}"; + $httpsRouterName = "edge-{$serviceKey}-https-{$suffix}"; + $serviceName = "edge-{$serviceKey}-svc-{$suffix}"; + $rule = $this->buildTraefikRule($route['host'], $route['path']); + if (is_null($rule)) { + continue; + } + + $config['http']['routers'][$httpRouterName] = [ + 'rule' => $rule, + 'entryPoints' => [$this->httpEntryPointName()], + 'middlewares' => [$redirectMiddlewareName], + 'service' => $serviceName, + ]; + + $config['http']['routers'][$httpsRouterName] = [ + 'rule' => $rule, + 'entryPoints' => [$this->httpsEntryPointName()], + 'service' => $serviceName, + 'tls' => [ + 'certResolver' => $this->certResolverName(), + ], + ]; + + $config['http']['services'][$serviceName] = [ + 'loadBalancer' => [ + 'servers' => [ + ['url' => $route['upstream_url']], + ], + ], + ]; + + if (data_get($route, 'pass_host_header') === true) { + $config['http']['services'][$serviceName]['loadBalancer']['passHostHeader'] = true; + } + + if (data_get($route, 'use_insecure_transport') === true) { + $transportName = "edge-{$serviceKey}-transport-{$suffix}"; + $config['http']['services'][$serviceName]['loadBalancer']['serversTransport'] = $transportName; + $config['http']['serversTransports'][$transportName] = [ + 'insecureSkipVerify' => true, + ]; + } + } + + return $config; + } + + 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), + $prefix, + $resourceUuid + ); + } + + protected function runRemoteCommands(Server $server, array $commands, bool $throwError = true): ?string + { + return instant_remote_process($commands, $server, $throwError); + } + + private function routeDirectoryPath(Server $edgeProxyServer): string + { + return rtrim($edgeProxyServer->proxyPath(), '/').'/dynamic'; + } + + 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->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid); + $temporaryRouteFilePath = $routeFilePath.'.tmp'; + + $escapedDirectory = escapeshellarg($this->routeDirectoryPath($edgeProxyServer)); + $escapedFilePath = escapeshellarg($routeFilePath); + $escapedTemporaryFilePath = escapeshellarg($temporaryRouteFilePath); + + $this->runRemoteCommands($edgeProxyServer, [ + "mkdir -p $escapedDirectory", + "echo '$payload' | base64 -d | tee $escapedTemporaryFilePath > /dev/null", + "mv $escapedTemporaryFilePath $escapedFilePath", + ]); + } + + private function deleteRouteFile(Server $edgeProxyServer, string $resourceUuid, string $filePrefix = self::SERVICE_ROUTE_FILE_PREFIX): void + { + $routeFilePath = $this->resourceRouteFilePath($edgeProxyServer, $filePrefix, $resourceUuid); + $escapedFilePath = escapeshellarg($routeFilePath); + $escapedTemporaryFilePath = escapeshellarg($routeFilePath.'.tmp'); + + $this->runRemoteCommands($edgeProxyServer, [ + "rm -f $escapedFilePath $escapedTemporaryFilePath", + ], false); + } + + private function buildTraefikRule(string $host, ?string $path): ?string + { + if ($this->hasUnsafeTraefikRuleValue($host) || (! is_null($path) && $this->hasUnsafeTraefikRuleValue($path))) { + return null; + } + + $rule = sprintf('Host(`%s`)', $host); + + if (! is_null($path) && $path !== '' && $path !== '/') { + $rule .= sprintf(' && PathPrefix(`%s`)', $path); + } + + return $rule; + } + + protected function resolveEdgeProxyServerByTeamId(?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 missingMasterDomainRouterWarning(string $resourceType, string $resourceUuid, ?int $teamId): ?string + { + if (is_null($teamId)) { + return null; + } + + return sprintf( + 'Edge proxy route skipped for %s %s: no master domain router is configured for team %d. Enable "Master Domain Router" on exactly one team server.', + $resourceType, + $resourceUuid, + $teamId + ); + } + + private function resolveDeploymentServer(Service $service): ?Server + { + $server = data_get($service, 'server'); + if ($server instanceof Server) { + return $server; + } + + $server = data_get($service, 'destination.server'); + if ($server instanceof Server) { + return $server; + } + + if ($service->exists && ! is_null($service->server_id)) { + return Server::query()->find($service->server_id); + } + + return null; + } + + 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)) { + return (int) $teamId; + } + + if ($service->exists) { + $service->loadMissing('environment.project'); + $teamId = data_get($service, 'environment.project.team_id'); + if (! is_null($teamId)) { + return (int) $teamId; + } + } + + 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([]); + + if ($service->relationLoaded('applications')) { + $applications = $service->applications; + } elseif ($service->exists) { + $applications = $service->applications()->get(); + } + + return $applications + ->filter(fn (ServiceApplication $application) => filled($application->fqdn)) + ->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 = [ + 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 parseServiceCompose(Service $service): array + { + if (blank($service->docker_compose_raw)) { + return []; + } + + try { + $parsedCompose = Yaml::parse($service->docker_compose_raw); + + return is_array($parsedCompose) ? $parsedCompose : []; + } catch (\Throwable) { + return []; + } + } + + private function serviceEnvironmentMap(Service $service): array + { + if ($service->relationLoaded('environment_variables')) { + return $service->environment_variables + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->all(); + } + + if (! $service->exists) { + return []; + } + + return $service->environment_variables() + ->get() + ->mapWithKeys(fn ($environmentVariable) => [$environmentVariable->key => (string) $environmentVariable->value]) + ->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 resolveRouteUpstream( + Server $deploymentServer, + string $tunnelHost, + ?int $publishedPort, + bool $allowDeploymentProxyFallback + ): ?array { + if (! is_null($publishedPort)) { + return [ + 'upstream_url' => sprintf('http://%s:%d', $tunnelHost, $publishedPort), + ]; + } + + if (! $allowDeploymentProxyFallback) { + return null; + } + + if ($deploymentServer->proxyType() === ProxyTypes::NONE->value) { + return null; + } + + return [ + 'upstream_url' => sprintf('https://%s:443', $tunnelHost), + 'pass_host_header' => true, + 'use_insecure_transport' => true, + ]; + } + + private function isDeploymentProxyFallbackUpstream(array $upstream): bool + { + return data_get($upstream, 'use_insecure_transport') === true; + } + + private function canFallbackToDeploymentProxyForServiceApplication( + ServiceApplication $application, + ?int $requestedInternalPort, + array $compose, + array $environmentMap + ): bool { + $candidatePorts = $this->resolveComposeServiceInternalPorts($compose, $application->name, $environmentMap); + $requiredPort = $application->getRequiredPort(); + if (! is_null($requiredPort)) { + $candidatePorts->push($requiredPort); + } + + return $this->candidatePortsSupportProxyFallback($requestedInternalPort, $candidatePorts); + } + + private function canFallbackToDeploymentProxyForApplication( + Application $application, + ?int $requestedInternalPort, + ?string $composeServiceName, + array $compose, + array $environmentMap + ): bool { + if ($application->build_pack === 'dockercompose') { + $serviceName = blank($composeServiceName) ? $application->uuid : $composeServiceName; + + return $this->candidatePortsSupportProxyFallback( + $requestedInternalPort, + $this->resolveComposeServiceInternalPorts($compose, $serviceName, $environmentMap) + ); + } + + return $this->candidatePortsSupportProxyFallback( + $requestedInternalPort, + $this->applicationInternalPorts($application) + ); + } + + private function candidatePortsSupportProxyFallback(?int $requestedInternalPort, Collection $candidatePorts): bool + { + $candidatePorts = $candidatePorts + ->filter(fn (mixed $port) => is_int($port) || (is_string($port) && is_numeric($port))) + ->map(fn (mixed $port) => (int) $port) + ->unique() + ->values(); + + if ($candidatePorts->isEmpty()) { + return false; + } + + if (! is_null($requestedInternalPort)) { + return $candidatePorts->contains($requestedInternalPort); + } + + return $candidatePorts->count() === 1; + } + + private function applicationInternalPorts(Application $application): Collection + { + if ($application->relationLoaded('settings') && data_get($application, 'settings.is_static', false)) { + return collect([80]); + } + + return collect($application->ports_exposes_array ?? []); + } + + private function detectUnsupportedDomainProtocol(string $domain): ?string + { + $trimmedDomain = trim($domain); + if ($trimmedDomain === '' || ! preg_match('/^[a-z][a-z0-9+\-.]*:\/\//i', $trimmedDomain)) { + return null; + } + + $protocol = strtolower((string) parse_url($trimmedDomain, PHP_URL_SCHEME)); + if ($protocol === '' || in_array($protocol, ['http', 'https'], true)) { + return null; + } + + return $protocol; + } + + 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) { + return null; + } + + $normalizedTunnelHost = trim($tunnelHost, '[]'); + if (! filter_var($normalizedTunnelHost, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return null; + } + + foreach ($this->resolveEdgeDockerSubnets($edgeProxyServer) as $subnet) { + if ($this->ipv4InCidr($normalizedTunnelHost, $subnet)) { + return sprintf( + '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 + ); + } + } + + return null; + } + + private function resolveEdgeDockerSubnets(Server $edgeProxyServer): array + { + try { + $subnetsOutput = $this->runRemoteCommands($edgeProxyServer, [ + "docker network inspect \$(docker network ls -q) --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' 2>/dev/null | sort -u || true", + ], false); + } catch (\Throwable) { + return []; + } + + if (! is_string($subnetsOutput) || trim($subnetsOutput) === '') { + return []; + } + + return collect(preg_split('/\R+/', $subnetsOutput) ?: []) + ->map(fn (string $line) => trim($line)) + ->filter(fn (string $line) => preg_match('/^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/', $line) === 1) + ->values() + ->all(); + } + + private function ipv4InCidr(string $ip, string $cidr): bool + { + [$networkIp, $prefixLength] = array_pad(explode('/', $cidr, 2), 2, null); + if (! is_string($networkIp) || ! is_string($prefixLength)) { + return false; + } + + if (! filter_var($networkIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + if (! preg_match('/^\d+$/', $prefixLength)) { + return false; + } + + $prefixLength = (int) $prefixLength; + if ($prefixLength < 0 || $prefixLength > 32) { + return false; + } + + $ipLong = ip2long($ip); + $networkLong = ip2long($networkIp); + if ($ipLong === false || $networkLong === false) { + return false; + } + + if ($prefixLength === 0) { + return true; + } + + $mask = -1 << (32 - $prefixLength); + + return ($ipLong & $mask) === ($networkLong & $mask); + } + + private function parseDomainUrl(string $domain): ?Url + { + $normalizedDomain = trim($domain); + if ($normalizedDomain === '') { + return null; + } + + if (! Str::startsWith($normalizedDomain, ['http://', 'https://'])) { + $normalizedDomain = 'https://'.$normalizedDomain; + } + + try { + $url = Url::fromString($normalizedDomain, ['http', 'https']); + if ($url->getHost() === '') { + return null; + } + + return $url; + } catch (\Throwable) { + return null; + } + } + + private function resolvePublishedPort(array $compose, string $serviceName, ?int $requestedInternalPort, array $environmentMap): ?int + { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { + return null; + } + + $resolvedEnvironmentMap = $this->mergeComposeEnvironmentMap($serviceConfig, $environmentMap); + + $portMappings = $this->parsePortMappings((array) data_get($serviceConfig, 'ports', []), $resolvedEnvironmentMap) + ->filter(fn (array $mapping) => ! is_null($mapping['published'])) + ->values(); + + if ($portMappings->isEmpty()) { + return null; + } + + return $this->selectPublishedPortFromMappings($portMappings, $requestedInternalPort); + } + + private function resolveComposeServiceInternalPorts(array $compose, string $serviceName, array $environmentMap): Collection + { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { + return collect(); + } + + $resolvedEnvironmentMap = $this->mergeComposeEnvironmentMap($serviceConfig, $environmentMap); + + $ports = $this->parsePortMappings((array) data_get($serviceConfig, 'ports', []), $resolvedEnvironmentMap) + ->pluck('target') + ->filter(fn (mixed $port) => ! is_null($port)); + + $exposedPorts = collect((array) data_get($serviceConfig, 'expose', [])) + ->map(fn (mixed $port) => $this->resolvePortValue($port, $resolvedEnvironmentMap)) + ->filter(fn (?int $port) => ! is_null($port)); + + return $ports->merge($exposedPorts)->values(); + } + + private function mergeComposeEnvironmentMap(array $serviceConfig, array $environmentMap): array + { + $resolvedEnvironmentMap = $environmentMap; + + foreach ($this->composeEnvironmentDefinitions($serviceConfig) as $environmentKey => $rawValue) { + if ( + array_key_exists($environmentKey, $environmentMap) && + trim((string) $environmentMap[$environmentKey]) !== '' + ) { + continue; + } + + $resolvedValue = $this->resolveEnvironmentValue($rawValue, $resolvedEnvironmentMap); + if (! is_null($resolvedValue)) { + $resolvedEnvironmentMap[$environmentKey] = $resolvedValue; + } + } + + return $resolvedEnvironmentMap; + } + + private function composeEnvironmentDefinitions(array $serviceConfig): array + { + $environmentDefinitions = []; + $environment = data_get($serviceConfig, 'environment', []); + + if (! is_array($environment)) { + return $environmentDefinitions; + } + + foreach ($environment as $key => $value) { + if (is_int($key)) { + $environmentPair = explode('=', (string) $value, 2); + if (count($environmentPair) !== 2 || trim($environmentPair[0]) === '') { + continue; + } + + $environmentDefinitions[trim($environmentPair[0])] = $environmentPair[1]; + + continue; + } + + if (! is_string($key) || trim($key) === '') { + continue; + } + + if (! is_scalar($value) && ! is_null($value)) { + continue; + } + + $environmentDefinitions[trim($key)] = $value; + } + + return $environmentDefinitions; + } + + private function resolveEnvironmentValue(mixed $rawValue, array $environmentMap): ?string + { + if (is_null($rawValue)) { + return null; + } + + if (is_bool($rawValue)) { + return $rawValue ? 'true' : 'false'; + } + + if (is_int($rawValue) || is_float($rawValue)) { + return (string) $rawValue; + } + + $normalizedValue = trim((string) $rawValue); + if ($normalizedValue === '') { + return ''; + } + + if ( + preg_match( + '/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/', + $normalizedValue, + $matches + ) + ) { + $environmentKey = $matches[1]; + $defaultValue = trim((string) ($matches[3] ?? '')); + $resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null; + + if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') { + return trim((string) $resolvedEnvironmentValue); + } + + return $defaultValue !== '' ? $defaultValue : null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedValue, $matches)) { + $environmentKey = $matches[1]; + $resolvedEnvironmentValue = $environmentMap[$environmentKey] ?? null; + + if (! is_null($resolvedEnvironmentValue) && trim((string) $resolvedEnvironmentValue) !== '') { + return trim((string) $resolvedEnvironmentValue); + } + + return null; + } + + if (array_key_exists($normalizedValue, $environmentMap)) { + $resolvedEnvironmentValue = trim((string) $environmentMap[$normalizedValue]); + + return $resolvedEnvironmentValue !== '' ? $resolvedEnvironmentValue : null; + } + + return $normalizedValue; + } + + 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) { + return $matchingTarget['published']; + } + + $matchingPublished = $portMappings->first(fn (array $mapping) => $mapping['published'] === $requestedInternalPort); + if ($matchingPublished) { + return $matchingPublished['published']; + } + } + + 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']; + } + + return null; + } + + private function resolveComposeServicePorts(array $compose, string $serviceName): ?array + { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { + return null; + } + + $ports = data_get($serviceConfig, 'ports'); + + return is_array($ports) ? $ports : null; + } + + private function resolveComposeServiceConfig(array $compose, string $serviceName): ?array + { + $services = data_get($compose, 'services', []); + if (! is_array($services) || empty($services)) { + return null; + } + + if (array_key_exists($serviceName, $services) && is_array($services[$serviceName])) { + return $services[$serviceName]; + } + + $normalizedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value(); + foreach ($services as $composeServiceName => $serviceConfig) { + $normalizedComposeServiceName = str((string) $composeServiceName)->replace('-', '_')->replace('.', '_')->value(); + if ($normalizedComposeServiceName !== $normalizedServiceName || ! is_array($serviceConfig)) { + continue; + } + + return $serviceConfig; + } + + // 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'))) + ->values(); + + if ($servicesWithPorts->count() === 1) { + return $servicesWithPorts->first(); + } + + return null; + } + + private function parsePortMappings(array $ports, array $environmentMap): Collection + { + $mappings = collect(); + + foreach ($ports as $portDefinition) { + if (is_array($portDefinition)) { + $protocol = strtolower((string) data_get($portDefinition, 'protocol', 'tcp')); + if ($protocol === 'udp') { + continue; + } + + $target = $this->resolvePortValue(data_get($portDefinition, 'target'), $environmentMap); + $published = $this->resolvePortValue(data_get($portDefinition, 'published'), $environmentMap); + + if (! is_null($target) || ! is_null($published)) { + $mappings->push([ + 'target' => $target, + 'published' => $published, + ]); + } + + continue; + } + + if (is_string($portDefinition) || is_int($portDefinition)) { + $mapping = $this->parsePortMappingFromString((string) $portDefinition, $environmentMap); + if (! is_null($mapping)) { + $mappings->push($mapping); + } + } + } + + return $mappings; + } + + private function parsePortMappingFromString(string $portDefinition, array $environmentMap): ?array + { + $normalizedPortDefinition = trim($portDefinition); + if ($normalizedPortDefinition === '') { + return null; + } + + if (preg_match('/\/(tcp|udp)$/i', $normalizedPortDefinition, $protocolMatches)) { + if (strtolower($protocolMatches[1]) === 'udp') { + return null; + } + + $normalizedPortDefinition = preg_replace('/\/(tcp|udp)$/i', '', $normalizedPortDefinition) ?? $normalizedPortDefinition; + } + + if (str_contains($normalizedPortDefinition, ':')) { + $segments = $this->splitPortDefinitionSegments($normalizedPortDefinition); + if (count($segments) < 2) { + return null; + } + + $containerPort = $this->resolvePortValue(array_pop($segments), $environmentMap); + $hostPort = $this->resolvePortValue(array_pop($segments), $environmentMap); + + return [ + 'target' => $containerPort, + 'published' => $hostPort, + ]; + } + + return [ + 'target' => $this->resolvePortValue($normalizedPortDefinition, $environmentMap), + 'published' => null, + ]; + } + + 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)) { + return $rawPortValue; + } + + $normalizedPortValue = trim((string) $rawPortValue); + 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; + } + + if (preg_match('/^\d+$/', $normalizedPortValue)) { + return (int) $normalizedPortValue; + } + + if ( + preg_match( + '/^\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-?])([^}]*))?\}$/', + $normalizedPortValue, + $matches + ) + ) { + $environmentKey = $matches[1]; + $defaultPort = trim((string) ($matches[3] ?? '')); + + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + if ($defaultPort !== '' && is_numeric($defaultPort)) { + return (int) $defaultPort; + } + + return null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $normalizedPortValue, $matches)) { + $environmentKey = $matches[1]; + $resolvedEnvironmentPort = $environmentMap[$environmentKey] ?? null; + if (! is_null($resolvedEnvironmentPort) && is_numeric(trim((string) $resolvedEnvironmentPort))) { + return (int) trim((string) $resolvedEnvironmentPort); + } + + return null; + } + + if (array_key_exists($normalizedPortValue, $environmentMap) && is_numeric(trim((string) $environmentMap[$normalizedPortValue]))) { + return (int) trim((string) $environmentMap[$normalizedPortValue]); + } + + return null; + } + + private function logWarning(string $message): void + { + $container = Container::getInstance(); + if ($container instanceof Container && $container->bound('log')) { + $container->make('log')->warning($message); + + return; + } + + error_log($message); + } + + private function httpEntryPointName(): string + { + return $this->configString('constants.coolify.proxy.traefik.entrypoints.http', 'http'); + } + + private function httpsEntryPointName(): string + { + return $this->configString('constants.coolify.proxy.traefik.entrypoints.https', 'https'); + } + + private function certResolverName(): string + { + return $this->configString('constants.coolify.proxy.traefik.cert_resolver', 'letsencrypt'); + } + + private function configString(string $key, string $default): string + { + $container = Container::getInstance(); + if (! ($container instanceof Container) || ! $container->bound('config')) { + return $default; + } + + $value = trim((string) $container->make('config')->get($key, $default)); + + return $value !== '' ? $value : $default; + } + + private function hasUnsafeTraefikRuleValue(string $value): bool + { + return str_contains($value, '`') || preg_match('/[\r\n]/', $value) === 1; + } + + private function normalizeRemoteHost(string $rawHost): ?string + { + $host = trim($rawHost); + if ($host === '') { + return null; + } + + // Allow values like https://10.8.0.15:8080/path and extract only host. + if (Str::startsWith($host, ['http://', '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; + } + + // Drop accidental host:port values so published compose port remains authoritative. + 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; + } +} diff --git a/config/constants.php b/config/constants.php index 85322a928..45a6c73e2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -16,6 +16,15 @@ return [ 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => 'https://cdn.coolify.io/releases.json', + 'proxy' => [ + 'traefik' => [ + 'entrypoints' => [ + 'http' => env('TRAEFIK_HTTP_ENTRYPOINT', 'http'), + 'https' => env('TRAEFIK_HTTPS_ENTRYPOINT', 'https'), + ], + 'cert_resolver' => env('TRAEFIK_CERT_RESOLVER', 'letsencrypt'), + ], + ], ], 'urls' => [ 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/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php index b14cb414a..d1d27e21f 100644 --- a/tests/Feature/StartDatabaseProxyTest.php +++ b/tests/Feature/StartDatabaseProxyTest.php @@ -1,28 +1,8 @@ create(); - - $database = StandalonePostgresql::factory()->create([ - 'team_id' => $team->id, - 'is_public' => true, - 'public_port' => 5432, - ]); - - expect($database->is_public)->toBeTrue(); - $action = new StartDatabaseProxy; // Use reflection to test the private method directly diff --git a/tests/Unit/DatabaseProxyMasterRoutingTest.php b/tests/Unit/DatabaseProxyMasterRoutingTest.php new file mode 100644 index 000000000..c84379706 --- /dev/null +++ b/tests/Unit/DatabaseProxyMasterRoutingTest.php @@ -0,0 +1,789 @@ +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 configurable database proxy timeout when routing through the master domain router server', function () { + $edgeServer = \Mockery::mock(Server::class)->makePartial(); + $edgeServer->id = 3; + + $deploymentServer = \Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 4; + $deploymentServer->ip = '10.8.0.44'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $database = new StandalonePostgresql; + $database->uuid = 'standalone-db-timeout-uuid'; + $database->name = 'standalone-db-timeout'; + $database->public_port = 15444; + $database->public_port_timeout = 7200; + $database->setRelation('destination', (object) [ + 'server' => $deploymentServer, + 'network' => 'standalone-timeout-network', + ]); + $database->setRelation('environment', (object) [ + 'project' => (object) ['team_id' => 124], + ]); + + $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.44:5432;') + ->and($nginxConf)->toContain('proxy_timeout 7200s;'); +}); + +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 the dev host configuration path only for the bind mount source', function () { + $action = new class extends StartDatabaseProxy + { + public function configurationDirectory(string $databaseUuid): string + { + return $this->resolveConfigurationDirectory($databaseUuid); + } + + public function hostConfigurationDirectory(string $databaseUuid): string + { + return $this->resolveHostConfigurationDirectory($databaseUuid); + } + + protected function isDevelopmentEnvironment(): bool + { + return true; + } + }; + + expect($action->configurationDirectory('dev-db-uuid')) + ->toBe('/data/coolify/databases/dev-db-uuid/proxy') + ->and($action->hostConfigurationDirectory('dev-db-uuid')) + ->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/dev-db-uuid/proxy'); +}); + +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('skips starting database proxy when deployment server is missing', 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 + { + public array $warnings = []; + + 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->warnings)->toHaveCount(1) + ->and($action->warnings[0])->toContain('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/EdgeProxyRemotePortForwardServiceTest.php b/tests/Unit/EdgeProxyRemotePortForwardServiceTest.php new file mode 100644 index 000000000..022580059 --- /dev/null +++ b/tests/Unit/EdgeProxyRemotePortForwardServiceTest.php @@ -0,0 +1,327 @@ +bound('log') ? $container->make('log') : null; + $container->instance('log', new NullLogger); +}); + +afterEach(function () use (&$originalLogger) { + if (is_null($originalLogger)) { + return; + } + + Container::getInstance()->instance('log', $originalLogger); +}); + +it('mirrors application published tcp ports onto the edge server for remote deployments', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 1; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 2; + $deploymentServer->ip = '10.8.0.15'; + + $application = new Application; + $application->uuid = 'application-port-forward'; + $application->build_pack = 'nixpacks'; + $application->ports_mappings = '25565:25565'; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1) + ->and($manager->calls[0]['server_id'])->toBe(1); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 25565;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.15:25565;'); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $manager->calls[0]['commands'][3], $composeMatches); + $dockerCompose = base64_decode($composeMatches[1] ?? ''); + $parsedCompose = Yaml::parse($dockerCompose); + + expect(data_get($parsedCompose, 'services.application-application-port-forward-edge-port-proxy.container_name')) + ->toBe('application-application-port-forward-edge-port-proxy') + ->and(data_get($parsedCompose, 'services.application-application-port-forward-edge-port-proxy.ports')) + ->toBe(['25565:25565']); +}); + +it('mirrors application published udp ports onto the edge server for remote deployments', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 11; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 12; + $deploymentServer->ip = '10.8.0.25'; + + $application = new Application; + $application->uuid = 'application-udp-port-forward'; + $application->build_pack = 'nixpacks'; + $application->ports_mappings = '19132:19132/udp'; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 19132 udp;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.25:19132;'); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*docker-compose\\.yaml/", $manager->calls[0]['commands'][3], $composeMatches); + $dockerCompose = base64_decode($composeMatches[1] ?? ''); + $parsedCompose = Yaml::parse($dockerCompose); + + expect(data_get($parsedCompose, 'services.application-application-udp-port-forward-edge-port-proxy.ports')) + ->toBe(['19132:19132/udp']); +}); + +it('mirrors published compose ports for services onto the edge server', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 21; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 22; + $deploymentServer->ip = '10.8.0.35'; + + $service = new Service; + $service->uuid = 'service-port-forward'; + $service->docker_compose_raw = <<<'YAML' +services: + mc: + ports: + - "25565:25565" + - "19132:19132/udp" +YAML; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1) + ->and($manager->calls[0]['server_id'])->toBe(21); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 25565;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.35:25565;') + ->and($nginxConf)->toContain('listen 19132 udp;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.35:19132;'); +}); + +it('mirrors compose application published ports resolved from compose environment defaults', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 23; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 24; + $deploymentServer->ip = '10.8.0.55'; + + $application = new Application; + $application->uuid = 'application-compose-env-default-port'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_raw = <<<'YAML' +services: + mc: + ports: + - "${PORT}:25565" + environment: + - PORT=${PORT:-25565} +YAML; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 25565;') + ->and($nginxConf)->toContain('proxy_pass 10.8.0.55:25565;'); +}); + +it('warns and removes stale application edge port proxy when remote host is missing', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 31; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 32; + $deploymentServer->ip = ''; + + $application = new Application; + $application->uuid = 'application-missing-remote-host'; + $application->build_pack = 'nixpacks'; + $application->ports_mappings = '25565:25565'; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toHaveCount(1) + ->and($warnings[0])->toContain('remote host is missing') + ->and($manager->calls)->toHaveCount(1) + ->and($manager->calls[0]['commands'][0])->toContain('docker rm -f'); +}); + +it('skips reserved edge ports while keeping other published application ports', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 41; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 42; + $deploymentServer->ip = '10.8.0.45'; + + $application = new Application; + $application->uuid = 'application-reserved-ports'; + $application->build_pack = 'nixpacks'; + $application->ports_mappings = '80:80,25565:25565,443:443'; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toHaveCount(2) + ->and($warnings[0])->toContain('reserved on the edge server') + ->and($warnings[1])->toContain('reserved on the edge server') + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d \\| tee .*nginx\\.conf/", $manager->calls[0]['commands'][2], $nginxMatches); + $nginxConf = base64_decode($nginxMatches[1] ?? ''); + expect($nginxConf)->toContain('listen 25565;') + ->and($nginxConf)->not->toContain('listen 80;') + ->and($nginxConf)->not->toContain('listen 443;'); +}); + +it('deletes application edge port proxy containers', function () { + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 51; + + $application = new Application; + $application->uuid = 'application-delete-port-proxy'; + + $manager = new class extends EdgeProxyRemotePortForwardService + { + 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; + } + }; + + $manager->deleteApplicationWithServer($application, $edgeProxyServer); + + expect($manager->calls)->toHaveCount(1) + ->and($manager->calls[0]['server_id'])->toBe(51) + ->and($manager->calls[0]['commands'][0])->toContain('application-application-delete-port-proxy-edge-port-proxy'); +}); diff --git a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php new file mode 100644 index 000000000..39067083f --- /dev/null +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -0,0 +1,1449 @@ +bound('log') ? $container->make('log') : null; + $container->instance('log', new NullLogger); +}); + +afterEach(function () use (&$originalLogger) { + if (is_null($originalLogger)) { + return; + } + + Container::getInstance()->instance('log', $originalLogger); +}); + +it('generates edge traefik config for a remote domain route', function () { + $service = new EdgeProxyRemoteRouteService; + + $config = $service->generateTraefikConfig('service-uuid', [[ + 'host' => 'demo.example.com', + 'path' => '/', + 'upstream_url' => 'http://10.8.0.15:9010', + ]]); + + expect(data_get($config, 'http.middlewares.edge-service-uuid-redirect-to-https.redirectScheme.scheme'))->toBe('https') + ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.rule'))->toBe('Host(`demo.example.com`)') + ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.entryPoints'))->toBe(['http']) + ->and(data_get($config, 'http.routers.edge-service-uuid-http-1.middlewares'))->toBe(['edge-service-uuid-redirect-to-https']) + ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.entryPoints'))->toBe(['https']) + ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.tls.certResolver'))->toBe('letsencrypt') + ->and(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.servers.0.url'))->toBe('http://10.8.0.15:9010'); +}); + +it('uses configured traefik entrypoints and cert resolver for remote routes', function () { + $container = Container::getInstance(); + $hadOriginalConfig = $container->bound('config'); + $originalConfig = $hadOriginalConfig ? $container->make('config') : null; + + $container->instance('config', new Repository([ + 'constants' => [ + 'coolify' => [ + 'proxy' => [ + 'traefik' => [ + 'entrypoints' => [ + 'http' => 'web', + 'https' => 'websecure', + ], + 'cert_resolver' => 'myresolver', + ], + ], + ], + ], + ])); + + try { + $service = new EdgeProxyRemoteRouteService; + $config = $service->generateTraefikConfig('service-uuid', [[ + 'host' => 'demo.example.com', + 'path' => '/', + 'upstream_url' => 'http://10.8.0.15:9010', + ]]); + + expect(data_get($config, 'http.routers.edge-service-uuid-http-1.entryPoints'))->toBe(['web']) + ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.entryPoints'))->toBe(['websecure']) + ->and(data_get($config, 'http.routers.edge-service-uuid-https-1.tls.certResolver'))->toBe('myresolver'); + } finally { + if ($hadOriginalConfig && ! is_null($originalConfig)) { + $container->instance('config', $originalConfig); + } else { + unset($container['config']); + } + } +}); + +it('adds an insecure transport when an edge route falls back to the deployment proxy https entrypoint', function () { + $service = new EdgeProxyRemoteRouteService; + + $config = $service->generateTraefikConfig('service-uuid', [[ + 'host' => 'demo.example.com', + 'path' => '/', + 'upstream_url' => 'https://10.8.0.15:443', + 'pass_host_header' => true, + 'use_insecure_transport' => true, + ]]); + + expect(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.passHostHeader'))->toBeTrue() + ->and(data_get($config, 'http.services.edge-service-uuid-svc-1.loadBalancer.serversTransport'))->toBe('edge-service-uuid-transport-1') + ->and(data_get($config, 'http.serversTransports.edge-service-uuid-transport-1.insecureSkipVerify'))->toBeTrue(); +}); + +it('returns warning when syncing service route without a master domain router', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server + { + return null; + } + }; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 10; + + $service = new Service; + $service->uuid = 'service-no-master-router'; + $service->setRelation('server', $deploymentServer); + $service->setRelation('environment', (object) [ + 'project' => (object) [ + 'team_id' => 42, + ], + ]); + + $warnings = $manager->syncService($service); + + expect($warnings)->toHaveCount(1) + ->and($warnings[0])->toContain('no master domain router is configured for team 42'); +}); + +it('returns warning when syncing application route without a master domain router', function () { + $manager = new class extends EdgeProxyRemoteRouteService + { + protected function resolveEdgeProxyServerByTeamId(?int $teamId): ?Server + { + return null; + } + }; + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 11; + + $application = new Application; + $application->uuid = 'application-no-master-router'; + $application->setRelation('environment', (object) [ + 'project' => (object) [ + 'team_id' => 52, + ], + ]); + + $warnings = $manager->syncApplicationOnDeploymentServer($application, $deploymentServer); + + expect($warnings)->toHaveCount(1) + ->and($warnings[0])->toContain('no master domain router is configured for team 52'); +}); + +it('creates, updates, and deletes a stable edge route file per service 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 = 10; + $deploymentServer->ip = '10.8.0.15'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-test-uuid'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://demo.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(1); + + $expectedPath = '/tmp/proxy/dynamic/service-remote-service-test-uuid.yaml'; + $expectedTempPath = '/tmp/proxy/dynamic/service-remote-service-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.15:9010'); + + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9020:3000" +YAML; + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->toBe([]) + ->and($manager->calls)->toHaveCount(2); + + $secondWriteCommands = implode("\n", $manager->calls[1]['commands']); + expect($secondWriteCommands)->toContain($expectedPath) + ->and($secondWriteCommands)->toContain($expectedTempPath) + ->and($secondWriteCommands)->toContain('tee') + ->and($secondWriteCommands)->toContain('mv'); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[1]['commands'][1], $secondPayloadMatches); + $secondPayload = base64_decode($secondPayloadMatches[1]); + expect($secondPayload)->toContain('http://10.8.0.15:9020'); + + $manager->deleteServiceWithServer($service, $edgeProxyServer); + + expect($manager->calls)->toHaveCount(3); + $deleteCommands = implode("\n", $manager->calls[2]['commands']); + expect($deleteCommands)->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('keeps application edge route files for HSTS-sensitive domains by falling back to the deployment proxy https entrypoint', 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' => 'TRAEFIK']; + + $application = new Application; + $application->uuid = 'application-missing-port-fallback'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://missing-port.example.com:3000'; + $application->ports_mappings = null; + $application->ports_exposes = '3000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'deployment server HTTPS proxy instead'))) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('https://10.8.0.32:443') + ->and($payload)->toContain('passHostHeader: true') + ->and($payload)->toContain('serversTransport: edge-application-missing-port-fallback-transport-1') + ->and($payload)->toContain('insecureSkipVerify: true') + ->and($payload)->toContain('certResolver: letsencrypt'); +}); + +it('does not fall back through the deployment proxy when an application domain targets an unknown internal port', 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 = 33; + $deploymentServer->ip = '10.8.0.33'; + $deploymentServer->proxy = ['type' => 'TRAEFIK']; + + $application = new Application; + $application->uuid = 'application-invalid-fallback-port'; + $application->build_pack = 'nixpacks'; + $application->fqdn = 'https://invalid-port.example.com:9999'; + $application->ports_mappings = null; + $application->ports_exposes = '3000,4000'; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('published host port could not be resolved') + ->and(collect($warnings)->contains(fn (string $warning) => ! str_contains($warning, 'deployment server HTTPS proxy instead')))->toBeTrue() + ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/application-remote-application-invalid-fallback-port.yaml') + ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); +}); + +it('keeps valid application edge routes when one domain port cannot be resolved and returns warning only for invalid domain', 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 = 33; + $deploymentServer->ip = '10.8.0.33'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $application = new Application; + $application->uuid = 'application-partial-routes'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_domains = json_encode([ + 'web' => ['domain' => 'https://good-app.example.com:3000,https://bad-app.example.com:9999'], + ]); + $application->docker_compose_raw = <<<'YAML' +services: + web: + ports: + - "9060:3000" + - "9070:4000" +YAML; + + $warnings = $manager->syncApplicationWithServers($application, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('published host port could not be resolved') + ->and($manager->calls)->toHaveCount(1); + + $writeCommands = implode("\n", $manager->calls[0]['commands']); + expect($writeCommands)->toContain('/tmp/proxy/dynamic/application-remote-application-partial-routes.yaml') + ->and($writeCommands)->toContain('tee') + ->and($writeCommands)->not->toContain('rm -f'); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`good-app.example.com`)') + ->and($payload)->not->toContain('Host(`bad-app.example.com`)') + ->and($payload)->toContain('http://10.8.0.33:9060'); +}); + +it('does not generate edge route file when published port cannot be resolved and returns actionable warning', 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 = 11; + $deploymentServer->ip = '10.8.0.16'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-without-port'; + $service->docker_compose_raw = <<<'YAML' +services: + app: {} +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://broken.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $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/service-remote-service-without-port.yaml') + ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); +}); + +it('keeps service edge route files for HSTS-sensitive domains by falling back to the deployment proxy https entrypoint', 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 = 11; + $deploymentServer->ip = '10.8.0.16'; + $deploymentServer->proxy = ['type' => 'TRAEFIK']; + + $service = new Service; + $service->uuid = 'service-without-port-fallback'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://broken.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'deployment server HTTPS proxy instead'))) + ->and($manager->calls)->toHaveCount(1); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('https://10.8.0.16:443') + ->and($payload)->toContain('Host(`broken.example.com`)') + ->and($payload)->toContain('insecureSkipVerify: true') + ->and($payload)->toContain('certResolver: letsencrypt'); +}); + +it('keeps valid edge routes when one domain port cannot be resolved and returns warning only for invalid domain', 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 = 13; + $deploymentServer->ip = '10.8.0.18'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-partial-routes'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000" + - "9020:4000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://good.example.com:3000,https://bad.example.com:9999'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('published host port could not be resolved') + ->and($manager->calls)->toHaveCount(1); + + $writeCommands = implode("\n", $manager->calls[0]['commands']); + expect($writeCommands)->toContain('/tmp/proxy/dynamic/service-remote-service-partial-routes.yaml') + ->and($writeCommands)->toContain('tee') + ->and($writeCommands)->not->toContain('rm -f'); + + preg_match("/echo '([^']+)' \\| base64 -d/", $manager->calls[0]['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + expect($payload)->toContain('Host(`good.example.com`)') + ->and($payload)->not->toContain('Host(`bad.example.com`)') + ->and($payload)->toContain('http://10.8.0.18:9010'); +}); + +it('does not generate edge route file when remote host is missing and returns actionable warning', 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 = 12; + $deploymentServer->ip = ''; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-without-tunnel-host'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://missing-tunnel.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and($warnings[0])->toContain('remote host is missing') + ->and(implode("\n", $manager->calls[0]['commands']))->toContain('/tmp/proxy/dynamic/service-remote-service-without-tunnel-host.yaml') + ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); +}); + +it('returns warning instead of throwing when edge route file write fails', 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, + ]; + + if (str_contains(implode("\n", $commands), 'tee')) { + throw new RuntimeException('edge ssh unavailable'); + } + + 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 = 14; + $deploymentServer->ip = '10.8.0.19'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-write-failure'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://write-failure.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'failed to write dynamic route configuration'))) + ->and($manager->calls)->toHaveCount(1); +}); + +it('normalizes remote tunnel host values before generating upstream url', 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 = 15; + $deploymentServer->ip = ''; + $deploymentServer->proxy = ['type' => 'NONE', 'tunnel_host' => 'https://10.8.0.20:9443/path']; + + $service = new Service; + $service->uuid = 'service-normalized-host'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://normalized.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $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.20:9010') + ->and($payload)->not->toContain('9443'); +}); + +it('resolves published port when application name differs but compose has a single service with 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 = 16; + $deploymentServer->ip = '10.8.0.21'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-single-compose-fallback'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9030:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'web'; + $application->fqdn = 'https://single-fallback.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $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.21:9030'); +}); + +it('ignores udp published ports when resolving upstream for edge routes', 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 = 17; + $deploymentServer->ip = '10.8.0.22'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-udp-filtering'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9010:3000/udp" + - "9020:3000/tcp" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://udp-filtering.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $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.22:9020') + ->and($payload)->not->toContain('http://10.8.0.22:9010'); +}); + +it('returns warning for invalid domains while keeping valid remote edge routes', 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 = 18; + $deploymentServer->ip = '10.8.0.23'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-invalid-domain-warning'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9040:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://valid.example.com:3000,https://:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'domain format is invalid'))) + ->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.example.com`)') + ->and($payload)->toContain('http://10.8.0.23:9040') + ->and($payload)->not->toContain('https://:3000'); +}); + +it('returns warning for unsupported domain protocols while keeping valid http routes', 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 = 19; + $deploymentServer->ip = '10.8.0.24'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-unsupported-protocol-warning'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9050:3000" + - "25565:25565" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://valid-http.example.com:3000,tcp://minecraft.example.com:25565'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $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-http.example.com`)') + ->and($payload)->toContain('http://10.8.0.24:9050') + ->and($payload)->not->toContain('minecraft.example.com'); +}); + +it('returns warning when remote tunnel ip overlaps with an edge docker subnet', 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, + ]; + + if (str_contains(implode("\n", $commands), 'docker network inspect')) { + return "10.8.0.0/24\n172.18.0.0/16\n"; + } + + return null; + } + }; + + $edgeProxyServer = Mockery::mock(Server::class)->makePartial(); + $edgeProxyServer->id = 0; + $edgeProxyServer->exists = true; + $edgeProxyServer->shouldReceive('proxyType')->andReturn('TRAEFIK'); + $edgeProxyServer->shouldReceive('proxyPath')->andReturn('/tmp/proxy'); + + $deploymentServer = Mockery::mock(Server::class)->makePartial(); + $deploymentServer->id = 20; + $deploymentServer->ip = '10.8.0.40'; + $deploymentServer->proxy = ['type' => 'NONE']; + + $service = new Service; + $service->uuid = 'service-overlap-warning'; + $service->docker_compose_raw = <<<'YAML' +services: + app: + ports: + - "9060:3000" +YAML; + + $application = new ServiceApplication; + $application->name = 'app'; + $application->fqdn = 'https://overlap.example.com:3000'; + + $service->setRelation('applications', collect([$application])); + $application->setRelation('service', $service); + + $warnings = $manager->syncServiceWithServers($service, $edgeProxyServer, $deploymentServer); + + expect($warnings)->not->toBeEmpty() + ->and(collect($warnings)->contains(fn (string $warning) => str_contains($warning, 'overlaps edge Docker network subnet 10.8.0.0/24'))); + + $writeCall = collect($manager->calls)->first( + fn (array $call) => str_contains(implode("\n", $call['commands']), 'base64 -d | tee') + ); + expect($writeCall)->not->toBeNull(); + + preg_match("/echo '([^']+)' \\| base64 -d/", $writeCall['commands'][1], $payloadMatches); + $payload = base64_decode($payloadMatches[1]); + + 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('resolves compose application published port from compose environment defaults', 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-compose-env-default-port'; + $application->build_pack = 'dockercompose'; + $application->docker_compose_domains = json_encode([ + 'web' => ['domain' => 'https://compose-env-default.example.com:3000'], + ]); + $application->docker_compose_raw = <<<'YAML' +services: + web: + ports: + - "${APP_HOST_PORT}:3000" + environment: + - APP_HOST_PORT=${APP_HOST_PORT:-9082} +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('http://10.8.0.37:9082'); +}); + +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'); +});