diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 31eea7692..4c07c1393 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -4,6 +4,7 @@ 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; @@ -64,6 +65,14 @@ class DeleteService '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 5e1f2aee5..0e0498fc0 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -3,6 +3,7 @@ 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; @@ -18,6 +19,7 @@ class StartService { $service->parse(); $edgeRoutingWarnings = []; + $edgePortForwardWarnings = []; try { $edgeRoutingWarnings = app(EdgeProxyRemoteRouteService::class)->syncService($service); } catch (\Throwable $exception) { @@ -27,6 +29,15 @@ class StartService ]); $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); } @@ -38,6 +49,9 @@ class StartService 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 e76faa20b..be2e17ab4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -19,6 +19,7 @@ use App\Models\StandaloneDocker; use App\Models\SwarmDocker; use App\Notifications\Application\DeploymentFailed; use App\Notifications\Application\DeploymentSuccess; +use App\Services\EdgeProxyRemotePortForwardService; use App\Services\EdgeProxyRemoteRouteService; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\ExecuteRemoteCommand; @@ -525,6 +526,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue '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' + ); + } } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index cff312716..0006c895c 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -7,7 +7,6 @@ use App\Actions\Database\StopDatabase; use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; -use App\Services\EdgeProxyRemoteRouteService; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Service; @@ -19,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; @@ -109,6 +110,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } 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(); 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 index 2a5229c99..be4ceeeea 100644 --- a/app/Services/EdgeProxyRemoteRouteService.php +++ b/app/Services/EdgeProxyRemoteRouteService.php @@ -1093,12 +1093,14 @@ class EdgeProxyRemoteRouteService private function resolvePublishedPort(array $compose, string $serviceName, ?int $requestedInternalPort, array $environmentMap): ?int { - $ports = $this->resolveComposeServicePorts($compose, $serviceName); - if (! is_array($ports)) { + $serviceConfig = $this->resolveComposeServiceConfig($compose, $serviceName); + if (! is_array($serviceConfig)) { return null; } - $portMappings = $this->parsePortMappings($ports, $environmentMap) + $resolvedEnvironmentMap = $this->mergeComposeEnvironmentMap($serviceConfig, $environmentMap); + + $portMappings = $this->parsePortMappings((array) data_get($serviceConfig, 'ports', []), $resolvedEnvironmentMap) ->filter(fn (array $mapping) => ! is_null($mapping['published'])) ->values(); @@ -1116,17 +1118,132 @@ class EdgeProxyRemoteRouteService return collect(); } - $ports = $this->parsePortMappings((array) data_get($serviceConfig, 'ports', []), $environmentMap) + $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, $environmentMap)) + ->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)) { 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 index f244c0745..39067083f 100644 --- a/tests/Unit/EdgeProxyRemoteRouteServiceTest.php +++ b/tests/Unit/EdgeProxyRemoteRouteServiceTest.php @@ -1207,7 +1207,7 @@ it('deletes application edge route when deployment server is the same as edge pr 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']))->toContain('/tmp/proxy/dynamic/application-remote-application-edge-self.yaml') ->and(implode("\n", $manager->calls[0]['commands']))->not->toContain('tee'); }); @@ -1352,6 +1352,56 @@ YAML; 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 {