From 6488751fd2c65706c03abf5b34d5db6961dcf88d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:11:31 +0100 Subject: [PATCH] feat(proxy): add database-backed config storage with disk backups - Store proxy configuration in database as primary source for faster access - Implement automatic timestamped backups when configuration changes - Add backfill migration logic to recover configs from disk for legacy servers - Simplify UI by removing loading states (config now readily available) - Add comprehensive logging for debugging configuration generation and recovery - Include unit tests for config recovery scenarios --- app/Actions/Proxy/GetProxyConfiguration.php | 52 +++++++-- app/Actions/Proxy/SaveProxyConfiguration.php | 36 ++++-- app/Livewire/Server/Proxy.php | 1 + bootstrap/helpers/proxy.php | 8 ++ .../views/livewire/server/proxy.blade.php | 54 ++++----- tests/Unit/ProxyConfigRecoveryTest.php | 109 ++++++++++++++++++ 6 files changed, 210 insertions(+), 50 deletions(-) create mode 100644 tests/Unit/ProxyConfigRecoveryTest.php diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 3aa1d8d34..de44b476f 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -4,6 +4,7 @@ use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class GetProxyConfiguration @@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string return 'OK'; } - $proxy_path = $server->proxyPath(); $proxy_configuration = null; - // If not forcing regeneration, try to read existing configuration if (! $forceRegenerate) { - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); + // Primary source: database + $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration'); + + // Backfill: existing servers may not have DB config yet — read from disk once + if (empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = $this->backfillFromDisk($server); + } } - // Generate default configuration if: - // 1. Force regenerate is requested - // 2. Configuration file doesn't exist or is empty + // Generate default configuration as last resort if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { - // Extract custom commands from existing config before regenerating $custom_commands = []; if (! empty(trim($proxy_configuration ?? ''))) { $custom_commands = extractCustomProxyCommands($server, $proxy_configuration); } + Log::warning('Proxy configuration regenerated to defaults', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found', + ]); + $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value(); } @@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string return $proxy_configuration; } + + /** + * Backfill: read config from disk for servers that predate DB storage. + * Stores the result in the database so future reads skip SSH entirely. + */ + private function backfillFromDisk(Server $server): ?string + { + $proxy_path = $server->proxyPath(); + $result = instant_remote_process([ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ], $server, false); + + if (! empty(trim($result ?? ''))) { + $server->proxy->last_saved_proxy_configuration = $result; + $server->save(); + + Log::info('Proxy config backfilled to database from disk', [ + 'server_id' => $server->id, + ]); + + return $result; + } + + return null; + } } diff --git a/app/Actions/Proxy/SaveProxyConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php index 53fbecce2..bcfd5011d 100644 --- a/app/Actions/Proxy/SaveProxyConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -9,19 +9,41 @@ class SaveProxyConfiguration { use AsAction; + private const MAX_BACKUPS = 10; + public function handle(Server $server, string $configuration): void { $proxy_path = $server->proxyPath(); $docker_compose_yml_base64 = base64_encode($configuration); + $new_hash = str($docker_compose_yml_base64)->pipe('md5')->value; - // Update the saved settings hash - $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; + // Only create a backup if the configuration actually changed + $old_hash = $server->proxy->get('last_saved_settings'); + $config_changed = $old_hash && $old_hash !== $new_hash; + + // Update the saved settings hash and store full config as database backup + $server->proxy->last_saved_settings = $new_hash; + $server->proxy->last_saved_proxy_configuration = $configuration; $server->save(); - // Transfer the configuration file to the server - instant_remote_process([ - "mkdir -p $proxy_path", - "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", - ], $server); + $backup_path = "$proxy_path/backups"; + + // Transfer the configuration file to the server, with backup if changed + $commands = ["mkdir -p $proxy_path"]; + + if ($config_changed) { + $short_hash = substr($old_hash, 0, 8); + $timestamp = now()->format('Y-m-d_H-i-s'); + $backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml"; + $commands[] = "mkdir -p $backup_path"; + // Skip backup if a file with the same hash already exists (identical content) + $commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true"; + // Prune old backups, keep only the most recent ones + $commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true'; + } + + $commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null"; + + instant_remote_process($commands, $server); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..d5f30fca0 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -51,6 +51,7 @@ public function mount() $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); + $this->loadProxyConfiguration(); } private function syncData(bool $toModel = false): void diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index ac52c0af8..cf9f648bb 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -4,6 +4,7 @@ use App\Enums\ProxyTypes; use App\Models\Application; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Symfony\Component\Yaml\Yaml; /** @@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar } function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) { + Log::info('Generating default proxy configuration', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'custom_commands_count' => count($custom_commands), + 'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown', + ]); + $proxy_path = $server->proxyPath(); $proxy_type = $server->proxyType(); diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 8bee1a166..e7bfc151c 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -1,7 +1,7 @@ @php use App\Enums\ProxyTypes; @endphp
@if ($server->proxyType()) -
+
@if ($selectedProxy !== 'NONE')
@@ -55,24 +55,19 @@

