diff --git a/monitoring/ssl-check.php b/monitoring/ssl-check.php new file mode 100644 index 0000000..ecc3e3f --- /dev/null +++ b/monitoring/ssl-check.php @@ -0,0 +1,312 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: MokoStandards.Scripts.Monitoring + * INGROUP: MokoStandards + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /monitoring/ssl-check.php + * VERSION: 01.00.00 + * BRIEF: Check SSL certificate expiry dates for a list of domains + */ + +declare(strict_types=1); + +final class SslCheck +{ + /** @var array */ + private array $args = []; + + /** @var array */ + private array $results = []; + + public function run(): int + { + $this->args = $this->parseArgs(); + + $domains = $this->resolveDomains(); + + if (count($domains) === 0) { + $this->log('No domains provided. Use --domains or --domain .'); + return 1; + } + + $warnDays = (int) ($this->args['warn-days'] ?? 30); + + foreach ($domains as $domain) { + $this->log("Checking: {$domain}"); + $entry = $this->checkCert($domain, $warnDays); + $this->results[] = $entry; + } + + $hasIssue = false; + foreach ($this->results as $r) { + if ($r['status'] !== 'OK') { + $hasIssue = true; + break; + } + } + + if (!empty($this->args['json'])) { + $this->outputJson(); + } else { + $this->outputTable(); + } + + if ($hasIssue && !empty($this->args['notify'])) { + $this->sendNotification(); + } + + return $hasIssue ? 1 : 0; + } + + /** + * @return array + */ + private function parseArgs(): array + { + $args = [ + 'domains' => null, + 'domain' => null, + 'warn-days' => 30, + 'notify' => null, + 'json' => false, + ]; + + $argv = $_SERVER['argv'] ?? []; + $argc = count($argv); + + for ($i = 1; $i < $argc; $i++) { + switch ($argv[$i]) { + case '--domains': + $args['domains'] = $argv[++$i] ?? null; + break; + case '--domain': + $args['domain'] = $argv[++$i] ?? null; + break; + case '--warn-days': + $args['warn-days'] = (int) ($argv[++$i] ?? 30); + break; + case '--notify': + $args['notify'] = $argv[++$i] ?? null; + break; + case '--json': + $args['json'] = true; + break; + } + } + + return $args; + } + + /** + * @return list + */ + private function resolveDomains(): array + { + $domains = []; + + if (!empty($this->args['domains'])) { + $file = $this->args['domains']; + if (!is_file($file) || !is_readable($file)) { + $this->log("Cannot read domains file: {$file}"); + return []; + } + $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines !== false) { + foreach ($lines as $line) { + $line = trim($line); + if ($line !== '' && $line[0] !== '#') { + $domains[] = $line; + } + } + } + } + + if (!empty($this->args['domain'])) { + $domains[] = trim($this->args['domain']); + } + + return $domains; + } + + /** + * @return array{domain: string, issuer: string, expires: string, daysLeft: int, status: string, error: string} + */ + private function checkCert(string $domain, int $warnDays): array + { + $entry = [ + 'domain' => $domain, + 'issuer' => '', + 'expires' => '', + 'daysLeft' => 0, + 'status' => 'EXPIRED', + 'error' => '', + ]; + + $context = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => true, + 'verify_peer_name' => true, + 'SNI_enabled' => true, + 'peer_name' => $domain, + ], + ]); + + $errno = 0; + $errstr = ''; + $client = @stream_socket_client( + "ssl://{$domain}:443", + $errno, + $errstr, + 15, + STREAM_CLIENT_CONNECT, + $context + ); + + if ($client === false) { + $entry['error'] = "Connection failed: {$errstr} ({$errno})"; + return $entry; + } + + $params = stream_context_get_params($client); + fclose($client); + + if (!isset($params['options']['ssl']['peer_certificate'])) { + $entry['error'] = 'No peer certificate captured'; + return $entry; + } + + $certResource = $params['options']['ssl']['peer_certificate']; + $certInfo = openssl_x509_parse($certResource); + + if ($certInfo === false) { + $entry['error'] = 'Failed to parse certificate'; + return $entry; + } + + // Issuer + $issuerParts = $certInfo['issuer'] ?? []; + $entry['issuer'] = $issuerParts['O'] ?? $issuerParts['CN'] ?? 'Unknown'; + + // Expiry + $validTo = $certInfo['validTo_time_t'] ?? 0; + $entry['expires'] = date('Y-m-d', $validTo); + + $now = time(); + $daysLeft = (int) floor(($validTo - $now) / 86400); + $entry['daysLeft'] = $daysLeft; + + if ($daysLeft < 0) { + $entry['status'] = 'EXPIRED'; + } elseif ($daysLeft <= $warnDays) { + $entry['status'] = 'WARN'; + } else { + $entry['status'] = 'OK'; + } + + return $entry; + } + + private function outputTable(): void + { + $colDomain = 6; + $colIssuer = 6; + $colExpires = 10; + $colDays = 9; + $colStatus = 7; + + foreach ($this->results as $r) { + $colDomain = max($colDomain, strlen($r['domain'])); + $colIssuer = max($colIssuer, strlen($r['issuer'])); + } + + $colDomain = min($colDomain, 40); + $colIssuer = min($colIssuer, 30); + + $fmt = "%-{$colDomain}s | %-{$colIssuer}s | %-{$colExpires}s | %-{$colDays}s | %-{$colStatus}s"; + + $this->log(''); + $this->log(sprintf($fmt, 'Domain', 'Issuer', 'Expires', 'Days Left', 'Status')); + $this->log(str_repeat('-', $colDomain + $colIssuer + $colExpires + $colDays + $colStatus + 13)); + + foreach ($this->results as $r) { + $domainDisplay = strlen($r['domain']) > 40 ? substr($r['domain'], 0, 37) . '...' : $r['domain']; + $issuerDisplay = strlen($r['issuer']) > 30 ? substr($r['issuer'], 0, 27) . '...' : $r['issuer']; + $daysStr = $r['error'] !== '' ? 'N/A' : (string) $r['daysLeft']; + $expiresStr = $r['expires'] ?: 'N/A'; + + $this->log(sprintf($fmt, $domainDisplay, $issuerDisplay, $expiresStr, $daysStr, $r['status'])); + + if ($r['error'] !== '') { + $this->log(" Error: {$r['error']}"); + } + } + + $this->log(''); + } + + private function outputJson(): void + { + echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } + + private function sendNotification(): void + { + $ntfyUrl = $this->args['notify']; + if (empty($ntfyUrl)) { + return; + } + + $issues = []; + foreach ($this->results as $r) { + if ($r['status'] !== 'OK') { + $issues[] = $r['domain'] . ' — ' . $r['status'] + . ($r['daysLeft'] > 0 ? " ({$r['daysLeft']} days left)" : '') + . ($r['error'] !== '' ? " [{$r['error']}]" : ''); + } + } + + $message = "SSL certificate issues:\n" . implode("\n", $issues); + + $ch = curl_init($ntfyUrl); + if ($ch === false) { + $this->log('Failed to initialise curl for notification.'); + return; + } + + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $message, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Title: SSL Certificate Alert', + 'Priority: high', + ], + ]); + + $result = curl_exec($ch); + if ($result === false) { + $this->log('Notification failed: ' . curl_error($ch)); + } else { + $this->log('Notification sent.'); + } + + curl_close($ch); + } + + private function log(string $message): void + { + fwrite(STDERR, $message . "\n"); + } +} + +$check = new SslCheck(); +exit($check->run());