#!/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());