feat(tls): dashboard "request certificate" button (trigger Caddy on-demand TLS)
For the common case where a domain was configured before its DNS pointed here, the operator can now request the Let's Encrypt cert from System -> Domain & TLS instead of waiting for the first HTTPS visitor: - DeploymentService::requestCertificate() does a DNS pre-check (domainResolvesHere via dns_get_record vs serverPublicIp) and, only when DNS is NOT a clear mismatch, triggers Caddy's on-demand issuance with an internal handshake (probeCertificate: `curl --connect-to <domain>:443:caddy:443` — the dial target is pinned to the caddy service, the domain only sets SNI/Host, so no SSRF). A clear mismatch returns early and never calls ACME (Let's Encrypt rate-limit protection). The outcome is persisted to the `tls_cert_status` Setting so the page shows a status without an ACME-triggering probe on every load. - System\Index::requestCertificate() is per-user throttled (cert-request:<id>, 5/10min, auto-expiring -> never a control-plane lockout) and audited (tls.cert_request). The status line + button render only in caddy mode with an active domain; external mode shows a note; bare IP hides it. Automatic on-demand still works on the first HTTPS handshake regardless — this only adds explicit control + a status. Tests cover the no-handshake-on-mismatch guard, the issued/failed/not-applicable states, and the per-user throttle. Full suite 192 green; Codex clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
4f1ea8ef0f
commit
1fb6b34fd1
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -11,7 +11,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
|
||||
### Hinzugefügt
|
||||
- **„DNS prüfen & Zertifikat anfordern" im Dashboard (System → Domain & TLS).** Wurde eine Domain
|
||||
konfiguriert, deren DNS noch nicht auf den Server zeigte, lässt sich das Let's-Encrypt-Zertifikat
|
||||
jetzt per Knopf anstoßen, sobald das DNS stimmt: eine DNS-Vorabprüfung (zeigt die Domain hierher?)
|
||||
und — nur ohne klare Fehlanpassung — ein interner Handshake, der Caddys On-Demand-Ausstellung
|
||||
auslöst, plus ein persistierter Status (aktiv / DNS zeigt woanders / ausstehend). Sichtbar nur im
|
||||
eingebauten-TLS-Modus mit aktiver Domain (im Externer-Proxy-Modus ein Hinweis, bei Bare-IP nichts).
|
||||
Per-Nutzer gedrosselt (5/10 Min, auto-ablaufend — sperrt die Control-Plane nie aus) + auditiert; der
|
||||
Handshake ist fest auf den `caddy`-Dienst gepinnt (kein SSRF). Hinweis: das automatische On-Demand
|
||||
greift weiterhin von selbst beim ersten HTTPS-Aufruf — der Knopf gibt nur explizite Kontrolle +
|
||||
Statusrückmeldung.
|
||||
|
||||
## [0.9.3] - 2026-06-17
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Services\DeploymentService;
|
|||
use App\Support\Confirm\ConfirmToken;
|
||||
use App\Support\Confirm\InvalidConfirmToken;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -149,6 +150,52 @@ class Index extends Component
|
|||
$this->dispatch('notify', message: __('system.restart_running'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-triggered Let's-Encrypt request for the ACTIVE, Caddy-served domain: a DNS
|
||||
* pre-check + an on-demand-TLS handshake (DeploymentService::requestCertificate). Per-user
|
||||
* throttled (5 / 10 min, auto-expiring — never a control-plane lockout) so it can't hammer
|
||||
* ACME, and audited. Only meaningful in caddy mode with an active domain.
|
||||
*/
|
||||
public function requestCertificate(DeploymentService $deployment): void
|
||||
{
|
||||
$domain = $deployment->domain();
|
||||
|
||||
if ($domain === null || $deployment->externalTls()) {
|
||||
$this->dispatch('notify', message: __('system.cert_not_applicable'), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$key = 'cert-request:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
$this->dispatch('notify', message: __('system.cert_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
$result = $deployment->requestCertificate($domain);
|
||||
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'tls.cert_request',
|
||||
'target' => $domain.' → '.$result['status'],
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
[$message, $level] = match ($result['status']) {
|
||||
'issued' => [__('system.cert_issued', ['domain' => $domain]), 'success'],
|
||||
'dns_mismatch' => [__('system.cert_dns_mismatch', [
|
||||
'resolved' => implode(', ', $result['resolved'] ?? []) ?: '—',
|
||||
'server' => $result['serverIp'] ?? '?',
|
||||
]), 'error'],
|
||||
default => [__('system.cert_failed'), 'error'],
|
||||
};
|
||||
|
||||
$this->dispatch('notify', message: $message, level: $level);
|
||||
}
|
||||
|
||||
/** Release channel changes are audited (confirmation via the shared modal). */
|
||||
public function confirmChannel(string $channel): void
|
||||
{
|
||||
|
|
@ -255,6 +302,9 @@ class Index extends Component
|
|||
'panelUrl' => $deployment->panelUrl(),
|
||||
'isOverridden' => $deployment->domainIsOverridden(),
|
||||
'channels' => $this->channelDescriptions(),
|
||||
// Cert request is meaningful only when Caddy serves TLS for an ACTIVE domain.
|
||||
'showCert' => $this->tlsMode === 'caddy' && $this->domain !== '',
|
||||
'certStatus' => $deployment->certStatus(),
|
||||
])->title(__('system.title'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
|
|
@ -276,4 +278,159 @@ class DeploymentService
|
|||
'scheme' => $opt['scheme'] ?? 'http',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own public IP, used only to pre-check that a domain's DNS points here
|
||||
* before triggering ACME. Best-effort (cached ~5 min on success); null when it can't be
|
||||
* determined — the caller then treats the DNS comparison as "unknown" and proceeds, since
|
||||
* the actual issuance handshake is the real source of truth.
|
||||
*/
|
||||
public function serverPublicIp(): ?string
|
||||
{
|
||||
$cached = Cache::get('clusev:public-ip');
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Process::timeout(5)->run(['curl', '-fsS', '--max-time', '3', 'https://api.ipify.org']);
|
||||
$ip = trim($res->output());
|
||||
} catch (Throwable) {
|
||||
$ip = '';
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
Cache::put('clusev:public-ip', $ip, now()->addMinutes(5));
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the domain's DNS (A/AAAA) currently resolves to THIS server.
|
||||
*
|
||||
* @return array{ok: ?bool, resolved: string[], serverIp: ?string}
|
||||
* ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable)
|
||||
*/
|
||||
public function domainResolvesHere(string $domain): array
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
$serverIp = $this->serverPublicIp();
|
||||
|
||||
$resolved = [];
|
||||
try {
|
||||
foreach ([DNS_A, DNS_AAAA] as $type) {
|
||||
foreach (@dns_get_record($domain, $type) ?: [] as $rec) {
|
||||
$ip = $rec['ip'] ?? $rec['ipv6'] ?? null;
|
||||
if (is_string($ip) && $ip !== '') {
|
||||
$resolved[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// resolution failed — treat as no records
|
||||
}
|
||||
$resolved = array_values(array_unique($resolved));
|
||||
|
||||
$ok = $serverIp === null ? null : in_array($serverIp, $resolved, true);
|
||||
|
||||
return ['ok' => $ok, 'resolved' => $resolved, 'serverIp' => $serverIp];
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Caddy's on-demand TLS for the domain by doing an internal HTTPS handshake to the
|
||||
* `caddy` service with SNI = the domain (`--connect-to` pins the dial to caddy regardless of
|
||||
* the domain, so there is no SSRF — the connection target is fixed, the domain only sets
|
||||
* SNI/Host). Returns true only when curl completes WITH certificate verification (exit 0),
|
||||
* i.e. Caddy is now serving a trusted (Let's Encrypt) cert for the domain. Never throws.
|
||||
*/
|
||||
public function probeCertificate(string $domain): bool
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
if ($domain === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Process::timeout(25)->run([
|
||||
'curl', '-sS', '-o', '/dev/null', '-w', '%{http_code}',
|
||||
'--connect-to', "{$domain}:443:caddy:443",
|
||||
'--max-time', '20',
|
||||
"https://{$domain}/",
|
||||
]);
|
||||
|
||||
// Exit 0 means the TLS handshake AND certificate verification succeeded (curl exits
|
||||
// 60 on an untrusted/self-signed cert) — so a trusted cert is being served.
|
||||
return $res->successful();
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-triggered certificate request: DNS pre-check, then (only if DNS is not a clear
|
||||
* mismatch) trigger on-demand issuance and verify. Persists the outcome to a Setting so the
|
||||
* dashboard can show a status without a live (ACME-triggering) probe on every page load.
|
||||
*
|
||||
* @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string}
|
||||
* status: issued | dns_mismatch | failed | not_applicable
|
||||
*/
|
||||
public function requestCertificate(string $domain): array
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
|
||||
if ($domain === '' || $this->externalTls()) {
|
||||
return $this->persistCertStatus('not_applicable');
|
||||
}
|
||||
|
||||
$dns = $this->domainResolvesHere($domain);
|
||||
if ($dns['ok'] === false) {
|
||||
// Don't trigger ACME against a wrong host — protects the Let's Encrypt failed-
|
||||
// validation rate limit.
|
||||
return $this->persistCertStatus('dns_mismatch', [
|
||||
'resolved' => $dns['resolved'],
|
||||
'serverIp' => $dns['serverIp'],
|
||||
]);
|
||||
}
|
||||
|
||||
$issued = $this->probeCertificate($domain);
|
||||
|
||||
return $this->persistCertStatus($issued ? 'issued' : 'failed');
|
||||
}
|
||||
|
||||
/** Last persisted certificate-request outcome, or null when never checked. */
|
||||
public function certStatus(): ?array
|
||||
{
|
||||
try {
|
||||
$raw = Setting::get('tls_cert_status');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/** @return array{status: string, checkedAt: string} */
|
||||
private function persistCertStatus(string $status, array $extra = []): array
|
||||
{
|
||||
$payload = array_merge(['status' => $status, 'checkedAt' => now()->toIso8601String()], $extra);
|
||||
|
||||
try {
|
||||
Setting::put('tls_cert_status', json_encode($payload));
|
||||
} catch (Throwable $e) {
|
||||
// Status display is best-effort (the notify still reports the live result), but
|
||||
// surface the failure to logs so a broken settings store doesn't fail silently.
|
||||
report($e);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ return [
|
|||
'change_tls_confirm' => 'Umstellen',
|
||||
'tls_saved_notify' => 'TLS-Modus gespeichert — Stack neu starten, um zu übernehmen.',
|
||||
|
||||
// TLS-Zertifikat (On-Demand-Ausstellung manuell anstoßen)
|
||||
'cert_title' => 'TLS-Zertifikat',
|
||||
'cert_button' => 'DNS prüfen & Zertifikat anfordern',
|
||||
'cert_status_issued' => 'Zertifikat aktiv — gültig und vertrauenswürdig.',
|
||||
'cert_status_dns_mismatch' => 'DNS zeigt (noch) nicht auf diesen Server — Zertifikat ausstehend.',
|
||||
'cert_status_failed' => 'Ausstellung fehlgeschlagen — sind Port 80 und 443 aus dem Internet erreichbar?',
|
||||
'cert_status_unknown' => 'Noch nicht geprüft. Sobald die Domain per DNS hierher zeigt, „anfordern" klicken.',
|
||||
'cert_external_note' => 'TLS läuft über den externen Reverse-Proxy — das Panel stellt kein Zertifikat aus.',
|
||||
'cert_issued' => 'Zertifikat für :domain ausgestellt.',
|
||||
'cert_dns_mismatch' => 'DNS zeigt auf :resolved statt auf diesen Server (:server) — kein Zertifikat angefordert.',
|
||||
'cert_failed' => 'Zertifikat konnte nicht ausgestellt werden — Ports 80/443 aus dem Internet erreichbar? Bitte später erneut.',
|
||||
'cert_not_applicable' => 'Zertifikatsanforderung hier nicht möglich (Bare-IP oder externer Proxy).',
|
||||
'cert_throttled' => 'Zu viele Anforderungen — bitte in :seconds Sekunden erneut.',
|
||||
|
||||
// Page title
|
||||
'title' => 'System — Clusev',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ return [
|
|||
'change_tls_confirm' => 'Switch',
|
||||
'tls_saved_notify' => 'TLS mode saved — restart the stack to apply.',
|
||||
|
||||
// TLS certificate (manually trigger on-demand issuance)
|
||||
'cert_title' => 'TLS certificate',
|
||||
'cert_button' => 'Check DNS & request certificate',
|
||||
'cert_status_issued' => 'Certificate active — valid and trusted.',
|
||||
'cert_status_dns_mismatch' => 'DNS does not point here (yet) — certificate pending.',
|
||||
'cert_status_failed' => 'Issuance failed — are ports 80 and 443 reachable from the internet?',
|
||||
'cert_status_unknown' => 'Not checked yet. Once the domain points here via DNS, click “request”.',
|
||||
'cert_external_note' => 'TLS is handled by the external reverse proxy — the panel issues no certificate.',
|
||||
'cert_issued' => 'Certificate issued for :domain.',
|
||||
'cert_dns_mismatch' => 'DNS points to :resolved instead of this server (:server) — no certificate requested.',
|
||||
'cert_failed' => 'Could not issue the certificate — are ports 80/443 reachable from the internet? Try again later.',
|
||||
'cert_not_applicable' => 'Certificate request not available here (bare IP or external proxy).',
|
||||
'cert_throttled' => 'Too many requests — try again in :seconds seconds.',
|
||||
|
||||
// Page title
|
||||
'title' => 'System — Clusev',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -190,6 +190,41 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- TLS certificate: explicit DNS check + on-demand issuance trigger + status.
|
||||
Only in Caddy mode with an active domain; the external proxy owns TLS otherwise. --}}
|
||||
@if ($showCert)
|
||||
@php $certState = $certStatus['status'] ?? null; @endphp
|
||||
<div class="rounded-md border border-line bg-inset/60 p-3">
|
||||
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] leading-relaxed">
|
||||
@switch($certState)
|
||||
@case('issued')
|
||||
<span class="text-online">{{ __('system.cert_status_issued') }}</span>
|
||||
@break
|
||||
@case('dns_mismatch')
|
||||
<span class="text-warning">{{ __('system.cert_status_dns_mismatch') }}</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="text-offline">{{ __('system.cert_status_failed') }}</span>
|
||||
@break
|
||||
@default
|
||||
<span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span>
|
||||
@endswitch
|
||||
</p>
|
||||
<x-btn variant="secondary" size="sm" class="mt-2.5"
|
||||
wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" />
|
||||
<svg wire:loading wire:target="requestCertificate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
{{ __('system.cert_button') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
@elseif ($tlsMode === 'external')
|
||||
<div class="rounded-md border border-line bg-inset/60 p-3">
|
||||
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">{{ __('system.cert_title') }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.cert_external_note') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Last-resort recovery: SSH into the host and reset admin access. --}}
|
||||
<div class="flex items-start gap-2.5 border-t border-line pt-4 text-ink-4">
|
||||
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\System\Index as SystemIndex;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The dashboard "request certificate" flow: a DNS pre-check that must NOT trigger ACME on a
|
||||
* mismatch (Let's-Encrypt rate-limit protection), and a per-user throttle on the action.
|
||||
*/
|
||||
class TlsCertificateRequestTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_dns_mismatch_does_not_trigger_a_handshake(): void
|
||||
{
|
||||
$svc = Mockery::mock(DeploymentService::class)->makePartial();
|
||||
$svc->shouldReceive('domainResolvesHere')->once()->with('example.com')
|
||||
->andReturn(['ok' => false, 'resolved' => ['203.0.113.9'], 'serverIp' => '198.51.100.1']);
|
||||
$svc->shouldReceive('probeCertificate')->never(); // never trigger ACME against a wrong host
|
||||
|
||||
$result = $svc->requestCertificate('example.com');
|
||||
|
||||
$this->assertSame('dns_mismatch', $result['status']);
|
||||
$this->assertSame('dns_mismatch', $svc->certStatus()['status']);
|
||||
}
|
||||
|
||||
public function test_dns_ok_and_handshake_success_is_issued(): void
|
||||
{
|
||||
$svc = Mockery::mock(DeploymentService::class)->makePartial();
|
||||
$svc->shouldReceive('domainResolvesHere')->once()
|
||||
->andReturn(['ok' => true, 'resolved' => ['198.51.100.1'], 'serverIp' => '198.51.100.1']);
|
||||
$svc->shouldReceive('probeCertificate')->once()->with('example.com')->andReturnTrue();
|
||||
|
||||
$this->assertSame('issued', $svc->requestCertificate('example.com')['status']);
|
||||
}
|
||||
|
||||
public function test_dns_ok_but_handshake_failure_is_failed(): void
|
||||
{
|
||||
$svc = Mockery::mock(DeploymentService::class)->makePartial();
|
||||
$svc->shouldReceive('domainResolvesHere')->once()->andReturn(['ok' => true, 'resolved' => [], 'serverIp' => null]);
|
||||
$svc->shouldReceive('probeCertificate')->once()->andReturnFalse();
|
||||
|
||||
$this->assertSame('failed', $svc->requestCertificate('example.com')['status']);
|
||||
}
|
||||
|
||||
public function test_external_tls_mode_is_not_applicable_and_never_probes(): void
|
||||
{
|
||||
Setting::put('tls_mode', 'external');
|
||||
|
||||
$svc = Mockery::mock(DeploymentService::class)->makePartial();
|
||||
$svc->shouldReceive('domainResolvesHere')->never();
|
||||
$svc->shouldReceive('probeCertificate')->never();
|
||||
|
||||
$this->assertSame('not_applicable', $svc->requestCertificate('example.com')['status']);
|
||||
}
|
||||
|
||||
public function test_request_action_is_throttled_per_user(): void
|
||||
{
|
||||
$this->mock(DeploymentService::class, function ($m) {
|
||||
$m->makePartial();
|
||||
// Stub the serving state directly (domain() otherwise reads a shared snapshot file).
|
||||
$m->shouldReceive('domain')->andReturn('example.com');
|
||||
$m->shouldReceive('externalTls')->andReturn(false);
|
||||
// The network-touching request is stubbed; the throttle must cap it at 5 calls.
|
||||
$m->shouldReceive('requestCertificate')->times(5)
|
||||
->andReturn(['status' => 'issued', 'checkedAt' => now()->toIso8601String()]);
|
||||
});
|
||||
|
||||
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
Livewire::test(SystemIndex::class)->call('requestCertificate');
|
||||
}
|
||||
|
||||
// 6th call was throttled before reaching the service; 5 audited requests.
|
||||
$this->assertSame(5, AuditEvent::where('action', 'tls.cert_request')->count());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue