mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Move proxy TLS certificates to a separate page
This commit is contained in:
@@ -2,20 +2,13 @@
|
||||
|
||||
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
|
||||
@@ -35,13 +28,6 @@ class Proxy extends Component
|
||||
|
||||
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.
|
||||
@@ -237,114 +223,6 @@ class Proxy extends Component
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Proxy;
|
||||
|
||||
use App\Actions\Proxy\DeleteTraefikAcmeBackup;
|
||||
use App\Actions\Proxy\DeleteTraefikCertificate;
|
||||
use App\Actions\Proxy\GetTraefikCertificates;
|
||||
use App\Actions\Proxy\ListTraefikAcmeBackups;
|
||||
use App\Actions\Proxy\RestoreTraefikAcmeBackup;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Certificates extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public array $traefikCertificates = [];
|
||||
|
||||
public bool $traefikCertificatesLoaded = false;
|
||||
|
||||
/** @var array<int, array{name: string, created_at: string, size: int}> */
|
||||
public array $traefikAcmeBackups = [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.proxy.certificates');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Proxy;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class CertificatesShow extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('view', $this->server);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.proxy.certificates-show');
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@
|
||||
'children' => [
|
||||
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'],
|
||||
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()],
|
||||
['label' => 'TLS Certificates', 'route' => 'server.proxy.certificates', 'active' => $activeSubMenu === 'certificates', 'icon' => 'shield-star', 'visible' => $server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value],
|
||||
['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -83,164 +83,6 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||
<x-application.settings-section id="server-proxy-certificates-section" title="TLS certificates"
|
||||
x-init="$wire.loadTraefikCertificates()"
|
||||
helper="Review certificates stored in Traefik's acme.json file. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority. If a route still uses the domain, Traefik requests a new certificate after the restart.">
|
||||
<x-slot:actions>
|
||||
<x-forms.button type="button" wire:click="loadTraefikCertificates"
|
||||
wire:loading.attr="disabled" wire:target="loadTraefikCertificates">
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<div wire:loading.flex wire:target="loadTraefikCertificates"
|
||||
class="min-h-24 items-center justify-center">
|
||||
<x-loading text="Loading TLS certificates…" />
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="loadTraefikCertificates">
|
||||
@if ($traefikCertificatesLoaded && count($traefikCertificates) === 0)
|
||||
<x-empty size="sm" title="No TLS certificates found"
|
||||
description="Traefik's ACME storage does not contain certificate entries."
|
||||
icon-name="shield-star" />
|
||||
@elseif (count($traefikCertificates) > 0)
|
||||
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-3xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Resolver</th>
|
||||
<th>Alternative names</th>
|
||||
<th>Expires</th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($traefikCertificates as $certificate)
|
||||
<tr wire:key="traefik-certificate-{{ $certificate['id'] }}">
|
||||
<td class="font-medium text-neutral-950 dark:text-fg">
|
||||
{{ $certificate['main_domain'] }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono text-xs">{{ $certificate['resolver'] }}</span>
|
||||
@if ($certificate['store'])
|
||||
<span class="text-xs text-neutral-500 dark:text-fg-dim">
|
||||
({{ $certificate['store'] }})
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if (count($certificate['sans']) > 0)
|
||||
<div class="flex max-w-md flex-wrap gap-1">
|
||||
@foreach ($certificate['sans'] as $domain)
|
||||
<span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
{{ $domain }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-neutral-500 dark:text-fg-dim">None</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{ $certificate['expires_at'] ?? 'Unknown' }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Delete TLS Certificate?"
|
||||
buttonTitle="Delete"
|
||||
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
|
||||
:actions="[
|
||||
'Save the current acme.json as a backup that you can restore below.',
|
||||
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
|
||||
]"
|
||||
warningMessage="The proxy keeps using the certificate until you restart it. If a route still uses the domain, Traefik requests a new certificate after the restart. You can restore the backup below to undo this."
|
||||
confirmationText="{{ $certificate['main_domain'] }}"
|
||||
confirmationLabel="Confirm by entering the domain"
|
||||
shortConfirmationLabel="Domain"
|
||||
step2ButtonText="Delete Certificate"
|
||||
isErrorButton :confirmWithPassword="false"
|
||||
:confirmWithText="true" />
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@can('manageProxy', $server)
|
||||
@if (count($traefikAcmeBackups) > 0)
|
||||
<div class="mt-6 flex flex-col gap-2">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-neutral-950 dark:text-fg">acme.json backups</h4>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Coolify saves a copy of acme.json before it changes the file and keeps the
|
||||
last {{ \App\Actions\Proxy\ListTraefikAcmeBackups::KEEP }} copies.
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-2xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Backup</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($traefikAcmeBackups as $backup)
|
||||
<tr wire:key="traefik-acme-backup-{{ $backup['name'] }}">
|
||||
<td class="font-mono text-xs text-neutral-950 dark:text-fg">
|
||||
{{ $backup['name'] }}
|
||||
</td>
|
||||
<td>{{ $backup['created_at'] }}</td>
|
||||
<td>{{ formatBytes($backup['size']) }}</td>
|
||||
<td>
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-modal-confirmation title="Restore acme.json Backup?"
|
||||
buttonTitle="Restore"
|
||||
submitAction="restoreTraefikAcmeBackup('{{ $backup['name'] }}')"
|
||||
:checkboxes="[
|
||||
['id' => 'restartProxyAfterAcmeRestore', 'label' => 'Restart the proxy now. Sites on this server are unavailable for a few seconds.'],
|
||||
]"
|
||||
:actions="[
|
||||
'Save the current acme.json as a new backup.',
|
||||
'Replace acme.json with the backup from '.$backup['created_at'].'.',
|
||||
]"
|
||||
warningMessage="Until the proxy restarts, it keeps its loaded certificates and can write them back to acme.json. Certificates issued after this backup are removed from acme.json."
|
||||
step2ButtonText="Restore Backup"
|
||||
:confirmWithPassword="false"
|
||||
:confirmWithText="false" />
|
||||
<x-modal-confirmation title="Delete acme.json Backup?"
|
||||
buttonTitle="Delete"
|
||||
submitAction="deleteTraefikAcmeBackup('{{ $backup['name'] }}')"
|
||||
:actions="['Delete the backup '.$backup['name'].'.']"
|
||||
step2ButtonText="Delete Backup"
|
||||
isErrorButton :confirmWithPassword="false"
|
||||
:confirmWithText="false" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$proxyTitle =
|
||||
$server->proxyType() === ProxyTypes::TRAEFIK->value
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
TLS Certificates | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="certificates" />
|
||||
@if ($server->isFunctional() && $server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value)
|
||||
<div class="w-full">
|
||||
<livewire:server.proxy.certificates :server="$server" />
|
||||
</div>
|
||||
@else
|
||||
<div class="application-settings-form w-full">
|
||||
<x-application.settings-section title="TLS certificates"
|
||||
helper="Review certificates stored by Traefik for this server.">
|
||||
<x-empty size="sm" :title="$server->isFunctional() ? 'Traefik required' : 'Server validation required'"
|
||||
:description="$server->isFunctional() ? 'TLS certificate management is available for the Traefik proxy.' : 'Validate this server before viewing its TLS certificates.'"
|
||||
icon-name="servers" />
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,167 @@
|
||||
@php use App\Enums\ProxyTypes; @endphp
|
||||
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
@if ($server->hasPendingProxyConfiguration())
|
||||
<x-callout type="warning" title="Restart required">
|
||||
Restart the proxy to apply TLS certificate changes.
|
||||
</x-callout>
|
||||
@endif
|
||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||
<x-application.settings-section id="server-proxy-certificates-section" title="TLS certificates"
|
||||
x-init="$wire.loadTraefikCertificates()"
|
||||
helper="Review certificates stored in Traefik's acme.json file. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority. If a route still uses the domain, Traefik requests a new certificate after the restart.">
|
||||
<x-slot:actions>
|
||||
<x-forms.button type="button" wire:click="loadTraefikCertificates"
|
||||
wire:loading.attr="disabled" wire:target="loadTraefikCertificates">
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<div wire:loading.flex wire:target="loadTraefikCertificates"
|
||||
class="min-h-24 items-center justify-center">
|
||||
<x-loading text="Loading TLS certificates…" />
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="loadTraefikCertificates">
|
||||
@if ($traefikCertificatesLoaded && count($traefikCertificates) === 0)
|
||||
<x-empty size="sm" title="No TLS certificates found"
|
||||
description="Traefik's ACME storage does not contain certificate entries."
|
||||
icon-name="shield-star" />
|
||||
@elseif (count($traefikCertificates) > 0)
|
||||
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-3xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Resolver</th>
|
||||
<th>Alternative names</th>
|
||||
<th>Expires</th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($traefikCertificates as $certificate)
|
||||
<tr wire:key="traefik-certificate-{{ $certificate['id'] }}">
|
||||
<td class="font-medium text-neutral-950 dark:text-fg">
|
||||
{{ $certificate['main_domain'] }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono text-xs">{{ $certificate['resolver'] }}</span>
|
||||
@if ($certificate['store'])
|
||||
<span class="text-xs text-neutral-500 dark:text-fg-dim">
|
||||
({{ $certificate['store'] }})
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if (count($certificate['sans']) > 0)
|
||||
<div class="flex max-w-md flex-wrap gap-1">
|
||||
@foreach ($certificate['sans'] as $domain)
|
||||
<span
|
||||
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-700 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
{{ $domain }}
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-neutral-500 dark:text-fg-dim">None</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
{{ $certificate['expires_at'] ?? 'Unknown' }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
@can('update', $server)
|
||||
<x-modal-confirmation title="Delete TLS Certificate?"
|
||||
buttonTitle="Delete"
|
||||
submitAction="deleteTraefikCertificate({{ $certificate['id'] }})"
|
||||
:actions="[
|
||||
'Save the current acme.json as a backup that you can restore below.',
|
||||
'Delete the certificate for '.$certificate['main_domain'].' from acme.json.',
|
||||
]"
|
||||
warningMessage="The proxy keeps using the certificate until you restart it. If a route still uses the domain, Traefik requests a new certificate after the restart. You can restore the backup below to undo this."
|
||||
confirmationText="{{ $certificate['main_domain'] }}"
|
||||
confirmationLabel="Confirm by entering the domain"
|
||||
shortConfirmationLabel="Domain"
|
||||
step2ButtonText="Delete Certificate"
|
||||
isErrorButton :confirmWithPassword="false"
|
||||
:confirmWithText="true" />
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@can('manageProxy', $server)
|
||||
@if (count($traefikAcmeBackups) > 0)
|
||||
<div class="mt-6 flex flex-col gap-2">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-neutral-950 dark:text-fg">acme.json backups</h4>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Coolify saves a copy of acme.json before it changes the file and keeps the
|
||||
last {{ \App\Actions\Proxy\ListTraefikAcmeBackups::KEEP }} copies.
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-2xl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Backup</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($traefikAcmeBackups as $backup)
|
||||
<tr wire:key="traefik-acme-backup-{{ $backup['name'] }}">
|
||||
<td class="font-mono text-xs text-neutral-950 dark:text-fg">
|
||||
{{ $backup['name'] }}
|
||||
</td>
|
||||
<td>{{ $backup['created_at'] }}</td>
|
||||
<td>{{ formatBytes($backup['size']) }}</td>
|
||||
<td>
|
||||
<div class="flex justify-end gap-2">
|
||||
<x-modal-confirmation title="Restore acme.json Backup?"
|
||||
buttonTitle="Restore"
|
||||
submitAction="restoreTraefikAcmeBackup('{{ $backup['name'] }}')"
|
||||
:checkboxes="[
|
||||
['id' => 'restartProxyAfterAcmeRestore', 'label' => 'Restart the proxy now. Sites on this server are unavailable for a few seconds.'],
|
||||
]"
|
||||
:actions="[
|
||||
'Save the current acme.json as a new backup.',
|
||||
'Replace acme.json with the backup from '.$backup['created_at'].'.',
|
||||
]"
|
||||
warningMessage="Until the proxy restarts, it keeps its loaded certificates and can write them back to acme.json. Certificates issued after this backup are removed from acme.json."
|
||||
step2ButtonText="Restore Backup"
|
||||
:confirmWithPassword="false"
|
||||
:confirmWithText="false" />
|
||||
<x-modal-confirmation title="Delete acme.json Backup?"
|
||||
buttonTitle="Delete"
|
||||
submitAction="deleteTraefikAcmeBackup('{{ $backup['name'] }}')"
|
||||
:actions="['Delete the backup '.$backup['name'].'.']"
|
||||
step2ButtonText="Delete Backup"
|
||||
isErrorButton :confirmWithPassword="false"
|
||||
:confirmWithText="false" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@@ -70,6 +70,7 @@ use App\Livewire\Server\GithubRunners;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\LogDrains;
|
||||
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
|
||||
use App\Livewire\Server\Proxy\CertificatesShow as ProxyCertificatesShow;
|
||||
use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfigurations;
|
||||
use App\Livewire\Server\Proxy\Logs as ProxyLogs;
|
||||
use App\Livewire\Server\Proxy\Show as ProxyShow;
|
||||
@@ -397,6 +398,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/danger', DeleteServer::class)->name('server.delete');
|
||||
Route::get('/transfer', ServerTransfer::class)->name('server.transfer');
|
||||
Route::get('/proxy', ProxyShow::class)->name('server.proxy');
|
||||
Route::get('/proxy/certificates', ProxyCertificatesShow::class)->name('server.proxy.certificates');
|
||||
Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs');
|
||||
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
it('gates Traefik ACME certificate deletion behind update authorization', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/proxy.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Server/Proxy.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/server/proxy/certificates.blade.php'));
|
||||
$component = file_get_contents(app_path('Livewire/Server/Proxy/Certificates.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('submitAction="deleteTraefikCertificate')
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Actions\Proxy\RestoreTraefikAcmeBackup;
|
||||
use App\Actions\Proxy\SaveTraefikAcmeFile;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Livewire\Server\Proxy;
|
||||
use App\Livewire\Server\Proxy\Certificates;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -219,7 +219,7 @@ it('lets an admin see and restore acme.json backups from the proxy page', functi
|
||||
actingAsAcmeBackupUser($this->server->team, 'admin');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('loadTraefikCertificates')
|
||||
->assertSee('acme.json.backup-20260101T000000Z-00000001')
|
||||
->assertSee('2026-01-01 00:00:00 UTC')
|
||||
@@ -245,7 +245,7 @@ it('restarts the proxy after a restore when the restart option is selected', fun
|
||||
actingAsAcmeBackupUser($this->server->team, 'admin');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->assertSet('restartProxyAfterAcmeRestore', true)
|
||||
->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '', ['restartProxyAfterAcmeRestore'])
|
||||
->assertDispatched('success');
|
||||
@@ -260,7 +260,7 @@ it('does not restart the proxy after a restore when the restart option is cleare
|
||||
actingAsAcmeBackupUser($this->server->team, 'admin');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001', '')
|
||||
->assertDispatched('success');
|
||||
|
||||
@@ -271,7 +271,7 @@ it('lets an admin delete an acme.json backup from the proxy page', function () {
|
||||
actingAsAcmeBackupUser($this->server->team, 'admin');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('deleteTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001')
|
||||
->assertDispatched('success')
|
||||
->assertSet('traefikAcmeBackups', []);
|
||||
@@ -283,7 +283,7 @@ it('does not let a member list, restore, or delete acme.json backups', function
|
||||
actingAsAcmeBackupUser($this->server->team, 'member');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('loadTraefikCertificates')
|
||||
->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 2)
|
||||
->assertSet('traefikAcmeBackups', [])
|
||||
@@ -305,7 +305,7 @@ it('does not list, restore, or delete acme.json backups of another team', functi
|
||||
actingAsAcmeBackupUser(Team::factory()->create(), 'owner');
|
||||
createAcmeBackup($this->proxyDirectory, 'acme.json.backup-20260101T000000Z-00000001', '{}');
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('loadTraefikCertificates')
|
||||
->assertSet('traefikAcmeBackups', [])
|
||||
->call('restoreTraefikAcmeBackup', 'acme.json.backup-20260101T000000Z-00000001')
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Actions\Development\SeedDevelopmentTraefikCertificates;
|
||||
use App\Actions\Proxy\DeleteTraefikCertificate;
|
||||
use App\Actions\Proxy\GetTraefikCertificates;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Livewire\Server\Proxy;
|
||||
use App\Livewire\Server\Proxy\Certificates;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -99,7 +99,7 @@ it('shows the restart warning after deleting a certificate from the proxy page',
|
||||
session(['currentTeam' => $this->server->team]);
|
||||
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->assertDontSee('Restart the proxy to apply TLS certificate changes.')
|
||||
->call('deleteTraefikCertificate', $certificateId)
|
||||
->assertDispatched('refreshServerShow')
|
||||
@@ -122,7 +122,7 @@ it('lets an admin delete a certificate and records an audit event', function ()
|
||||
actingAsTraefikCertificateUser($this->server->team, 'admin');
|
||||
$certificate = GetTraefikCertificates::run($this->server)[0];
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('deleteTraefikCertificate', $certificate['id'])
|
||||
->assertDispatched('success');
|
||||
|
||||
@@ -140,7 +140,7 @@ it('lets a member list certificates but not delete them', function () {
|
||||
actingAsTraefikCertificateUser($this->server->team, 'member');
|
||||
$certificateId = GetTraefikCertificates::run($this->server)[0]['id'];
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('loadTraefikCertificates')
|
||||
->assertSet('traefikCertificates', fn (array $certificates): bool => count($certificates) === 40)
|
||||
->assertDontSeeHtml('deleteTraefikCertificate(')
|
||||
@@ -157,7 +157,7 @@ it('does not show or delete certificates of another team', function () {
|
||||
actingAsTraefikCertificateUser(Team::factory()->create(), 'owner');
|
||||
$certificateId = app(TraefikAcmeService::class)->certificates($this->acmeContents)[0]['id'];
|
||||
|
||||
Livewire::test(Proxy::class, ['server' => $this->server])
|
||||
Livewire::test(Certificates::class, ['server' => $this->server])
|
||||
->call('loadTraefikCertificates')
|
||||
->assertSet('traefikCertificates', [])
|
||||
->assertDontSee('app1.example.com')
|
||||
@@ -186,3 +186,38 @@ it('refuses to add example certificates outside development', function () {
|
||||
|
||||
expect($this->uploadedContents)->toBeNull();
|
||||
});
|
||||
|
||||
it('serves the certificate page for team users', function (string $role) {
|
||||
$this->server->team->update(['show_boarding' => false]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
actingAsTraefikCertificateUser($this->server->team, $role);
|
||||
|
||||
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
||||
->assertOk()
|
||||
->assertSeeLivewire(Certificates::class);
|
||||
})->with(['admin', 'member']);
|
||||
|
||||
it('does not serve the certificate page of another team', function () {
|
||||
actingAsTraefikCertificateUser(Team::factory()->create(['show_boarding' => false]), 'owner');
|
||||
|
||||
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('requires login to open the certificate page', function () {
|
||||
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('does not load certificate controls for a Caddy server', function () {
|
||||
actingAsTraefikCertificateUser($this->server->team, 'admin');
|
||||
$this->server->team->update(['show_boarding' => false]);
|
||||
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true]);
|
||||
$this->server->proxy = ['type' => ProxyTypes::CADDY->value, 'status' => 'running'];
|
||||
$this->server->save();
|
||||
|
||||
$this->get(route('server.proxy.certificates', ['server_uuid' => $this->server->uuid]))
|
||||
->assertOk()
|
||||
->assertDontSeeLivewire(Certificates::class)
|
||||
->assertSee('Traefik required');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user