clusev/app/Services/CertService.php

88 lines
3.0 KiB
PHP

<?php
namespace App\Services;
/**
* Reads a TLS endpoint's certificate expiry natively (a PHP SSL stream + openssl_x509_parse) — no
* SSH, no shell, so `host` is never interpolated into a command. Trust is intentionally NOT verified
* (verify_peer off): the point is to report expiry even for a self-signed or already-expired cert.
* Monitoring internal endpoints is a legitimate use, so private/internal hosts are allowed.
*/
class CertService
{
/**
* @return array{ok:bool, subject?:string, issuer?:string, expiresAt?:int, daysLeft?:?int, error?:string}
*/
public function check(string $host, int $port, int $timeout = 5): array
{
$ctx = stream_context_create(['ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
'SNI_enabled' => true,
'peer_name' => $host,
]]);
$errno = 0;
$errstr = '';
// @ — a refused/timed-out/unresolved endpoint is a normal result here, not a PHP warning.
$client = @stream_socket_client(
'ssl://'.$host.':'.$port,
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
$ctx,
);
if (! $client) {
return ['ok' => false, 'error' => $errstr !== '' ? $errstr : 'connection failed'];
}
$params = stream_context_get_params($client);
fclose($client);
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
if ($cert === null) {
return ['ok' => false, 'error' => 'no certificate presented'];
}
return ['ok' => true] + $this->certInfo($cert);
}
/**
* Extract subject/issuer/expiry from an openssl cert (resource or PEM). Split out so it is unit-
* testable against a generated cert without a live network connection.
*
* @param \OpenSSLCertificate|string $cert
* @return array{subject:string, issuer:string, expiresAt:?int, daysLeft:?int}
*/
public function certInfo($cert): array
{
$parsed = openssl_x509_parse($cert) ?: [];
$expiresAt = isset($parsed['validTo_time_t']) ? (int) $parsed['validTo_time_t'] : null;
return [
'subject' => (string) ($parsed['subject']['CN'] ?? ($parsed['subject']['O'] ?? '—')),
'issuer' => (string) ($parsed['issuer']['CN'] ?? ($parsed['issuer']['O'] ?? '—')),
'expiresAt' => $expiresAt,
'daysLeft' => $expiresAt !== null ? (int) floor(($expiresAt - time()) / 86400) : null,
];
}
/** expired · critical (<7d) · warning (<30d) · ok — mapped to the status triad in the UI. */
public function status(?int $daysLeft): string
{
if ($daysLeft === null) {
return 'unknown';
}
return match (true) {
$daysLeft < 0 => 'expired',
$daysLeft < 7 => 'critical',
$daysLeft < 30 => 'warning',
default => 'ok',
};
}
}