feat(proxy): add database-backed config storage with disk backups (#8905)

This commit is contained in:
Andras Bacsai 2026-03-11 14:44:12 +01:00 committed by GitHub
commit 6815fbda29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 210 additions and 50 deletions

View file

@ -4,6 +4,7 @@
use App\Models\Server; use App\Models\Server;
use App\Services\ProxyDashboardCacheService; use App\Services\ProxyDashboardCacheService;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class GetProxyConfiguration class GetProxyConfiguration
@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string
return 'OK'; return 'OK';
} }
$proxy_path = $server->proxyPath();
$proxy_configuration = null; $proxy_configuration = null;
// If not forcing regeneration, try to read existing configuration
if (! $forceRegenerate) { if (! $forceRegenerate) {
$payload = [ // Primary source: database
"mkdir -p $proxy_path", $proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
"cat $proxy_path/docker-compose.yml 2>/dev/null",
]; // Backfill: existing servers may not have DB config yet — read from disk once
$proxy_configuration = instant_remote_process($payload, $server, false); if (empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = $this->backfillFromDisk($server);
}
} }
// Generate default configuration if: // Generate default configuration as last resort
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
// Extract custom commands from existing config before regenerating
$custom_commands = []; $custom_commands = [];
if (! empty(trim($proxy_configuration ?? ''))) { if (! empty(trim($proxy_configuration ?? ''))) {
$custom_commands = extractCustomProxyCommands($server, $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(); $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; 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;
}
} }

View file

@ -9,19 +9,41 @@ class SaveProxyConfiguration
{ {
use AsAction; use AsAction;
private const MAX_BACKUPS = 10;
public function handle(Server $server, string $configuration): void public function handle(Server $server, string $configuration): void
{ {
$proxy_path = $server->proxyPath(); $proxy_path = $server->proxyPath();
$docker_compose_yml_base64 = base64_encode($configuration); $docker_compose_yml_base64 = base64_encode($configuration);
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
// Update the saved settings hash // Only create a backup if the configuration actually changed
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; $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(); $server->save();
// Transfer the configuration file to the server $backup_path = "$proxy_path/backups";
instant_remote_process([
"mkdir -p $proxy_path", // Transfer the configuration file to the server, with backup if changed
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", $commands = ["mkdir -p $proxy_path"];
], $server);
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);
} }
} }

View file

@ -51,6 +51,7 @@ public function mount()
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false); $this->syncData(false);
$this->loadProxyConfiguration();
} }
private function syncData(bool $toModel = false): void private function syncData(bool $toModel = false): void

View file

@ -4,6 +4,7 @@
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\Application; use App\Models\Application;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
/** /**
@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
} }
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = []) 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_path = $server->proxyPath();
$proxy_type = $server->proxyType(); $proxy_type = $server->proxyType();

View file

@ -1,7 +1,7 @@
@php use App\Enums\ProxyTypes; @endphp @php use App\Enums\ProxyTypes; @endphp
<div> <div>
@if ($server->proxyType()) @if ($server->proxyType())
<div x-init="$wire.loadProxyConfiguration"> <div>
@if ($selectedProxy !== 'NONE') @if ($selectedProxy !== 'NONE')
<form wire:submit='submit'> <form wire:submit='submit'>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -55,24 +55,19 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3>{{ $proxyTitle }}</h3> <h3>{{ $proxyTitle }}</h3>
@can('update', $server) @can('update', $server)
<div wire:loading wire:target="loadProxyConfiguration"> @if ($proxySettings)
<x-forms.button disabled>Reset Configuration</x-forms.button> <x-modal-confirmation title="Reset Proxy Configuration?"
</div> buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
<div wire:loading.remove wire:target="loadProxyConfiguration"> :actions="[
@if ($proxySettings) 'Reset proxy configuration to default settings',
<x-modal-confirmation title="Reset Proxy Configuration?" 'All custom configurations will be lost',
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration" 'Custom ports and entrypoints will be removed',
:actions="[ ]" confirmationText="{{ $server->name }}"
'Reset proxy configuration to default settings', confirmationLabel="Please confirm by entering the server name below"
'All custom configurations will be lost', shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
'Custom ports and entrypoints will be removed', :confirmWithPassword="false" :confirmWithText="true">
]" confirmationText="{{ $server->name }}" </x-modal-confirmation>
confirmationLabel="Please confirm by entering the server name below" @endif
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
:confirmWithPassword="false" :confirmWithText="true">
</x-modal-confirmation>
@endif
</div>
@endcan @endcan
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value) @if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<button type="button" x-show="traefikWarningsDismissed" <button type="button" x-show="traefikWarningsDismissed"
@ -134,19 +129,14 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
@endif @endif
</div> </div>
@endif @endif
<div wire:loading wire:target="loadProxyConfiguration" class="pt-4"> @if ($proxySettings)
<x-loading text="Loading proxy configuration..." /> <div class="flex flex-col gap-2 pt-2">
</div> <x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
<div wire:loading.remove wire:target="loadProxyConfiguration"> monacoEditorLanguage="yaml"
@if ($proxySettings) label="Configuration file ( {{ $this->configurationFilePath }} )"
<div class="flex flex-col gap-2 pt-2"> name="proxySettings" id="proxySettings" rows="30" />
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor </div>
monacoEditorLanguage="yaml" @endif
label="Configuration file ( {{ $this->configurationFilePath }} )"
name="proxySettings" id="proxySettings" rows="30" />
</div>
@endif
</div>
</form> </form>
@elseif($selectedProxy === 'NONE') @elseif($selectedProxy === 'NONE')
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View file

@ -0,0 +1,109 @@
<?php
use App\Actions\Proxy\GetProxyConfiguration;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Spatie\SchemalessAttributes\SchemalessAttributes;
beforeEach(function () {
Log::spy();
Cache::spy();
});
function mockServerWithDbConfig(?string $savedConfig): object
{
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
$proxyAttributes->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);
});