fix(proxy): validate stored config matches proxy type (#9146)
This commit is contained in:
commit
5c460dd2a1
3 changed files with 106 additions and 3 deletions
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
namespace App\Actions\Proxy;
|
namespace App\Actions\Proxy;
|
||||||
|
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\ProxyDashboardCacheService;
|
use App\Services\ProxyDashboardCacheService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class GetProxyConfiguration
|
class GetProxyConfiguration
|
||||||
{
|
{
|
||||||
|
|
@ -24,6 +26,17 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
// Primary source: database
|
// Primary source: database
|
||||||
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
|
$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
|
// Backfill: existing servers may not have DB config yet — read from disk once
|
||||||
if (empty(trim($proxy_configuration ?? ''))) {
|
if (empty(trim($proxy_configuration ?? ''))) {
|
||||||
$proxy_configuration = $this->backfillFromDisk($server);
|
$proxy_configuration = $this->backfillFromDisk($server);
|
||||||
|
|
@ -55,6 +68,29 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
return $proxy_configuration;
|
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.
|
* Backfill: read config from disk for servers that predate DB storage.
|
||||||
* Stores the result in the database so future reads skip SSH entirely.
|
* Stores the result in the database so future reads skip SSH entirely.
|
||||||
|
|
|
||||||
|
|
@ -1471,6 +1471,9 @@ public function changeProxy(string $proxyType, bool $async = true)
|
||||||
if ($validProxyTypes->contains(str($proxyType)->lower())) {
|
if ($validProxyTypes->contains(str($proxyType)->lower())) {
|
||||||
$this->proxy->set('type', str($proxyType)->upper());
|
$this->proxy->set('type', str($proxyType)->upper());
|
||||||
$this->proxy->set('status', 'exited');
|
$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();
|
$this->save();
|
||||||
if ($this->proxySet()) {
|
if ($this->proxySet()) {
|
||||||
if ($async) {
|
if ($async) {
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,26 @@
|
||||||
Cache::spy();
|
Cache::spy();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockServerWithDbConfig(?string $savedConfig): object
|
function mockServerWithDbConfig(?string $savedConfig, string $proxyType = 'TRAEFIK'): object
|
||||||
{
|
{
|
||||||
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
||||||
$proxyAttributes->shouldReceive('get')
|
$proxyAttributes->shouldReceive('get')
|
||||||
->with('last_saved_proxy_configuration')
|
->with('last_saved_proxy_configuration')
|
||||||
->andReturn($savedConfig);
|
->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 = Mockery::mock('App\Models\Server');
|
||||||
$server->shouldIgnoreMissing();
|
$server->shouldIgnoreMissing();
|
||||||
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
||||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||||
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
||||||
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
$server->shouldReceive('proxyType')->andReturn($proxyType);
|
||||||
$server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
|
$server->shouldReceive('proxyPath')->andReturn($proxyPath);
|
||||||
|
|
||||||
return $server;
|
return $server;
|
||||||
}
|
}
|
||||||
|
|
@ -107,3 +113,61 @@ function mockServerWithDbConfig(?string $savedConfig): object
|
||||||
|
|
||||||
expect($result)->toBe($savedConfig);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue