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\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;
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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();

View file

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