141 lines
5.4 KiB
PHP
141 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Daily TLS certificate expiry monitor.
|
|
*
|
|
* Inspects the Let's Encrypt certificates under the configured live path and
|
|
* emails a two-tier warning (NOTICE then URGENT) as expiry approaches, well
|
|
* ahead of the renewal window, so a silent renewal failure is surfaced before
|
|
* the certificate actually lapses.
|
|
*
|
|
* The alert is sent synchronously via Mail::raw() (never queued) so it does not
|
|
* depend on a running queue worker, and every warning line is also written to
|
|
* the Laravel log as a fallback. Scheduled in app/Console/Kernel.php; an
|
|
* optional healthchecks.io dead-man's-switch is wired via the scheduler ping.
|
|
*/
|
|
class CheckCertExpiry extends Command
|
|
{
|
|
protected $signature = 'cert:check
|
|
{--dry-run : Print findings without sending email}
|
|
{--notice-days= : Override the NOTICE threshold (for testing)}
|
|
{--urgent-days= : Override the URGENT threshold (for testing)}';
|
|
protected $description = 'Check Let\'s Encrypt certificate expiry and email a two-tier warning as it approaches.';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! timebank_config('cert_monitor.enabled', true)) {
|
|
$this->info('Certificate monitor is disabled (cert_monitor.enabled=false).');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$livePath = timebank_config('cert_monitor.live_path', '/etc/letsencrypt/live');
|
|
$noticeDays = (int) ($this->option('notice-days') ?? timebank_config('cert_monitor.notice_days', 20));
|
|
$urgentDays = (int) ($this->option('urgent-days') ?? timebank_config('cert_monitor.urgent_days', 10));
|
|
$email = timebank_config('cert_monitor.alert_email') ?: timebank_config('mail.system_admin.email');
|
|
|
|
$certs = glob(rtrim($livePath, '/') . '/*/cert.pem');
|
|
if (empty($certs)) {
|
|
$this->warn("No certificates found under {$livePath}.");
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$warnings = [];
|
|
$urgent = false;
|
|
|
|
foreach ($certs as $cert) {
|
|
$domain = basename(dirname($cert));
|
|
$expiry = $this->certExpiry($cert);
|
|
|
|
if ($expiry === null) {
|
|
$this->warn("Could not read expiry for {$domain} ({$cert}).");
|
|
continue;
|
|
}
|
|
|
|
$days = (int) floor((($expiry->getTimestamp()) - time()) / 86400);
|
|
|
|
if ($days < $urgentDays) {
|
|
$warnings[] = sprintf('URGENT: %s expires in %d days (%s)', $domain, $days, $expiry->format('Y-m-d H:i:s T'));
|
|
$urgent = true;
|
|
} elseif ($days < $noticeDays) {
|
|
$warnings[] = sprintf('NOTICE: %s expires in %d days (%s)', $domain, $days, $expiry->format('Y-m-d H:i:s T'));
|
|
} else {
|
|
$this->line(sprintf('OK: %s expires in %d days.', $domain, $days));
|
|
}
|
|
}
|
|
|
|
if (empty($warnings)) {
|
|
$this->info('All certificates are outside the warning window.');
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$body = implode("\n", $warnings);
|
|
|
|
// Fallback that survives mail failure: always record to the app log.
|
|
foreach ($warnings as $line) {
|
|
\Log::warning('[cert-monitor] ' . $line);
|
|
}
|
|
|
|
$this->warn($body);
|
|
|
|
if ($this->option('dry-run')) {
|
|
$this->comment('Dry run - no email sent.');
|
|
return $urgent ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
$priority = $urgent ? '[URGENT]' : '[NOTICE]';
|
|
$subject = $priority . ' ' . config('app.name') . ' TLS certificate expiry';
|
|
|
|
try {
|
|
// Synchronous send (not queued) so the alert does not depend on a worker.
|
|
Mail::raw(
|
|
$body . "\n\nThis is an automated message from the TLS certificate monitor.",
|
|
function ($message) use ($email, $subject) {
|
|
$message->to($email)->subject($subject);
|
|
}
|
|
);
|
|
$this->info("Alert email sent to {$email}.");
|
|
} catch (\Throwable $e) {
|
|
// Logged above already; surface the mail failure for the scheduler output.
|
|
\Log::error('[cert-monitor] Failed to send alert email: ' . $e->getMessage());
|
|
$this->error('Failed to send alert email: ' . $e->getMessage());
|
|
return self::FAILURE;
|
|
}
|
|
|
|
// Nonzero on URGENT so the scheduler/healthcheck can distinguish severity.
|
|
return $urgent ? self::FAILURE : self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Read a certificate's notAfter date. Uses the openssl extension if available,
|
|
* otherwise shells out to the openssl binary.
|
|
*/
|
|
private function certExpiry(string $path): ?\DateTimeImmutable
|
|
{
|
|
if (! is_readable($path)) {
|
|
return null;
|
|
}
|
|
|
|
if (function_exists('openssl_x509_parse')) {
|
|
$data = openssl_x509_parse(file_get_contents($path));
|
|
if (is_array($data) && ! empty($data['validTo_time_t'])) {
|
|
return (new \DateTimeImmutable())->setTimestamp((int) $data['validTo_time_t']);
|
|
}
|
|
}
|
|
|
|
$out = @shell_exec('openssl x509 -enddate -noout -in ' . escapeshellarg($path) . ' 2>/dev/null');
|
|
if ($out && preg_match('/notAfter=(.+)/', $out, $m)) {
|
|
$ts = strtotime(trim($m[1]));
|
|
if ($ts !== false) {
|
|
return (new \DateTimeImmutable())->setTimestamp($ts);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|