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
Authored-by: Moko Consulting
313 lines
7.5 KiB
PHP
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());
|