Files
moko-platform/monitoring/ssl-check.php
T
jmiller 44f21f2c3c
Universal: Cascade Main → Dev / Cascade main → branches (push) Failing after 1s
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Scripts governance (push) Successful in 10s
Generic: Repo Health / Repository health (push) Failing after 9s
Generic: Repo Health / Release configuration (push) Failing after 36s
feat: add ssl-check.php
Authored-by: Moko Consulting
2026-05-19 20:47:23 +00:00

313 lines
7.5 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<string, mixed> */
private array $args = [];
/** @var array<array{domain: string, issuer: string, expires: string, daysLeft: int, status: string, error: string}> */
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 <file> or --domain <single>.');
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<string, mixed>
*/
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<string>
*/
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());