{{ $proxyTitle }}

@can('update', $server) -
- Reset Configuration -
-
- @if ($proxySettings) - - - @endif -
+ @if ($proxySettings) + + + @endif @endcan @if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
@endif -
- -
-
- @if ($proxySettings) -
- -
- @endif -
+ @if ($proxySettings) +
+ +
+ @endif @elseif($selectedProxy === 'NONE')
diff --git a/tests/Unit/ProxyConfigRecoveryTest.php b/tests/Unit/ProxyConfigRecoveryTest.php new file mode 100644 index 000000000..219ec9bca --- /dev/null +++ b/tests/Unit/ProxyConfigRecoveryTest.php @@ -0,0 +1,109 @@ +shouldReceive('get') + ->with('last_saved_proxy_configuration') + ->andReturn($savedConfig); + + $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'); + + return $server; +} + +it('returns OK for NONE proxy type without reading config', function () { + $server = Mockery::mock('App\Models\Server'); + $server->shouldIgnoreMissing(); + $server->shouldReceive('proxyType')->andReturn('NONE'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe('OK'); +}); + +it('reads proxy configuration from database', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // ProxyDashboardCacheService is called at the end — mock it + $server->shouldReceive('proxyType')->andReturn('TRAEFIK'); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +}); + +it('preserves full custom config including labels, env vars, and custom commands', function () { + $customConfig = <<<'YAML' +services: + traefik: + image: traefik:v3.5 + command: + - '--entrypoints.http.address=:80' + - '--metrics.prometheus=true' + labels: + - 'traefik.enable=true' + - 'waf.custom.middleware=true' + environment: + CF_API_EMAIL: user@example.com + CF_API_KEY: secret-key +YAML; + + $server = mockServerWithDbConfig($customConfig); + + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($customConfig) + ->and($result)->toContain('waf.custom.middleware=true') + ->and($result)->toContain('CF_API_EMAIL') + ->and($result)->toContain('metrics.prometheus=true'); +}); + +it('logs warning when regenerating defaults', function () { + Log::swap(new \Illuminate\Log\LogManager(app())); + Log::spy(); + + // No DB config, no disk config — will try to regenerate + $server = mockServerWithDbConfig(null); + + // backfillFromDisk will be called — we need instant_remote_process to return empty + // Since it's a global function we can't easily mock it, so test the logging via + // the force regenerate path instead + try { + GetProxyConfiguration::run($server, forceRegenerate: true); + } catch (\Throwable $e) { + // generateDefaultProxyConfiguration may fail without full server setup + } + + Log::shouldHaveReceived('warning') + ->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults')) + ->once(); +}); + +it('does not read from disk when DB config exists', function () { + $savedConfig = "services:\n traefik:\n image: traefik:v3.5\n"; + $server = mockServerWithDbConfig($savedConfig); + + // If disk were read, instant_remote_process would be called. + // Since we're not mocking it and the test passes, it proves DB is used. + $result = GetProxyConfiguration::run($server); + + expect($result)->toBe($savedConfig); +});