From b8e52c6a45bbeb87037f8c40544e3207cef1db9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:32:34 +0100 Subject: [PATCH] feat(proxy): validate stored config matches current proxy type Add validation in GetProxyConfiguration to detect when stored proxy config belongs to a different proxy type (e.g., Traefik config on a Caddy server) and trigger regeneration with a warning log. Clear cached proxy configuration and settings when proxy type is changed to prevent stale configs from being reused. Includes tests verifying config rejection on type mismatch and graceful fallback on invalid YAML. --- app/Actions/Proxy/GetProxyConfiguration.php | 36 +++++++++++ app/Models/Server.php | 3 + tests/Unit/ProxyConfigRecoveryTest.php | 70 ++++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index de44b476f..159f12252 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -2,10 +2,12 @@ namespace App\Actions\Proxy; +use App\Enums\ProxyTypes; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\Yaml\Yaml; class GetProxyConfiguration { @@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string // Primary source: database $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + // Validate stored config matches current proxy type + if (! empty(trim($proxy_configuration ?? ''))) { + if (! $this->configMatchesProxyType($proxyType, $proxy_configuration)) { + Log::warning('Stored proxy config does not match current proxy type, will regenerate', [ + 'server_id' => $server->id, + 'proxy_type' => $proxyType, + ]); + $proxy_configuration = null; + } + } + // Backfill: existing servers may not have DB config yet — read from disk once if (empty(trim($proxy_configuration ?? ''))) { $proxy_configuration = $this->backfillFromDisk($server); @@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + /** + * Check that the stored docker-compose YAML contains the expected service + * for the server's current proxy type. Returns false if the config belongs + * to a different proxy type (e.g. Traefik config on a CADDY server). + */ + private function configMatchesProxyType(string $proxyType, string $configuration): bool + { + try { + $yaml = Yaml::parse($configuration); + $services = data_get($yaml, 'services', []); + + return match ($proxyType) { + ProxyTypes::TRAEFIK->value => isset($services['traefik']), + ProxyTypes::CADDY->value => isset($services['caddy']), + ProxyTypes::NGINX->value => isset($services['nginx']), + default => true, + }; + } catch (\Throwable $e) { + // If YAML is unparseable, don't block — let the existing flow handle it + return true; + } + } + /** * Backfill: read config from disk for servers that predate DB storage. * Stores the result in the database so future reads skip SSH entirely. diff --git a/app/Models/Server.php b/app/Models/Server.php index 527c744a5..ce877bd20 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true) if ($validProxyTypes->contains(str($proxyType)->lower())) { $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); + $this->proxy->set('last_saved_proxy_configuration', null); + $this->proxy->set('last_saved_settings', null); + $this->proxy->set('last_applied_settings', null); $this->save(); if ($this->proxySet()) { if ($async) { diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php index 219ec9bca..e10d899fe 100644 --- a/tests/Unit/ProxyConfigRecoveryTest.php +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -10,20 +10,26 @@ Cache::spy(); }); -function mockServerWithDbConfig(?string $savedConfig): object +function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object { $proxyAttributes = Mockery::mock(SchemalessAttributes::class); $proxyAttributes->shouldReceive('get') ->with('last_saved_proxy_configuration') ->andReturn($savedConfig); + $proxyPath = match ($proxyType) { + 'CADDY' => '/data/coolify/proxy/caddy', + 'NGINX' => '/data/coolify/proxy/nginx', + default => '/data/coolify/proxy/', + }; + $server = Mockery::mock('App\Models\Server'); $server->shouldIgnoreMissing(); $server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes); $server->shouldReceive('getAttribute')->with('id')->andReturn(1); $server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server'); - $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); - $server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy'); + $server->shouldReceive('proxyType')->andReturn($proxyType); + $server->shouldReceive('proxyPath')->andReturn($proxyPath); return $server; } @@ -107,3 +113,61 @@ function mockServerWithDbConfig(?string $savedConfig): object expect($result)->toBe($savedConfig); }); + +it('rejects stored Traefik config when proxy type is CADDY', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $traefikConfig = "services:\n traefik:\n image: traefik:v3.6\n"; + $server = mockServerWithDbConfig($traefikConfig, 'CADDY'); + + // Config type mismatch should trigger regeneration, which will try + // backfillFromDisk (instant_remote_process) then generateDefault. + // Both will fail in test env, but the warning log proves mismatch was detected. + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('rejects stored Caddy config when proxy type is TRAEFIK', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'TRAEFIK'); + + try { + GetProxyConfiguration::run($server); + } catch (\Throwable $e) { + // Expected — regeneration requires SSH/full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'does not match current proxy type')) + ->once(); +}); + +it('accepts stored Caddy config when proxy type is CADDY', function () { + $caddyConfig = "services:\n caddy:\n image: lucaslorentz/caddy-docker-proxy:2.8-alpine\n"; + $server = mockServerWithDbConfig($caddyConfig, 'CADDY'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($caddyConfig); +}); + +it('accepts stored config when YAML parsing fails', function () { + $invalidYaml = "this: is: not: [valid yaml: {{{}}}"; + $server = mockServerWithDbConfig($invalidYaml, 'TRAEFIK'); + + // Invalid YAML should not block — configMatchesProxyType returns true on parse failure + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($invalidYaml); +});