diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3bf91c281..3aa1d8d34 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string // 1. Force regenerate is requested // 2. Configuration file doesn't exist or is empty if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); + // Extract custom commands from existing config before regenerating + $custom_commands = []; + if (! empty(trim($proxy_configuration ?? ''))) { + $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); + } + + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } if (empty($proxy_configuration)) { diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 5bc1d005e..924bad307 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server) return $commands->flatten(); } -function generate_default_proxy_configuration(Server $server) +function extractCustomProxyCommands(Server $server, string $existing_config): array +{ + $custom_commands = []; + $proxy_type = $server->proxyType(); + + if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) { + return $custom_commands; + } + + try { + $yaml = Yaml::parse($existing_config); + $existing_commands = data_get($yaml, 'services.traefik.command', []); + + if (empty($existing_commands)) { + return $custom_commands; + } + + // Define default commands that Coolify generates + $default_command_prefixes = [ + '--ping=', + '--api.', + '--entrypoints.http.address=', + '--entrypoints.https.address=', + '--entrypoints.http.http.encodequerysemicolons=', + '--entryPoints.http.http2.maxConcurrentStreams=', + '--entrypoints.https.http.encodequerysemicolons=', + '--entryPoints.https.http2.maxConcurrentStreams=', + '--entrypoints.https.http3', + '--providers.file.', + '--certificatesresolvers.', + '--providers.docker', + '--providers.swarm', + '--log.level=', + '--accesslog.', + ]; + + // Extract commands that don't match default prefixes (these are custom) + foreach ($existing_commands as $command) { + $is_default = false; + foreach ($default_command_prefixes as $prefix) { + if (str_starts_with($command, $prefix)) { + $is_default = true; + break; + } + } + if (! $is_default) { + $custom_commands[] = $command; + } + } + } catch (\Exception $e) { + // If we can't parse the config, return empty array + // Silently fail to avoid breaking the proxy regeneration + } + + return $custom_commands; +} +function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); @@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server) $config['services']['traefik']['command'][] = '--providers.docker=true'; $config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false'; } + + // Append custom commands (e.g., trustedIPs for Cloudflare) + if (! empty($custom_commands)) { + foreach ($custom_commands as $custom_command) { + $config['services']['traefik']['command'][] = $custom_command; + } + } } elseif ($proxy_type === 'CADDY') { $config = [ 'networks' => $array_of_networks->toArray(), diff --git a/tests/Unit/ProxyCustomCommandsTest.php b/tests/Unit/ProxyCustomCommandsTest.php new file mode 100644 index 000000000..d68ca6dc7 --- /dev/null +++ b/tests/Unit/ProxyCustomCommandsTest.php @@ -0,0 +1,137 @@ + [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.address=:80', + '--entrypoints.https.address=:443', + '--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22', + '--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22', + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + // Mock a server with Traefik proxy type + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands) + ->toBeArray() + ->toHaveCount(2) + ->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22') + ->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22'); +}); + +it('returns empty array when only default commands exist', function () { + // Config with only default commands + $existingConfig = [ + 'services' => [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.address=:80', + '--entrypoints.https.address=:443', + '--providers.docker=true', + '--providers.docker.exposedbydefault=false', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('handles invalid yaml gracefully', function () { + $invalidYaml = 'this is not: valid: yaml::: content'; + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $invalidYaml); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('returns empty array for caddy proxy type', function () { + $existingConfig = [ + 'services' => [ + 'caddy' => [ + 'environment' => ['SOME_VAR=value'], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::CADDY->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('returns empty array when config is empty', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, ''); + + expect($customCommands)->toBeArray()->toBeEmpty(); +}); + +it('correctly identifies multiple custom command types', function () { + $existingConfig = [ + 'services' => [ + 'traefik' => [ + 'command' => [ + '--ping=true', + '--api.dashboard=true', + '--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20', + '--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20', + '--entrypoints.http.forwardedHeaders.insecure=true', + '--metrics.prometheus=true', + '--providers.docker=true', + ], + ], + ], + ]; + + $yamlConfig = Yaml::dump($existingConfig); + + $server = Mockery::mock('App\Models\Server'); + $server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value); + + $customCommands = extractCustomProxyCommands($server, $yamlConfig); + + expect($customCommands) + ->toBeArray() + ->toHaveCount(4) + ->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20') + ->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20') + ->toContain('--entrypoints.http.forwardedHeaders.insecure=true') + ->toContain('--metrics.prometheus=true'); +});