Files

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;
}
}