Skip to content

Commit 1afee16

Browse files
[Fix] Add SSL expiry refresh actions and upgrade troubleshooting docs (#1184)
* wip * fixes * add with failure * code rabbit fix
1 parent 6e0a1bd commit 1afee16

9 files changed

Lines changed: 471 additions & 75 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace App\Actions\SSL;
4+
5+
use App\Models\Site;
6+
use App\Models\Ssl;
7+
use Illuminate\Support\Facades\Log;
8+
use Throwable;
9+
10+
class CheckSiteSslsExpiry
11+
{
12+
/**
13+
* Refresh the stored expiry date for every checkable SSL attached to the
14+
* site's hosted domains.
15+
*
16+
* @return array{checked: int, failed: int}
17+
*/
18+
public function handle(Site $site): array
19+
{
20+
$ssls = $site->hostedDomains()
21+
->whereNotNull('ssl_id')
22+
->with('ssl')
23+
->get()
24+
->pluck('ssl')
25+
->filter(fn (?Ssl $ssl): bool => $ssl !== null && $ssl->certificate_path !== null)
26+
->unique('id');
27+
28+
if ($ssls->isEmpty()) {
29+
return ['checked' => 0, 'failed' => 0];
30+
}
31+
32+
$ssh = $site->server->ssh();
33+
$action = app(CheckSslExpiry::class);
34+
$checked = 0;
35+
$failed = 0;
36+
37+
foreach ($ssls as $ssl) {
38+
try {
39+
$action->check($ssl, notify: false, ssh: $ssh);
40+
$checked++;
41+
} catch (Throwable $e) {
42+
$failed++;
43+
Log::warning('[SSL expiry check] Failed to check certificate', [
44+
'ssl_id' => $ssl->id,
45+
'error' => $e->getMessage(),
46+
]);
47+
}
48+
}
49+
50+
return ['checked' => $checked, 'failed' => $failed];
51+
}
52+
}

app/Actions/SSL/CheckSslExpiry.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Actions\SSL;
4+
5+
use App\Facades\Notifier;
6+
use App\Models\Ssl;
7+
use App\Notifications\SslCertificateExpiring;
8+
use Illuminate\Validation\ValidationException;
9+
10+
class CheckSslExpiry
11+
{
12+
private const EXPIRY_WARNING_DAYS = 14;
13+
14+
/**
15+
* Read the certificate from the server, refresh the stored expiry date and
16+
* domains, and optionally send the expiry notification.
17+
*
18+
* @throws ValidationException
19+
*/
20+
public function check(Ssl $ssl, bool $notify = true, mixed $ssh = null): void
21+
{
22+
$server = $ssl->server ?? $ssl->site?->server;
23+
24+
if ($server === null || $ssl->certificate_path === null) {
25+
throw ValidationException::withMessages([
26+
'ssl' => 'This certificate cannot be checked.',
27+
]);
28+
}
29+
30+
$ssh ??= $server->ssh();
31+
32+
$certificate = trim($ssh->exec('sudo cat '.escapeshellarg($ssl->certificate_path)));
33+
34+
if (empty($certificate) || ! str_contains($certificate, 'BEGIN CERTIFICATE')) {
35+
throw ValidationException::withMessages([
36+
'ssl' => 'Could not read the certificate from the server.',
37+
]);
38+
}
39+
40+
$parsed = CertificateParser::parse($certificate);
41+
42+
$dirty = false;
43+
44+
if (! $ssl->expires_at?->equalTo($parsed['expires_at'])) {
45+
$ssl->expires_at = $parsed['expires_at'];
46+
$dirty = true;
47+
}
48+
49+
if ($ssl->domains !== $parsed['domains']) {
50+
$ssl->domains = $parsed['domains'];
51+
$dirty = true;
52+
}
53+
54+
if ($notify) {
55+
$dirty = $this->handleExpiryNotification($ssl) || $dirty;
56+
}
57+
58+
if ($dirty) {
59+
$ssl->save();
60+
}
61+
}
62+
63+
private function handleExpiryNotification(Ssl $ssl): bool
64+
{
65+
if ($ssl->expires_at === null) {
66+
return false;
67+
}
68+
69+
if ($ssl->expires_at->isAfter(now()->addDays(self::EXPIRY_WARNING_DAYS))) {
70+
if ($ssl->expiry_notified_at !== null) {
71+
$ssl->expiry_notified_at = null;
72+
73+
return true;
74+
}
75+
76+
return false;
77+
}
78+
79+
if ($ssl->expiry_notified_at !== null) {
80+
return false;
81+
}
82+
83+
$server = $ssl->server ?? $ssl->site?->server;
84+
85+
if ($server === null) {
86+
return false;
87+
}
88+
89+
Notifier::send($server, new SslCertificateExpiring($ssl));
90+
$ssl->expiry_notified_at = now();
91+
92+
return true;
93+
}
94+
}

app/Http/Controllers/HostedDomainController.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use App\Actions\HostedDomain\ReactivateHostedDomain;
1010
use App\Actions\HostedDomain\UpdateHostedDomain;
1111
use App\Actions\SSL\AssignSslToDomains;
12+
use App\Actions\SSL\CheckSiteSslsExpiry;
13+
use App\Actions\SSL\CheckSslExpiry;
1214
use App\Actions\SSL\GetMatchingSslCertificates;
1315
use App\Actions\SSL\RenewSiteSsl;
1416
use App\Enums\HostedDomainStatus;
@@ -147,6 +149,53 @@ public function renewSsl(Server $server, Site $site): RedirectResponse
147149
->with('info', 'Renewing site SSL certificate.');
148150
}
149151

