Files
coolify/app/Livewire/Server/Proxy.php
T
Andras Bacsai ef90fe06f1 feat(proxy): back up acme.json before changes and allow restoring backups
Coolify copies acme.json to a timestamped backup before every change and
keeps the last 10. The proxy page lists the backups with Restore and Delete
for team admins, and the restore dialog can restart the proxy right away.
2026-09-30 18:10:10 +02:00

537 lines
19 KiB
PHP

<?php
namespace App\Livewire\Server;
use App\Actions\Proxy\DeleteTraefikAcmeBackup;
use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Actions\Proxy\ListTraefikAcmeBackups;
use App\Actions\Proxy\RestoreTraefikAcmeBackup;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Rules\SafeExternalUrl;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Gate;
use Livewire\Component;
class Proxy extends Component
{
use AuthorizesRequests;
use ListensToTeamChannel;
public Server $server;
public ?string $selectedProxy = null;
public $proxySettings = null;
public bool $redirectEnabled = true;
public ?string $redirectUrl = null;
public bool $generateExactLabels = false;
public array $traefikCertificates = [];
public bool $traefikCertificatesLoaded = false;
/** @var array<int, array{name: string, created_at: string, size: int}> */
public array $traefikAcmeBackups = [];
/**
* Cache the versions.json file data in memory for this component instance.
* This avoids multiple file reads during a single request/render cycle.
*/
protected ?array $cachedVersionsFile = null;
public function getListeners()
{
return [
'saveConfiguration' => 'submit',
...$this->teamChannelListeners([
'ProxyStatusChangedUI' => '$refresh',
]),
];
}
protected function rules()
{
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
];
}
public function mount()
{
$this->selectedProxy = $this->server->proxyType();
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
$this->syncData(false);
$this->clearAppliedTraefikBranchWarning();
}
private function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->server->settings->generate_exact_labels = $this->generateExactLabels;
} else {
$this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false;
}
}
/**
* Get Traefik versions from cached data with in-memory optimization.
* Returns array like: ['v3.5' => '3.5.6', 'v3.6' => '3.6.2']
*
* This method adds an in-memory cache layer on top of the global
* get_traefik_versions() helper to avoid multiple calls during
* a single component lifecycle/render.
*/
protected function getTraefikVersions(): ?array
{
// In-memory cache for this component instance (per-request)
if ($this->cachedVersionsFile !== null) {
return data_get($this->cachedVersionsFile, 'traefik');
}
// Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data();
if (! $versionsData) {
return null;
}
$this->cachedVersionsFile = $versionsData;
$traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null;
}
public function getConfigurationFilePathProperty(): string
{
return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
}
public function changeProxy()
{
try {
$this->authorize('update', $this->server);
$this->server->proxy = null;
$this->server->detected_traefik_version = null;
$this->server->traefik_outdated_info = null;
$this->server->save();
$this->dispatch('reloadWindow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function selectProxy($proxy_type)
{
try {
$this->authorize('update', $this->server);
$this->server->changeProxy($proxy_type, async: false);
$this->selectedProxy = $this->server->proxy->type;
$this->dispatch('reloadWindow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->server);
$this->validate();
$this->syncData(true);
$this->server->settings->save();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSaveRedirect()
{
try {
$this->authorize('update', $this->server);
$this->server->proxy->redirect_enabled = $this->redirectEnabled;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function resetProxyConfiguration()
{
try {
$this->authorize('update', $this->server);
// Explicitly regenerate default configuration
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function loadProxyConfiguration()
{
try {
$this->proxySettings = GetProxyConfiguration::run($this->server);
$this->clearAppliedTraefikBranchWarning();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getTraefikVersionForWarningProperty(): ?string
{
if ($this->server->detected_traefik_version) {
return $this->server->detected_traefik_version;
}
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
return null;
}
$configuration = $this->server->proxy->get('last_saved_proxy_configuration');
if (! is_string($configuration) || ! preg_match('/^\s*image:\s*[\'\"]?traefik:(v?\d+\.\d+(?:\.\d+)?|latest)[\'\"]?\s*$/mi', $configuration, $matches)) {
return null;
}
return $matches[1];
}
/**
* The saved caddy-docker-proxy image when it is older than 2.9 (Caddy 2.7), else null.
*/
public function getOutdatedCaddyImageProperty(): ?string
{
return $this->server->outdatedCaddyProxyImage();
}
public function loadTraefikCertificates(): void
{
$this->traefikCertificates = [];
try {
$this->authorize('view', $this->server);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->traefikCertificatesLoaded = true;
} catch (\Throwable $e) {
$this->traefikCertificatesLoaded = true;
handleError($e, $this);
}
$this->loadTraefikAcmeBackups();
}
private function loadTraefikAcmeBackups(): void
{
$this->traefikAcmeBackups = [];
try {
if (Gate::allows('manageProxy', $this->server)) {
$this->traefikAcmeBackups = ListTraefikAcmeBackups::run($this->server);
}
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/** Default of the "Restart the proxy now" option in the acme.json restore dialog. */
public bool $restartProxyAfterAcmeRestore = true;
/**
* @param array<int, string> $selectedActions checkbox ids selected in the restore dialog
*/
public function restoreTraefikAcmeBackup(string $backupName, string $password = '', array $selectedActions = []): void
{
try {
$this->authorize('manageProxy', $this->server);
RestoreTraefikAcmeBackup::run($this->server, $backupName);
auditLog('ui.proxy.acme_backup_restored', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'backup' => $backupName,
]);
$this->loadTraefikCertificates();
// A running Traefik keeps its certificates in memory and can write them back to acme.json.
if (in_array('restartProxyAfterAcmeRestore', $selectedActions, true)) {
RestartProxyJob::dispatch($this->server);
auditLog('ui.proxy.restarted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'acme.json restored. The proxy is restarting to load the restored certificates.');
return;
}
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'acme.json restored. Restart the proxy to load the restored certificates.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function deleteTraefikAcmeBackup(string $backupName, string $password = ''): void
{
try {
$this->authorize('manageProxy', $this->server);
DeleteTraefikAcmeBackup::run($this->server, $backupName);
auditLog('ui.proxy.acme_backup_deleted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'backup' => $backupName,
]);
$this->loadTraefikAcmeBackups();
$this->dispatch('success', 'acme.json backup deleted.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function deleteTraefikCertificate(string $certificateId, string $password = ''): void
{
try {
$this->authorize('update', $this->server);
$certificate = DeleteTraefikCertificate::run($this->server, $certificateId);
auditLog('ui.proxy.certificate_deleted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'domain' => $certificate['main_domain'],
'resolver' => $certificate['resolver'],
]);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->loadTraefikAcmeBackups();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Get the latest Traefik version for this server's current branch.
*
* This compares the server's detected version against available versions
* in versions.json to determine the latest patch for the current branch,
* or the newest available version if no current version is detected.
*/
public function getLatestTraefikVersionProperty(): ?string
{
try {
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Get this server's current version
$currentVersion = $this->traefikVersionForWarning;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
$current = ltrim($currentVersion, 'v');
if (preg_match('/^(\d+\.\d+)/', $current, $matches)) {
$branch = "v{$matches[1]}";
if (isset($traefikVersions[$branch])) {
$version = $traefikVersions[$branch];
return str_starts_with($version, 'v') ? $version : "v{$version}";
}
}
}
// Return the newest available version
$newestVersion = collect($traefikVersions)
->map(fn ($v) => ltrim($v, 'v'))
->sortBy(fn ($v) => $v, SORT_NATURAL)
->last();
return $newestVersion ? "v{$newestVersion}" : null;
} catch (\Throwable $e) {
return null;
}
}
public function getIsTraefikOutdatedProperty(): bool
{
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
$latestVersion = $this->latestTraefikVersion;
if (! $latestVersion) {
return false;
}
// Compare versions (strip 'v' prefix)
$current = ltrim($currentVersion, 'v');
$latest = ltrim($latestVersion, 'v');
return version_compare($current, $latest, '<');
}
/**
* Check if a newer Traefik branch (minor version) is available for this server.
* Returns the branch identifier (e.g., "v3.6") if a newer branch exists.
*/
public function getNewerTraefikBranchAvailableProperty(): ?string
{
try {
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value) {
return null;
}
// Get this server's current version
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
$configuredBranch = $this->getConfiguredTraefikBranch();
// Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info;
$storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) {
$upgradeTarget = str_starts_with($outdatedInfo['upgrade_target'], 'v')
? $outdatedInfo['upgrade_target']
: "v{$outdatedInfo['upgrade_target']}";
if ($configuredBranch && version_compare($configuredBranch, ltrim($upgradeTarget, 'v'), '>=')) {
return null;
}
return $upgradeTarget;
}
}
// Fallback: compute from cached versions data
$traefikVersions = $this->getTraefikVersions();
if (! $traefikVersions) {
return null;
}
// Extract current branch (e.g., "3.5" from "3.5.6")
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)/', $current, $matches)) {
return null;
}
$currentBranch = $matches[1];
// Find the newest branch that's greater than current
$newestBranch = null;
foreach ($traefikVersions as $branch => $version) {
$branchNum = ltrim($branch, 'v');
if (version_compare($branchNum, $currentBranch, '>')) {
if (! $newestBranch || version_compare($branchNum, $newestBranch, '>')) {
$newestBranch = $branchNum;
}
}
}
if (! $newestBranch || ($configuredBranch && version_compare($configuredBranch, $newestBranch, '>='))) {
return null;
}
return "v{$newestBranch}";
} catch (\Throwable $e) {
return null;
}
}
public function getLatestNewerTraefikVersionProperty(): ?string
{
$branch = $this->newerTraefikBranchAvailable;
$version = $branch ? ($this->getTraefikVersions()[$branch] ?? null) : null;
return $version ? 'v'.ltrim($version, 'v') : null;
}
private function getConfiguredTraefikBranch(): ?string
{
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
return null;
}
if (! is_string($this->proxySettings)) {
return null;
}
if (! preg_match('/^\s*image:\s*[\'\"]?traefik:v?(\d+\.\d+)(?:\.\d+)?[\'\"]?\s*$/mi', $this->proxySettings, $matches)) {
return null;
}
return $matches[1];
}
private function clearAppliedTraefikBranchWarning(): void
{
$outdatedInfo = $this->server->traefik_outdated_info;
if (data_get($outdatedInfo, 'type') !== 'minor_upgrade') {
return;
}
$configuredBranch = $this->getConfiguredTraefikBranch();
$upgradeTarget = ltrim((string) data_get($outdatedInfo, 'upgrade_target'), 'v');
if (! $configuredBranch || ! $upgradeTarget || version_compare($configuredBranch, $upgradeTarget, '<')) {
return;
}
Server::query()
->whereKey($this->server->id)
->where('traefik_outdated_info->type', data_get($outdatedInfo, 'type'))
->where('traefik_outdated_info->current', data_get($outdatedInfo, 'current'))
->where('traefik_outdated_info->upgrade_target', data_get($outdatedInfo, 'upgrade_target'))
->update(['traefik_outdated_info' => null]);
}
}