88 lines
3.0 KiB
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',
|
|
};
|
|
}
|
|
}
|