Add SSL monitoring (Svc, model, cron, UI)

Introduce SSL certificate monitoring: add SslService for fetching/parsing certs and parsing monitor targets, SslCertificate model for storing snapshots and managing monitored targets, and cron/check_ssl.php for scheduled checks. Extend DomainController with many SSL endpoints and helpers (add/refresh/bulk refresh/delete/bulk delete, snapshot handling, formatting, stats, safety checks) and surface SSL data in domain views. Add NotificationService helpers to create/send SSL alerts, update Installer to include new migration, add migration 028 to create ssl_certificates table, bump app version default to 1.1.5, update changelog, and modify routes and templates to include SSL tab and related UI. Logs and basic validation/error handling are included to surface SSL issues and protect default root-target behavior.
This commit is contained in:
Hosteroid
2026-03-08 21:12:09 +02:00
parent 8559e903b9
commit 5916daa293
17 changed files with 2460 additions and 349 deletions

390
app/Services/SslService.php Normal file
View File

@@ -0,0 +1,390 @@
<?php
namespace App\Services;
use App\Helpers\InputValidator;
class SslService
{
private const DEFAULT_PORT = 443;
private const CONNECT_TIMEOUT = 15;
private const EXPIRING_SOON_DAYS = 30;
private Logger $logger;
public function __construct()
{
$this->logger = new Logger('ssl');
}
/**
* Normalize a user-supplied SSL host into a monitored hostname for the domain.
*/
public function normalizeHostname(string $input, string $baseDomain): ?string
{
$target = $this->parseMonitorTarget($input, $baseDomain);
return $target['hostname'] ?? null;
}
/**
* Parse a user-supplied SSL monitoring target into hostname + port.
*
* @return array{hostname:string,port:int}|null
*/
public function parseMonitorTarget(string $input, string $baseDomain): ?array
{
$baseDomain = strtolower(trim($baseDomain));
$input = strtolower(trim($input));
$port = self::DEFAULT_PORT;
if ($input === '') {
return null;
}
if (str_contains($input, '://') || preg_match('/[\/\\\\\s?#]/', $input)) {
return null;
}
$colonPos = strrpos($input, ':');
if ($colonPos !== false) {
$portText = substr($input, $colonPos + 1);
if ($portText === '' || !ctype_digit($portText)) {
return null;
}
$port = (int)$portText;
if ($port < 1 || $port > 65535) {
return null;
}
$input = substr($input, 0, $colonPos);
if ($input === '') {
return null;
}
}
$hostname = $this->normalizeMonitorHostname($input, $baseDomain);
if ($hostname === null) {
return null;
}
return [
'hostname' => $hostname,
'port' => $port,
];
}
/**
* Fetch and parse certificate details for a hostname.
*
* @return array{status:string,is_trusted:bool,is_self_signed:bool,valid_from:?string,valid_to:?string,days_remaining:?int,issuer_name:?string,subject_name:?string,serial_number:?string,signature_algorithm:?string,key_bits:?int,key_type:?string,certificate_version:?string,san_list:array,last_checked:string,last_error:?string,raw_data:array}
*/
public function fetchCertificateSnapshot(string $hostname, int $port = self::DEFAULT_PORT): array
{
$hostname = strtolower(trim($hostname));
$now = date('Y-m-d H:i:s');
$primary = $this->connect($hostname, $port, true);
$verified = $primary['success'];
$connection = $primary;
if (!$primary['success']) {
$fallback = $this->connect($hostname, $port, false);
if ($fallback['success']) {
$connection = $fallback;
}
}
if (empty($connection['certificate'])) {
$error = $primary['error'] ?: ($connection['error'] ?? 'Could not retrieve certificate');
$this->logger->warning('SSL certificate fetch failed', [
'hostname' => $hostname,
'port' => $port,
'error' => $error,
]);
return [
'status' => 'invalid',
'is_trusted' => false,
'is_self_signed' => false,
'valid_from' => null,
'valid_to' => null,
'days_remaining' => null,
'issuer_name' => null,
'subject_name' => null,
'serial_number' => null,
'signature_algorithm' => null,
'key_bits' => null,
'key_type' => null,
'certificate_version' => null,
'san_list' => [],
'last_checked' => $now,
'last_error' => $error,
'raw_data' => [
'hostname' => $hostname,
'port' => $port,
'verified_attempt_error' => $primary['error'] ?? null,
],
];
}
$parsed = @openssl_x509_parse($connection['certificate']);
if (!is_array($parsed)) {
return [
'status' => 'invalid',
'is_trusted' => false,
'is_self_signed' => false,
'valid_from' => null,
'valid_to' => null,
'days_remaining' => null,
'issuer_name' => null,
'subject_name' => null,
'serial_number' => null,
'signature_algorithm' => null,
'key_bits' => null,
'key_type' => null,
'certificate_version' => null,
'san_list' => [],
'last_checked' => $now,
'last_error' => 'Could not parse certificate',
'raw_data' => [
'hostname' => $hostname,
'port' => $port,
'verified_attempt_error' => $primary['error'] ?? null,
],
];
}
$publicKeyDetails = $this->getPublicKeyDetails($connection['certificate']);
$validFromTs = isset($parsed['validFrom_time_t']) ? (int)$parsed['validFrom_time_t'] : null;
$validToTs = isset($parsed['validTo_time_t']) ? (int)$parsed['validTo_time_t'] : null;
$daysRemaining = $validToTs !== null ? (int)floor(($validToTs - time()) / 86400) : null;
$subjectName = $this->formatDistinguishedName($parsed['subject'] ?? []);
$issuerName = $this->formatDistinguishedName($parsed['issuer'] ?? []);
$isSelfSigned = $subjectName !== '' && $subjectName === $issuerName;
$sanList = $this->extractSanList($parsed);
$status = $this->determineStatus($verified, $daysRemaining);
$error = $primary['error'] ?? null;
$snapshot = [
'status' => $status,
'is_trusted' => $verified,
'is_self_signed' => $isSelfSigned,
'valid_from' => $validFromTs ? date('Y-m-d H:i:s', $validFromTs) : null,
'valid_to' => $validToTs ? date('Y-m-d H:i:s', $validToTs) : null,
'days_remaining' => $daysRemaining,
'issuer_name' => $issuerName ?: null,
'subject_name' => $subjectName ?: null,
'serial_number' => $parsed['serialNumberHex'] ?? ($parsed['serialNumber'] ?? null),
'signature_algorithm' => $parsed['signatureTypeLN'] ?? ($parsed['signatureTypeSN'] ?? null),
'key_bits' => $publicKeyDetails['bits'],
'key_type' => $publicKeyDetails['type'],
'certificate_version' => isset($parsed['version']) ? 'v' . ((int)$parsed['version'] + 1) : null,
'san_list' => $sanList,
'last_checked' => $now,
'last_error' => $status === 'valid' || $status === 'expiring' || $status === 'expired' ? null : $error,
'raw_data' => [
'hostname' => $hostname,
'port' => $port,
'subject' => $parsed['subject'] ?? [],
'issuer' => $parsed['issuer'] ?? [],
'extensions' => $parsed['extensions'] ?? [],
'verified_attempt_error' => $primary['error'] ?? null,
'san_list' => $sanList,
],
];
$this->logger->info('SSL certificate fetched', [
'hostname' => $hostname,
'port' => $port,
'status' => $snapshot['status'],
'trusted' => $snapshot['is_trusted'],
'days_remaining' => $snapshot['days_remaining'],
]);
return $snapshot;
}
/**
* Format a monitored target for display and notifications.
*/
public function formatTargetLabel(string $hostname, int $port = self::DEFAULT_PORT): string
{
$hostname = strtolower(trim($hostname));
return $port === self::DEFAULT_PORT ? $hostname : $hostname . ':' . $port;
}
private function determineStatus(bool $verified, ?int $daysRemaining): string
{
if ($daysRemaining !== null && $daysRemaining < 0) {
return 'expired';
}
if (!$verified) {
return 'invalid';
}
if ($daysRemaining !== null && $daysRemaining <= self::EXPIRING_SOON_DAYS) {
return 'expiring';
}
return 'valid';
}
private function connect(string $hostname, int $port, bool $verifyPeer): array
{
$context = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'capture_peer_cert_chain' => true,
'SNI_enabled' => true,
'peer_name' => $hostname,
'verify_peer' => $verifyPeer,
'verify_peer_name' => $verifyPeer,
'allow_self_signed' => !$verifyPeer,
'disable_compression' => true,
],
]);
$errno = 0;
$errstr = '';
$warning = null;
set_error_handler(static function (int $severity, string $message) use (&$warning): bool {
$warning = $message;
return true;
});
try {
$socket = @stream_socket_client(
"ssl://{$hostname}:{$port}",
$errno,
$errstr,
self::CONNECT_TIMEOUT,
STREAM_CLIENT_CONNECT,
$context
);
} finally {
restore_error_handler();
}
if (!$socket) {
return [
'success' => false,
'error' => $warning ?: $errstr ?: ('Connection failed (' . $errno . ')'),
'certificate' => null,
];
}
$params = stream_context_get_params($socket);
fclose($socket);
return [
'success' => true,
'error' => $warning,
'certificate' => $params['options']['ssl']['peer_certificate'] ?? null,
];
}
private function getPublicKeyDetails($certificate): array
{
$publicKey = @openssl_pkey_get_public($certificate);
if ($publicKey === false) {
return ['bits' => null, 'type' => null];
}
$details = @openssl_pkey_get_details($publicKey) ?: [];
if (PHP_VERSION_ID < 80000) {
@openssl_free_key($publicKey);
}
return [
'bits' => isset($details['bits']) ? (int)$details['bits'] : null,
'type' => $this->mapKeyType($details['type'] ?? null),
];
}
private function mapKeyType(?int $type): ?string
{
return match ($type) {
OPENSSL_KEYTYPE_RSA => 'RSA',
OPENSSL_KEYTYPE_DSA => 'DSA',
OPENSSL_KEYTYPE_DH => 'DH',
OPENSSL_KEYTYPE_EC => 'EC',
default => null,
};
}
private function extractSanList(array $parsed): array
{
$sanText = $parsed['extensions']['subjectAltName'] ?? '';
if ($sanText === '') {
return [];
}
$result = [];
foreach (explode(',', $sanText) as $entry) {
$entry = trim($entry);
if (str_starts_with($entry, 'DNS:')) {
$result[] = substr($entry, 4);
}
}
return array_values(array_unique(array_filter($result)));
}
private function formatDistinguishedName(array $parts): string
{
if (!empty($parts['CN'])) {
return (string)$parts['CN'];
}
foreach (['O', 'OU', 'emailAddress'] as $field) {
if (!empty($parts[$field])) {
return (string)$parts[$field];
}
}
$values = [];
foreach ($parts as $key => $value) {
if (is_scalar($value) && $value !== '') {
$values[] = $key . '=' . $value;
}
}
return implode(', ', $values);
}
private function normalizeMonitorHostname(string $input, string $baseDomain): ?string
{
if ($input === '' || $input === '@') {
return $baseDomain;
}
$input = rtrim($input, '.');
if ($input === $baseDomain) {
return $baseDomain;
}
if (InputValidator::validateDomain($input) && str_ends_with($input, '.' . $baseDomain)) {
return $input;
}
if (!$this->isValidRelativeHost($input)) {
return null;
}
$candidate = $input . '.' . $baseDomain;
return InputValidator::validateDomain($candidate) ? $candidate : null;
}
private function isValidRelativeHost(string $host): bool
{
return (bool)preg_match(
'/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/i',
$host
);
}
}