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:
@@ -5,19 +5,25 @@ namespace App\Controllers;
|
||||
use Core\Controller;
|
||||
use App\Models\Domain;
|
||||
use App\Models\NotificationGroup;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Services\WhoisService;
|
||||
use App\Services\SslService;
|
||||
|
||||
class DomainController extends Controller
|
||||
{
|
||||
private Domain $domainModel;
|
||||
private NotificationGroup $groupModel;
|
||||
private WhoisService $whoisService;
|
||||
private SslCertificate $sslCertificateModel;
|
||||
private SslService $sslService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->domainModel = new Domain();
|
||||
$this->groupModel = new NotificationGroup();
|
||||
$this->whoisService = new WhoisService();
|
||||
$this->sslCertificateModel = new SslCertificate();
|
||||
$this->sslService = new SslService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,6 +615,7 @@ class DomainController extends Controller
|
||||
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||
$dnsMonitoringEnabled = isset($_POST['dns_monitoring_enabled']) ? 1 : 0;
|
||||
$sslMonitoringEnabled = isset($_POST['ssl_monitoring_enabled']) ? 1 : 0;
|
||||
$tagsInput = trim($_POST['tags'] ?? '');
|
||||
$manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null;
|
||||
|
||||
@@ -644,6 +651,7 @@ class DomainController extends Controller
|
||||
'notification_group_id' => $groupId,
|
||||
'is_active' => $isActive,
|
||||
'dns_monitoring_enabled' => $dnsMonitoringEnabled,
|
||||
'ssl_monitoring_enabled' => $sslMonitoringEnabled,
|
||||
'expiration_date' => $manualExpirationDate
|
||||
]);
|
||||
|
||||
@@ -666,6 +674,24 @@ class DomainController extends Controller
|
||||
$notificationService->sendToGroup($groupId, $subject, $message);
|
||||
}
|
||||
|
||||
// Send notification if SSL monitoring changed and has notification group
|
||||
$sslMonitoringChanged = (($domain['ssl_monitoring_enabled'] ?? 0) != $sslMonitoringEnabled);
|
||||
if ($sslMonitoringChanged && $groupId) {
|
||||
$notificationService = new \App\Services\NotificationService();
|
||||
|
||||
if ($sslMonitoringEnabled) {
|
||||
$message = "🟢 SSL monitoring has been ENABLED for {$domain['domain_name']}\n\n" .
|
||||
"The root certificate and monitored SSL endpoints will now be checked automatically.";
|
||||
$subject = "✅ SSL Monitoring Enabled: {$domain['domain_name']}";
|
||||
} else {
|
||||
$message = "🔴 SSL monitoring has been DISABLED for {$domain['domain_name']}\n\n" .
|
||||
"SSL certificates will no longer be checked until monitoring is re-enabled.";
|
||||
$subject = "⏸️ SSL Monitoring Disabled: {$domain['domain_name']}";
|
||||
}
|
||||
|
||||
$notificationService->sendToGroup($groupId, $subject, $message);
|
||||
}
|
||||
|
||||
// Send notification if DNS monitoring changed and has notification group
|
||||
$dnsMonitoringChanged = (($domain['dns_monitoring_enabled'] ?? 1) != $dnsMonitoringEnabled);
|
||||
if ($dnsMonitoringChanged && $groupId) {
|
||||
@@ -824,6 +850,307 @@ class DomainController extends Controller
|
||||
return "DNS updated ({$totalRecords} records)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and persist the latest SSL certificate snapshot for a host.
|
||||
*
|
||||
* @return array{id:int,hostname:string,port:int,display_target:string,status:string,error:?string}
|
||||
*/
|
||||
private function performSslRefreshForHost(int $domainId, string $hostname, int $port = 443): array
|
||||
{
|
||||
$snapshot = $this->sslService->fetchCertificateSnapshot($hostname, $port);
|
||||
$id = $this->sslCertificateModel->saveSnapshot($domainId, $hostname, $snapshot, $port);
|
||||
$this->domainModel->update($domainId, ['ssl_last_checked' => $snapshot['last_checked']]);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'display_target' => $this->sslService->formatTargetLabel($hostname, $port),
|
||||
'status' => $snapshot['status'],
|
||||
'error' => $snapshot['last_error'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSL endpoints that should be checked for a domain.
|
||||
* Falls back to the root domain on 443 until a root target is explicitly tracked.
|
||||
*
|
||||
* @return array<int,array{hostname:string,port:int}>
|
||||
*/
|
||||
private function getSslMonitorTargets(int $domainId, string $rootDomain): array
|
||||
{
|
||||
$rootDomain = strtolower($rootDomain);
|
||||
$targets = $this->sslCertificateModel->getDistinctTargets($domainId);
|
||||
$hasTrackedRootTarget = false;
|
||||
|
||||
foreach ($targets as $target) {
|
||||
if ($target['hostname'] === $rootDomain) {
|
||||
$hasTrackedRootTarget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasTrackedRootTarget) {
|
||||
$targets[] = [
|
||||
'hostname' => $rootDomain,
|
||||
'port' => 443,
|
||||
];
|
||||
}
|
||||
|
||||
usort($targets, static function (array $a, array $b): int {
|
||||
$hostnameCompare = strcasecmp($a['hostname'], $b['hostname']);
|
||||
if ($hostnameCompare !== 0) {
|
||||
return $hostnameCompare;
|
||||
}
|
||||
|
||||
return $a['port'] <=> $b['port'];
|
||||
});
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tracked root-domain SSL endpoints for delete safeguards.
|
||||
*/
|
||||
private function countStoredRootSslTargets(int $domainId, string $rootDomain): int
|
||||
{
|
||||
$rootDomain = strtolower($rootDomain);
|
||||
$targets = $this->sslCertificateModel->getDistinctTargets($domainId);
|
||||
|
||||
return count(array_filter($targets, static fn(array $target): bool => $target['hostname'] === $rootDomain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the certificate row represents the default root SSL target.
|
||||
*/
|
||||
private function isDefaultRootSslTarget(array $certificate, string $rootDomain): bool
|
||||
{
|
||||
return strtolower($certificate['hostname']) === strtolower($rootDomain)
|
||||
&& (int)($certificate['port'] ?? 443) === 443;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted SSL certificates for rendering.
|
||||
*/
|
||||
private function getFormattedSslCertificates(int $domainId, string $rootDomain): array
|
||||
{
|
||||
$rawCertificates = $this->sslCertificateModel->getByDomain($domainId);
|
||||
$rootDomain = strtolower($rootDomain);
|
||||
$rootTargetCount = count(array_filter(
|
||||
$rawCertificates,
|
||||
static fn(array $certificate): bool => strtolower($certificate['hostname']) === $rootDomain
|
||||
));
|
||||
|
||||
$certificates = array_map(
|
||||
fn(array $certificate) => $this->formatSslCertificate($certificate, $rootDomain, $rootTargetCount),
|
||||
$rawCertificates
|
||||
);
|
||||
|
||||
usort($certificates, function (array $a, array $b): int {
|
||||
if ($a['is_root'] !== $b['is_root']) {
|
||||
return $a['is_root'] ? -1 : 1;
|
||||
}
|
||||
|
||||
$hostnameCompare = strcasecmp($a['hostname'], $b['hostname']);
|
||||
if ($hostnameCompare !== 0) {
|
||||
return $hostnameCompare;
|
||||
}
|
||||
|
||||
return $a['port'] <=> $b['port'];
|
||||
});
|
||||
|
||||
return $certificates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a single SSL certificate row for the view.
|
||||
*/
|
||||
private function formatSslCertificate(array $certificate, string $rootDomain, int $rootTargetCount): array
|
||||
{
|
||||
$certificate['hostname'] = strtolower($certificate['hostname']);
|
||||
$certificate['port'] = (int)($certificate['port'] ?? 443);
|
||||
$certificate['is_root'] = $this->isDefaultRootSslTarget($certificate, $rootDomain);
|
||||
$certificate['display_target'] = $this->sslService->formatTargetLabel($certificate['hostname'], $certificate['port']);
|
||||
$certificate['can_delete'] = !$certificate['is_root'] || $rootTargetCount > 1;
|
||||
$certificate['san_list'] = !empty($certificate['san_list'])
|
||||
? (json_decode($certificate['san_list'], true) ?: [])
|
||||
: [];
|
||||
$certificate['raw_data'] = !empty($certificate['raw_data'])
|
||||
? (json_decode($certificate['raw_data'], true) ?: [])
|
||||
: [];
|
||||
$certificate['issuer_organization'] = $this->extractCertificateDnValue(
|
||||
is_array($certificate['raw_data']['issuer'] ?? null) ? $certificate['raw_data']['issuer'] : [],
|
||||
'O'
|
||||
);
|
||||
$certificate['subject_organization'] = $this->extractCertificateDnValue(
|
||||
is_array($certificate['raw_data']['subject'] ?? null) ? $certificate['raw_data']['subject'] : [],
|
||||
'O'
|
||||
);
|
||||
$certificate['days_remaining'] = $certificate['days_remaining'] !== null
|
||||
? (int)$certificate['days_remaining']
|
||||
: null;
|
||||
$certificate['is_trusted'] = !empty($certificate['is_trusted']);
|
||||
$certificate['is_self_signed'] = !empty($certificate['is_self_signed']);
|
||||
|
||||
return array_merge($certificate, $this->getSslStatusMeta($certificate['status'] ?? 'invalid'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable distinguished name field from parsed certificate data.
|
||||
*/
|
||||
private function extractCertificateDnValue(array $parts, string $field): ?string
|
||||
{
|
||||
if (!array_key_exists($field, $parts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $parts[$field];
|
||||
if (is_array($value)) {
|
||||
$values = array_values(array_filter(array_map(static function ($item): ?string {
|
||||
if (!is_scalar($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = trim((string)$item);
|
||||
return $item !== '' ? $item : null;
|
||||
}, $value)));
|
||||
|
||||
return !empty($values) ? implode(', ', $values) : null;
|
||||
}
|
||||
|
||||
if (!is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string)$value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS classes and labels for an SSL status.
|
||||
*/
|
||||
private function getSslStatusMeta(string $status): array
|
||||
{
|
||||
return match ($status) {
|
||||
'valid' => [
|
||||
'status_label' => 'Valid & Trusted',
|
||||
'status_icon' => 'fa-check-circle',
|
||||
'status_badge_class' => 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border-green-200 dark:border-green-800',
|
||||
'card_border_class' => 'border-green-200 dark:border-green-800',
|
||||
'header_class' => 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-800',
|
||||
'accent_class' => 'text-green-600 dark:text-green-400',
|
||||
],
|
||||
'expiring' => [
|
||||
'status_label' => 'Expiring Soon',
|
||||
'status_icon' => 'fa-exclamation-triangle',
|
||||
'status_badge_class' => 'bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400 border-amber-200 dark:border-amber-800',
|
||||
'card_border_class' => 'border-amber-200 dark:border-amber-800',
|
||||
'header_class' => 'bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-800',
|
||||
'accent_class' => 'text-amber-600 dark:text-amber-400',
|
||||
],
|
||||
'expired' => [
|
||||
'status_label' => 'Expired',
|
||||
'status_icon' => 'fa-times-circle',
|
||||
'status_badge_class' => 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 border-red-200 dark:border-red-800',
|
||||
'card_border_class' => 'border-red-200 dark:border-red-800',
|
||||
'header_class' => 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-800',
|
||||
'accent_class' => 'text-red-600 dark:text-red-400',
|
||||
],
|
||||
default => [
|
||||
'status_label' => 'Invalid / Untrusted',
|
||||
'status_icon' => 'fa-ban',
|
||||
'status_badge_class' => 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 border-red-200 dark:border-red-800',
|
||||
'card_border_class' => 'border-red-200 dark:border-red-800',
|
||||
'header_class' => 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-800',
|
||||
'accent_class' => 'text-red-600 dark:text-red-400',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSL summary counts for the tab.
|
||||
*/
|
||||
private function buildSslStats(array $certificates): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => count($certificates),
|
||||
'valid' => 0,
|
||||
'expiring' => 0,
|
||||
'expired' => 0,
|
||||
'invalid' => 0,
|
||||
];
|
||||
|
||||
foreach ($certificates as $certificate) {
|
||||
$status = $certificate['status'] ?? 'invalid';
|
||||
if (isset($stats[$status])) {
|
||||
$stats[$status]++;
|
||||
} else {
|
||||
$stats['invalid']++;
|
||||
}
|
||||
}
|
||||
|
||||
$stats['issues'] = $stats['expired'] + $stats['invalid'];
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SSL monitoring is enabled before allowing SSL checks.
|
||||
*/
|
||||
private function ensureSslMonitoringEnabled(array $domain, int $id): bool
|
||||
{
|
||||
if (!empty($domain['ssl_monitoring_enabled'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$_SESSION['warning'] = 'SSL monitoring is disabled for this domain';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse certificate ids from a comma-separated POST value.
|
||||
*/
|
||||
private function parseSslCertificateIds(?string $rawIds): array
|
||||
{
|
||||
if ($rawIds === null || trim($rawIds) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = array_map('intval', explode(',', $rawIds));
|
||||
$ids = array_filter($ids, static fn(int $id): bool => $id > 0);
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe internal return path for the current domain page.
|
||||
*/
|
||||
private function getSafeDomainReturnPath(int $id, string $fallbackHash = ''): string
|
||||
{
|
||||
$fallback = '/domains/' . $id . $fallbackHash;
|
||||
$returnTo = trim((string)($_POST['return_to'] ?? ''));
|
||||
|
||||
if ($returnTo === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$parts = parse_url($returnTo);
|
||||
if ($parts === false) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$path = $parts['path'] ?? '';
|
||||
if ($path !== '/domains/' . $id) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$fragment = '';
|
||||
if (!empty($parts['fragment']) && preg_match('/^[a-z0-9_-]+$/i', $parts['fragment'])) {
|
||||
$fragment = '#' . $parts['fragment'];
|
||||
}
|
||||
|
||||
return $path . $fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect back to the originating page (domain view or list).
|
||||
*/
|
||||
@@ -831,7 +1158,7 @@ class DomainController extends Controller
|
||||
{
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if (strpos($referer, '/domains/' . $id) !== false) {
|
||||
$this->redirect('/domains/' . $id . $hash);
|
||||
$this->redirect($this->getSafeDomainReturnPath($id, $hash));
|
||||
} else {
|
||||
$this->redirect('/domains');
|
||||
}
|
||||
@@ -839,6 +1166,13 @@ class DomainController extends Controller
|
||||
|
||||
public function refreshWhois($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
@@ -861,6 +1195,13 @@ class DomainController extends Controller
|
||||
|
||||
public function refreshAll($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
@@ -877,6 +1218,17 @@ class DomainController extends Controller
|
||||
} else {
|
||||
$messages[] = 'DNS skipped (monitoring disabled)';
|
||||
}
|
||||
if (!empty($domain['ssl_monitoring_enabled'])) {
|
||||
$targets = $this->getSslMonitorTargets($id, $domain['domain_name']);
|
||||
$refreshed = 0;
|
||||
foreach ($targets as $target) {
|
||||
$this->performSslRefreshForHost($id, $target['hostname'], $target['port']);
|
||||
$refreshed++;
|
||||
}
|
||||
$messages[] = 'SSL updated (' . $refreshed . ' endpoint' . ($refreshed === 1 ? '' : 's') . ')';
|
||||
} else {
|
||||
$messages[] = 'SSL skipped (monitoring disabled)';
|
||||
}
|
||||
|
||||
$_SESSION['success'] = 'Domain refreshed: ' . implode(', ', $messages);
|
||||
$this->redirectBackToDomain($id);
|
||||
@@ -952,6 +1304,8 @@ class DomainController extends Controller
|
||||
$dnsRecords = $dnsModel->getByDomainGrouped($id);
|
||||
$dnsRecordCount = $dnsModel->countByDomain($id);
|
||||
$dnsHasCloudflare = $dnsModel->hasCloudflare($id);
|
||||
$sslCertificates = $this->getFormattedSslCertificates($id, $domain['domain_name']);
|
||||
$sslStats = $this->buildSslStats($sslCertificates);
|
||||
|
||||
// Extract cached IP details (PTR, ASN, geo) from stored raw_data
|
||||
$dnsIpDetails = [];
|
||||
@@ -980,6 +1334,8 @@ class DomainController extends Controller
|
||||
'dnsRecordCount' => $dnsRecordCount,
|
||||
'dnsHasCloudflare' => $dnsHasCloudflare,
|
||||
'dnsIpDetails' => $dnsIpDetails,
|
||||
'sslCertificates' => $sslCertificates,
|
||||
'sslStats' => $sslStats,
|
||||
'title' => $domain['domain_name']
|
||||
]);
|
||||
}
|
||||
@@ -1759,6 +2115,13 @@ class DomainController extends Controller
|
||||
|
||||
public function refreshDns($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
@@ -1779,6 +2142,318 @@ class DomainController extends Controller
|
||||
$this->redirectBackToDomain($id, '#dns');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a monitored SSL hostname and fetch its certificate immediately.
|
||||
*/
|
||||
public function addSslHost($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->ensureSslMonitoringEnabled($domain, $id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$input = \App\Helpers\InputValidator::sanitizeText($_POST['hostname'] ?? '');
|
||||
$target = $this->sslService->parseMonitorTarget($input, $domain['domain_name']);
|
||||
|
||||
if ($target === null) {
|
||||
$_SESSION['error'] = 'Enter a valid subdomain, full hostname, or host:port under this domain';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$alreadyTracked = $this->sslCertificateModel->findByDomainAndHost(
|
||||
$id,
|
||||
$target['hostname'],
|
||||
$target['port']
|
||||
) !== null;
|
||||
$result = $this->performSslRefreshForHost($id, $target['hostname'], $target['port']);
|
||||
|
||||
if (in_array($result['status'], ['invalid', 'expired'], true)) {
|
||||
$_SESSION['warning'] = ($alreadyTracked ? 'SSL certificate refreshed' : 'SSL certificate added')
|
||||
. ' for ' . $result['display_target'] . ', but an issue was detected'
|
||||
. ($result['error'] ? ': ' . $result['error'] : '.');
|
||||
} else {
|
||||
$_SESSION['success'] = ($alreadyTracked ? 'SSL certificate refreshed for ' : 'SSL certificate added for ')
|
||||
. $result['display_target'];
|
||||
}
|
||||
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all monitored SSL hosts for the domain.
|
||||
* Ensures the root hostname is always checked.
|
||||
*/
|
||||
public function refreshAllSsl($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->ensureSslMonitoringEnabled($domain, $id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targets = $this->getSslMonitorTargets($id, $domain['domain_name']);
|
||||
|
||||
$results = [];
|
||||
foreach ($targets as $target) {
|
||||
$results[] = $this->performSslRefreshForHost($id, $target['hostname'], $target['port']);
|
||||
}
|
||||
|
||||
$issues = array_filter($results, static function (array $result): bool {
|
||||
return in_array($result['status'], ['invalid', 'expired'], true);
|
||||
});
|
||||
|
||||
if (!empty($issues)) {
|
||||
$_SESSION['warning'] = 'SSL check completed for ' . count($results) . ' endpoint(s); ' . count($issues) . ' issue(s) detected.';
|
||||
} else {
|
||||
$_SESSION['success'] = 'SSL certificates refreshed for ' . count($results) . ' endpoint(s).';
|
||||
}
|
||||
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a single monitored SSL host.
|
||||
*/
|
||||
public function refreshSsl($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$certificateId = (int)($params['certificateId'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->ensureSslMonitoringEnabled($domain, $id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$certificate = $this->sslCertificateModel->findByDomainAndId($id, $certificateId);
|
||||
if (!$certificate) {
|
||||
$_SESSION['error'] = 'SSL certificate not found';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->performSslRefreshForHost($id, $certificate['hostname'], (int)($certificate['port'] ?? 443));
|
||||
|
||||
if (in_array($result['status'], ['invalid', 'expired'], true)) {
|
||||
$_SESSION['warning'] = 'SSL certificate checked for ' . $result['display_target']
|
||||
. ($result['error'] ? ': ' . $result['error'] : '.');
|
||||
} else {
|
||||
$_SESSION['success'] = 'SSL certificate refreshed for ' . $result['display_target'];
|
||||
}
|
||||
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh selected monitored SSL hosts.
|
||||
*/
|
||||
public function bulkRefreshSsl($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->ensureSslMonitoringEnabled($domain, $id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->parseSslCertificateIds($_POST['certificate_ids'] ?? '');
|
||||
if (empty($ids)) {
|
||||
$_SESSION['warning'] = 'Select at least one SSL certificate to check';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($ids as $certificateId) {
|
||||
$certificate = $this->sslCertificateModel->findByDomainAndId($id, $certificateId);
|
||||
if ($certificate) {
|
||||
$results[] = $this->performSslRefreshForHost(
|
||||
$id,
|
||||
$certificate['hostname'],
|
||||
(int)($certificate['port'] ?? 443)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
$_SESSION['error'] = 'No valid SSL certificates were selected';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$issues = array_filter($results, static function (array $result): bool {
|
||||
return in_array($result['status'], ['invalid', 'expired'], true);
|
||||
});
|
||||
|
||||
if (!empty($issues)) {
|
||||
$_SESSION['warning'] = 'Checked ' . count($results) . ' SSL certificate(s); ' . count($issues) . ' issue(s) detected.';
|
||||
} else {
|
||||
$_SESSION['success'] = 'Checked ' . count($results) . ' SSL certificate(s).';
|
||||
}
|
||||
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitored SSL host.
|
||||
*/
|
||||
public function deleteSsl($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$certificateId = (int)($params['certificateId'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$certificate = $this->sslCertificateModel->findByDomainAndId($id, $certificateId);
|
||||
if (!$certificate) {
|
||||
$_SESSION['error'] = 'SSL certificate not found';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isDefaultRootSslTarget($certificate, $domain['domain_name'])
|
||||
&& $this->countStoredRootSslTargets($id, $domain['domain_name']) <= 1) {
|
||||
$_SESSION['error'] = 'Add another root SSL endpoint first if you want to replace the default port 443 check';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sslCertificateModel->deleteByDomainAndId($id, $certificateId);
|
||||
$_SESSION['success'] = 'SSL certificate removed for '
|
||||
. $this->sslService->formatTargetLabel($certificate['hostname'], (int)($certificate['port'] ?? 443));
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected monitored SSL hosts.
|
||||
*/
|
||||
public function bulkDeleteSsl($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/domains');
|
||||
|
||||
$id = (int)($params['id'] ?? 0);
|
||||
$domain = $this->checkDomainAccess($id);
|
||||
|
||||
if (!$domain) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
$this->redirect('/domains');
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->parseSslCertificateIds($_POST['certificate_ids'] ?? '');
|
||||
if (empty($ids)) {
|
||||
$_SESSION['warning'] = 'Select at least one SSL certificate to remove';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$storedRootTargetCount = $this->countStoredRootSslTargets($id, $domain['domain_name']);
|
||||
$selectedCertificates = [];
|
||||
foreach ($ids as $certificateId) {
|
||||
$certificate = $this->sslCertificateModel->findByDomainAndId($id, $certificateId);
|
||||
if ($certificate) {
|
||||
$selectedCertificates[] = $certificate;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedRootTargetCount = count(array_filter(
|
||||
$selectedCertificates,
|
||||
fn(array $certificate): bool => strtolower($certificate['hostname']) === strtolower($domain['domain_name'])
|
||||
));
|
||||
|
||||
$deletableIds = [];
|
||||
foreach ($selectedCertificates as $certificate) {
|
||||
if ($this->isDefaultRootSslTarget($certificate, $domain['domain_name'])
|
||||
&& ($storedRootTargetCount - $selectedRootTargetCount) < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deletableIds[] = (int)$certificate['id'];
|
||||
}
|
||||
|
||||
if (empty($deletableIds)) {
|
||||
$_SESSION['warning'] = 'No removable SSL certificates were selected. Add another root endpoint first if you want to replace port 443.';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = $this->sslCertificateModel->deleteByDomainAndIds($id, $deletableIds);
|
||||
$_SESSION['success'] = 'Removed ' . $deleted . ' SSL certificate(s).';
|
||||
$this->redirectBackToDomain($id, '#ssl');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for specific domains (API endpoint)
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,7 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_monitoring.sql',
|
||||
'028_add_ssl_monitoring.sql',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -202,6 +203,7 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_monitoring.sql',
|
||||
'028_add_ssl_monitoring.sql',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -426,6 +428,7 @@ class InstallerController extends Controller
|
||||
'025_add_update_system_v1.1.3.sql',
|
||||
'026_update_app_version_v1.1.4.sql',
|
||||
'027_add_dns_monitoring.sql',
|
||||
'028_add_ssl_monitoring.sql',
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||
@@ -661,7 +664,9 @@ class InstallerController extends Controller
|
||||
|
||||
// Fallback: detect "to" version from which migrations were run
|
||||
if ($toVersion === $fromVersion) {
|
||||
if (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
||||
if (in_array('028_add_ssl_monitoring.sql', $executed)) {
|
||||
$toVersion = '1.1.5';
|
||||
} elseif (in_array('026_update_app_version_v1.1.4.sql', $executed)) {
|
||||
$toVersion = '1.1.4';
|
||||
} elseif (in_array('025_add_update_system_v1.1.3.sql', $executed)) {
|
||||
$toVersion = '1.1.3';
|
||||
|
||||
Reference in New Issue
Block a user