152+
#[Post('/{hostedDomain}/check-expiry', name: 'hosted-domains.check-expiry')]
153+
public function checkExpiry(Server $server, Site $site, HostedDomain $hostedDomain): RedirectResponse
154+
{
155+
$this->authorize('update', [$hostedDomain, $site, $server]);
156+
157+
if ($hostedDomain->ssl === null) {
158+
return back()
159+
->with('error', 'This domain does not have an SSL certificate.');
160+
}
161+
162+
try {
163+
app(CheckSslExpiry::class)->check($hostedDomain->ssl, notify: false);
164+
} catch (ValidationException $e) {
165+
return back()
166+
->with('error', $e->getMessage());
167+
}
168+
169+
return back()
170+
->with('success', 'SSL expiry date refreshed.');
171+
}
172+
173+
#[Post('/check-expiry', name: 'hosted-domains.check-expiry-all')]
174+
public function checkExpiryAll(Server $server, Site $site): RedirectResponse
175+
{
176+
$this->authorize('create', [HostedDomain::class, $site, $server]);
177+
178+
['checked' => $checked, 'failed' => $failed] = app(CheckSiteSslsExpiry::class)->handle($site);
179+
180+
if ($checked === 0 && $failed === 0) {
181+
return back()
182+
->with('info', 'No SSL certificates to check for this site.');
183+
}
184+
185+
if ($checked === 0) {
186+
return back()
187+
->with('error', 'Failed to refresh SSL expiry for all certificates.');
188+
}
189+
190+
if ($failed > 0) {
191+
return back()
192+
->with('warning', "Refreshed SSL expiry for {$checked} certificate(s); {$failed} failed.");
193+
}
194+
195+
return back()
196+
->with('success', "Refreshed SSL expiry for {$checked} certificate(s).");
197+
}
198+
150199
#[Get('/matching-ssls', name: 'hosted-domains.matching-ssls')]
151200
public function matchingSsls(Request $request, Server $server, Site $site): JsonResponse
152201
{

app/Jobs/SSL/CheckSslExpiryJob.php

Lines changed: 9 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22

33
namespace App\Jobs\SSL;
44

5-
use App\Actions\SSL\CertificateParser;
5+
use App\Actions\SSL\CheckSslExpiry;
66
use App\Enums\SslStatus;
77
use App\Enums\SslType;
8-
use App\Facades\Notifier;
98
use App\Models\Server;
109
use App\Models\Ssl;
11-
use App\Notifications\SslCertificateExpiring;
1210
use Illuminate\Contracts\Queue\ShouldQueue;
1311
use Illuminate\Foundation\Queue\Queueable;
1412
use Illuminate\Support\Facades\Log;
@@ -18,8 +16,6 @@ class CheckSslExpiryJob implements ShouldQueue
1816
{
1917
use Queueable;
2018

21-
private const EXPIRY_WARNING_DAYS = 14;
22-
2319
public function __construct(protected Server $server) {}
2420

2521
public function handle(): void
@@ -38,79 +34,17 @@ public function handle(): void
3834
}
3935

4036
$ssh = $this->server->ssh();
37+
$action = app(CheckSslExpiry::class);
4138

4239
foreach ($ssls as $ssl) {
43-
$this->checkCertificate($ssh, $ssl);
44-
}
45-
}
46-
47-
private function checkCertificate(mixed $ssh, Ssl $ssl): void
48-
{
49-
try {
50-
$certificate = trim($ssh->exec("sudo cat {$ssl->certificate_path}"));
51-
52-
if (empty($certificate) || ! str_contains($certificate, 'BEGIN CERTIFICATE')) {
53-
return;
40+
try {
41+
$action->check($ssl, notify: true, ssh: $ssh);
42+
} catch (Throwable $e) {
43+
Log::warning('[SSL expiry check] Failed to check certificate', [
44+
'ssl_id' => $ssl->id,
45+
'error' => $e->getMessage(),
46+
]);
5447
}
55-
56-
$parsed = CertificateParser::parse($certificate);
57-
} catch (Throwable $e) {
58-
Log::warning('[SSL expiry check] Failed to read certificate', [
59-
'ssl_id' => $ssl->id,
60-
'error' => $e->getMessage(),
61-
]);
62-
63-
return;
64-
}
65-
66-
$dirty = false;
67-
68-
if (! $ssl->expires_at?->equalTo($parsed['expires_at'])) {
69-
$ssl->expires_at = $parsed['expires_at'];
70-
$dirty = true;
71-
}
72-
73-
if ($ssl->domains !== $parsed['domains']) {
74-
$ssl->domains = $parsed['domains'];
75-
$dirty = true;
76-
}
77-
78-
$dirty = $this->handleExpiryNotification($ssl) || $dirty;
79-
80-
if ($dirty) {
81-
$ssl->save();
8248
}
8349
}
84-
85-
private function handleExpiryNotification(Ssl $ssl): bool
86-
{
87-
if ($ssl->expires_at === null) {
88-
return false;
89-
}
90-
91-
if ($ssl->expires_at->isAfter(now()->addDays(self::EXPIRY_WARNING_DAYS))) {
92-
if ($ssl->expiry_notified_at !== null) {
93-
$ssl->expiry_notified_at = null;
94-
95-
return true;
96-
}
97-
98-
return false;
99-
}
100-
101-
if ($ssl->expiry_notified_at !== null) {
102-
return false;
103-
}
104-
105-
$server = $ssl->site?->server;
106-
107-
if ($server === null) {
108-
return false;
109-
}
110-
111-
Notifier::send($server, new SslCertificateExpiring($ssl));
112-
$ssl->expiry_notified_at = now();
113-
114-
return true;
115-
}
11650
}

app/Tables/HostedDomainTable.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ protected function columns(): array
5353
Column::data('type', fn (HostedDomain $hd) => $hd->type->getText()),
5454
Column::data('type_color', fn (HostedDomain $hd) => $hd->type->getColor()),
5555
Column::data('ssl_id'),
56+
Column::data('ssl_can_check_expiry', fn (HostedDomain $hd) => $hd->ssl !== null && $hd->ssl->certificate_path !== null),
5657
Column::data('error'),
5758
Column::data('ssl', fn (HostedDomain $hd) => $hd->ssl ? SslResource::make($hd->ssl) : null),
5859
ActionsColumn::make(),

0 commit comments

Comments
 (0)