Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/Actions/SSL/CheckSslExpiry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace App\Actions\SSL;

use App\Facades\Notifier;
use App\Models\Ssl;
use App\Notifications\SslCertificateExpiring;
use Illuminate\Validation\ValidationException;

class CheckSslExpiry
{
private const EXPIRY_WARNING_DAYS = 14;

/**
* Read the certificate from the server, refresh the stored expiry date and
* domains, and optionally send the expiry notification.
*
* @throws ValidationException
*/
public function check(Ssl $ssl, bool $notify = true, mixed $ssh = null): void
{
$server = $ssl->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;
}
}
57 changes: 57 additions & 0 deletions app/Http/Controllers/HostedDomainController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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'])]
Expand Down Expand Up @@ -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
{
Expand Down
84 changes: 9 additions & 75 deletions app/Jobs/SSL/CheckSslExpiryJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
}
}
99 changes: 99 additions & 0 deletions docs/4.x/prologue/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mysql-build@oss.oracle.com>
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.
Loading
Loading