diff --git a/app/Actions/SSL/CheckSslExpiry.php b/app/Actions/SSL/CheckSslExpiry.php new file mode 100644 index 000000000..6d65e597b --- /dev/null +++ b/app/Actions/SSL/CheckSslExpiry.php @@ -0,0 +1,94 @@ +site?->server; + + if ($server === null || $ssl->certificate_path === null) { + throw ValidationException::withMessages([ + 'ssl' => 'This certificate cannot be checked.', + ]); + } + + $ssh ??= $server->ssh(); + + $certificate = trim($ssh->exec("sudo cat {$ssl->certificate_path}")); + + if (empty($certificate) || ! str_contains($certificate, 'BEGIN CERTIFICATE')) { + throw ValidationException::withMessages([ + 'ssl' => 'Could not read the certificate from the server.', + ]); + } + + $parsed = CertificateParser::parse($certificate); + + $dirty = false; + + if (! $ssl->expires_at?->equalTo($parsed['expires_at'])) { + $ssl->expires_at = $parsed['expires_at']; + $dirty = true; + } + + if ($ssl->domains !== $parsed['domains']) { + $ssl->domains = $parsed['domains']; + $dirty = true; + } + + if ($notify) { + $dirty = $this->handleExpiryNotification($ssl) || $dirty; + } + + if ($dirty) { + $ssl->save(); + } + } + + private function handleExpiryNotification(Ssl $ssl): bool + { + if ($ssl->expires_at === null) { + return false; + } + + if ($ssl->expires_at->isAfter(now()->addDays(self::EXPIRY_WARNING_DAYS))) { + if ($ssl->expiry_notified_at !== null) { + $ssl->expiry_notified_at = null; + + return true; + } + + return false; + } + + if ($ssl->expiry_notified_at !== null) { + return false; + } + + $server = $ssl->site?->server; + + if ($server === null) { + return false; + } + + Notifier::send($server, new SslCertificateExpiring($ssl)); + $ssl->expiry_notified_at = now(); + + return true; + } +} diff --git a/app/Http/Controllers/HostedDomainController.php b/app/Http/Controllers/HostedDomainController.php index 7216c1f11..fb7bda535 100644 --- a/app/Http/Controllers/HostedDomainController.php +++ b/app/Http/Controllers/HostedDomainController.php @@ -9,6 +9,7 @@ use App\Actions\HostedDomain\ReactivateHostedDomain; use App\Actions\HostedDomain\UpdateHostedDomain; use App\Actions\SSL\AssignSslToDomains; +use App\Actions\SSL\CheckSslExpiry; use App\Actions\SSL\GetMatchingSslCertificates; use App\Actions\SSL\RenewSiteSsl; use App\Enums\HostedDomainStatus; @@ -23,6 +24,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; @@ -32,6 +34,7 @@ use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; use Spatie\RouteAttributes\Attributes\Put; +use Throwable; #[Prefix('/servers/{server}/sites/{site}/domains')] #[Middleware(['auth', 'has-project'])] @@ -147,6 +150,60 @@ public function renewSsl(Server $server, Site $site): RedirectResponse ->with('info', 'Renewing site SSL certificate.'); } + #[Post('/{hostedDomain}/check-expiry', name: 'hosted-domains.check-expiry')] + public function checkExpiry(Server $server, Site $site, HostedDomain $hostedDomain): RedirectResponse + { + $this->authorize('update', [$hostedDomain, $site, $server]); + + if ($hostedDomain->ssl === null) { + return back() + ->with('error', 'This domain does not have an SSL certificate.'); + } + + app(CheckSslExpiry::class)->check($hostedDomain->ssl, notify: false); + + return back() + ->with('success', 'SSL expiry date refreshed.'); + } + + #[Post('/check-expiry', name: 'hosted-domains.check-expiry-all')] + public function checkExpiryAll(Server $server, Site $site): RedirectResponse + { + $this->authorize('create', [HostedDomain::class, $site, $server]); + + $ssls = $site->hostedDomains() + ->whereNotNull('ssl_id') + ->with('ssl') + ->get() + ->pluck('ssl') + ->filter(fn (?Ssl $ssl): bool => $ssl !== null && $ssl->certificate_path !== null) + ->unique('id'); + + if ($ssls->isEmpty()) { + return back() + ->with('info', 'No SSL certificates to check for this site.'); + } + + $ssh = $server->ssh(); + $action = app(CheckSslExpiry::class); + $checked = 0; + + foreach ($ssls as $ssl) { + try { + $action->check($ssl, notify: false, ssh: $ssh); + $checked++; + } catch (Throwable $e) { + Log::warning('[SSL expiry check] Failed to check certificate', [ + 'ssl_id' => $ssl->id, + 'error' => $e->getMessage(), + ]); + } + } + + return back() + ->with('success', "Refreshed SSL expiry for {$checked} certificate(s)."); + } + #[Get('/matching-ssls', name: 'hosted-domains.matching-ssls')] public function matchingSsls(Request $request, Server $server, Site $site): JsonResponse { diff --git a/app/Jobs/SSL/CheckSslExpiryJob.php b/app/Jobs/SSL/CheckSslExpiryJob.php index 35e5421fc..62cfc98d1 100644 --- a/app/Jobs/SSL/CheckSslExpiryJob.php +++ b/app/Jobs/SSL/CheckSslExpiryJob.php @@ -2,13 +2,11 @@ namespace App\Jobs\SSL; -use App\Actions\SSL\CertificateParser; +use App\Actions\SSL\CheckSslExpiry; use App\Enums\SslStatus; use App\Enums\SslType; -use App\Facades\Notifier; use App\Models\Server; use App\Models\Ssl; -use App\Notifications\SslCertificateExpiring; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Log; @@ -18,8 +16,6 @@ class CheckSslExpiryJob implements ShouldQueue { use Queueable; - private const EXPIRY_WARNING_DAYS = 14; - public function __construct(protected Server $server) {} public function handle(): void @@ -38,79 +34,17 @@ public function handle(): void } $ssh = $this->server->ssh(); + $action = app(CheckSslExpiry::class); foreach ($ssls as $ssl) { - $this->checkCertificate($ssh, $ssl); - } - } - - private function checkCertificate(mixed $ssh, Ssl $ssl): void - { - try { - $certificate = trim($ssh->exec("sudo cat {$ssl->certificate_path}")); - - if (empty($certificate) || ! str_contains($certificate, 'BEGIN CERTIFICATE')) { - return; + try { + $action->check($ssl, notify: true, ssh: $ssh); + } catch (Throwable $e) { + Log::warning('[SSL expiry check] Failed to check certificate', [ + 'ssl_id' => $ssl->id, + 'error' => $e->getMessage(), + ]); } - - $parsed = CertificateParser::parse($certificate); - } catch (Throwable $e) { - Log::warning('[SSL expiry check] Failed to read certificate', [ - 'ssl_id' => $ssl->id, - 'error' => $e->getMessage(), - ]); - - return; - } - - $dirty = false; - - if (! $ssl->expires_at?->equalTo($parsed['expires_at'])) { - $ssl->expires_at = $parsed['expires_at']; - $dirty = true; - } - - if ($ssl->domains !== $parsed['domains']) { - $ssl->domains = $parsed['domains']; - $dirty = true; - } - - $dirty = $this->handleExpiryNotification($ssl) || $dirty; - - if ($dirty) { - $ssl->save(); } } - - private function handleExpiryNotification(Ssl $ssl): bool - { - if ($ssl->expires_at === null) { - return false; - } - - if ($ssl->expires_at->isAfter(now()->addDays(self::EXPIRY_WARNING_DAYS))) { - if ($ssl->expiry_notified_at !== null) { - $ssl->expiry_notified_at = null; - - return true; - } - - return false; - } - - if ($ssl->expiry_notified_at !== null) { - return false; - } - - $server = $ssl->site?->server; - - if ($server === null) { - return false; - } - - Notifier::send($server, new SslCertificateExpiring($ssl)); - $ssl->expiry_notified_at = now(); - - return true; - } } diff --git a/docs/4.x/prologue/upgrade.md b/docs/4.x/prologue/upgrade.md index 9aa9baf6a..60e400b91 100644 --- a/docs/4.x/prologue/upgrade.md +++ b/docs/4.x/prologue/upgrade.md @@ -193,3 +193,102 @@ Local installation via Laravel Sail is no longer supported in 4.x. Use the If you run Vito locally another way (Laravel Valet, etc.), make sure you have PHP 8.4 or newer installed, switch to the `4.x` branch, and review the [Breaking Changes](./breaking-changes). + +## Troubleshooting + +This section lists problems you may run into while upgrading a server from 3.x to 4.x, and how to +resolve them. + +### Expired MySQL APT signing key + +When updating packages or checking for updates on a server where Vito 3.x installed MySQL, you may +see an error like this: + +```text +W: GPG error: http://repo.mysql.com/apt/ubuntu noble InRelease: The following signatures were invalid: EXPKEYSIG B7B3B788A8D3785C MySQL Release Engineering +E: The repository 'http://repo.mysql.com/apt/ubuntu noble InRelease' is not signed. +``` + +This is caused by an **expired GPG key** for the MySQL APT repository that was imported when 3.x +installed MySQL. APT verifies a repository's release metadata against a stored signing key; once that +key passes its expiry date (`EXPKEYSIG` = *expired key signature*), APT treats the repository as +unsigned and refuses to use it, which blocks package updates. + +To fix it, run the following command in the server's terminal as the `vito` user: + +```sh +sudo gpg --no-default-keyring --keyring /usr/share/keyrings/mysql-apt-config.gpg \ + --keyserver hkps://keyserver.ubuntu.com --recv-keys B7B3B788A8D3785C +``` + +**What this does and why it works:** + +- `--no-default-keyring --keyring /usr/share/keyrings/mysql-apt-config.gpg` tells `gpg` to operate on + the dedicated MySQL keyring file rather than your personal keyring. This is the exact keyring the + MySQL APT source references with its `signed-by=` option, so it's the copy of the key that APT + actually checks signatures against. +- `--keyserver hkps://keyserver.ubuntu.com --recv-keys B7B3B788A8D3785C` downloads the key with ID + `B7B3B788A8D3785C` (the MySQL Release Engineering key named in the error) from the Ubuntu keyserver + over an encrypted (`hkps`) connection. +- MySQL renewed this key under the **same key ID** by extending its expiry date and re-publishing it. + Re-fetching it pulls the updated self-signature carrying the new expiry, overwriting the stale, + expired copy in the keyring. With a valid, non-expired key in place, APT can verify the repository's + signature again and `apt update` succeeds. + +:::info +After importing the key, re-run the package update (or retry the operation in Vito) to confirm the +error is gone. +::: + +### "Updates available" count never clears + +You may see the *"X updates available"* warning and find that, no matter how many times you run the +update, the count never changes — it always reports the same number of updates remaining (for example, +3). + +This can be caused by an incorrectly configured MySQL install. Check the upgrade log for a message +like this: + +```text +The following packages have been kept back: + libgd3 mysql-common mysql-server + +0 upgraded, 0 newly installed, 0 to remove and 3 not upgraded. +``` + +"Kept back" means APT is intentionally holding those packages instead of upgrading them, so Vito's +automatic update never applies them and the count stays stuck. + +If the held-back packages are MySQL needing an upgrade to the expected version, back up your databases +first, then perform the upgrade manually in the server's terminal. For example: + +```sh +sudo apt-get install mysql-server mysql-common libgd3 +``` + +:::warning +Take fresh backups **before** upgrading MySQL manually. Your 3.x database backups will **not** restore +on 4.x, so old backups are not a valid safety net here — see +[Breaking Changes › Database Backup Format](./breaking-changes#database-backup-format). +::: + +If the manual upgrade causes any issues with the database, you can reinstall the associated SQL +instance from the server's **Services** page and then restore your databases from your fresh backups. + +### Sites report "SSL certificate expiring in 0 days" + +After upgrading, you may see a warning on all of your sites along the lines of *"X SSL certificate +expiring in 0 days"*, even though the certificates are valid. + +This happens because Vito 3.x stored each certificate's expiry date when it was issued but never kept +it up to date, so after renewals the stored date became stale. Vito 4.x maintains these dates for you, +but it does so via an **overnight script**. The expiry dates will therefore refresh and the warnings +clear automatically the next time that script runs. + +If you would rather clear the warning straight away, you can refresh the dates on demand per site: + +1. Open the site and go to the **Domains** menu. +2. Click the **lock icon** at the top of the Domains menu. +3. Click **Check SSL expiry (all)**. + +Vito will re-read the certificates' real expiry dates and update the warnings immediately. diff --git a/resources/js/pages/hosted-domains/index.tsx b/resources/js/pages/hosted-domains/index.tsx index 7f264a9e7..d304e49c8 100644 --- a/resources/js/pages/hosted-domains/index.tsx +++ b/resources/js/pages/hosted-domains/index.tsx @@ -11,6 +11,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { BookOpenIcon, + CalendarClockIcon, EllipsisVerticalIcon, LoaderCircleIcon, LockIcon, @@ -172,6 +173,13 @@ export default function HostedDomains() { Force Renew SSL )} + + router.post(route('hosted-domains.check-expiry-all', { server: page.props.server.id, site: page.props.site.id }))} + > + + Check SSL Expiry (all) +