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