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';
|
||||
|
||||
@@ -122,7 +122,7 @@ class Setting extends Model
|
||||
*/
|
||||
public function getAppVersion(): string
|
||||
{
|
||||
return $this->getValue('app_version', '1.1.4');
|
||||
return $this->getValue('app_version', '1.1.5');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
166
app/Models/SslCertificate.php
Normal file
166
app/Models/SslCertificate.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Core\Model;
|
||||
|
||||
class SslCertificate extends Model
|
||||
{
|
||||
protected static string $table = 'ssl_certificates';
|
||||
|
||||
/**
|
||||
* Get all monitored SSL certificates for a domain.
|
||||
*/
|
||||
public function getByDomain(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC, port ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count monitored SSL certificates for a domain.
|
||||
*/
|
||||
public function countByDomain(int $domainId): int
|
||||
{
|
||||
$stmt = $this->db->prepare("SELECT COUNT(*) FROM ssl_certificates WHERE domain_id = ?");
|
||||
$stmt->execute([$domainId]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct monitored hostnames for a domain.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getDistinctHosts(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT DISTINCT hostname FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return array_column($stmt->fetchAll(), 'hostname');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct monitored SSL targets for a domain.
|
||||
*
|
||||
* @return array<int,array{hostname:string,port:int}>
|
||||
*/
|
||||
public function getDistinctTargets(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT DISTINCT hostname, port FROM ssl_certificates WHERE domain_id = ? ORDER BY hostname ASC, port ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
|
||||
return array_map(
|
||||
static fn(array $row): array => [
|
||||
'hostname' => strtolower($row['hostname']),
|
||||
'port' => (int)$row['port'],
|
||||
],
|
||||
$stmt->fetchAll()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a monitored SSL certificate by domain and host.
|
||||
*/
|
||||
public function findByDomainAndHost(int $domainId, string $hostname, int $port = 443): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? AND hostname = ? AND port = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, strtolower($hostname), $port]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a monitored SSL certificate by domain and id.
|
||||
*/
|
||||
public function findByDomainAndId(int $domainId, int $id): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM ssl_certificates WHERE domain_id = ? AND id = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, $id]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the latest SSL snapshot for a monitored host.
|
||||
* Creates the row if it does not exist.
|
||||
*/
|
||||
public function saveSnapshot(int $domainId, string $hostname, array $snapshot, int $port = 443): int
|
||||
{
|
||||
$hostname = strtolower($hostname);
|
||||
$existing = $this->findByDomainAndHost($domainId, $hostname, $port);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$data = [
|
||||
'domain_id' => $domainId,
|
||||
'hostname' => $hostname,
|
||||
'port' => $port,
|
||||
'status' => $snapshot['status'] ?? 'invalid',
|
||||
'is_trusted' => !empty($snapshot['is_trusted']) ? 1 : 0,
|
||||
'is_self_signed' => !empty($snapshot['is_self_signed']) ? 1 : 0,
|
||||
'valid_from' => $snapshot['valid_from'] ?? null,
|
||||
'valid_to' => $snapshot['valid_to'] ?? null,
|
||||
'days_remaining' => $snapshot['days_remaining'] ?? null,
|
||||
'issuer_name' => $snapshot['issuer_name'] ?? null,
|
||||
'subject_name' => $snapshot['subject_name'] ?? null,
|
||||
'serial_number' => $snapshot['serial_number'] ?? null,
|
||||
'signature_algorithm' => $snapshot['signature_algorithm'] ?? null,
|
||||
'key_bits' => $snapshot['key_bits'] ?? null,
|
||||
'key_type' => $snapshot['key_type'] ?? null,
|
||||
'certificate_version' => $snapshot['certificate_version'] ?? null,
|
||||
'san_list' => isset($snapshot['san_list']) ? json_encode($snapshot['san_list']) : null,
|
||||
'last_checked' => $snapshot['last_checked'] ?? $now,
|
||||
'last_error' => $snapshot['last_error'] ?? null,
|
||||
'raw_data' => isset($snapshot['raw_data']) ? json_encode($snapshot['raw_data']) : null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$update = $data;
|
||||
unset($update['domain_id'], $update['hostname'], $update['port']);
|
||||
$this->update($existing['id'], $update);
|
||||
return (int)$existing['id'];
|
||||
}
|
||||
|
||||
$data['created_at'] = $now;
|
||||
return $this->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a monitored SSL certificate by domain and id.
|
||||
*/
|
||||
public function deleteByDomainAndId(int $domainId, int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM ssl_certificates WHERE domain_id = ? AND id = ?");
|
||||
return $stmt->execute([$domainId, $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple monitored SSL certificates by domain and ids.
|
||||
* @return int Number of deleted rows.
|
||||
*/
|
||||
public function deleteByDomainAndIds(int $domainId, array $ids): int
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $this->db->prepare(
|
||||
"DELETE FROM ssl_certificates WHERE domain_id = ? AND id IN ({$placeholders})"
|
||||
);
|
||||
$stmt->execute(array_merge([$domainId], $ids));
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
@@ -713,6 +713,151 @@ class NotificationService
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSL monitoring notification (in-app / bell icon).
|
||||
*/
|
||||
public function notifySslStatusChange(
|
||||
int $userId,
|
||||
string $domainName,
|
||||
string $hostname,
|
||||
int $domainId,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null
|
||||
): void {
|
||||
$notificationModel = new \App\Models\Notification();
|
||||
$notificationModel->createNotification(
|
||||
$userId,
|
||||
'ssl_status_change',
|
||||
$this->getSslNotificationTitle($newStatus),
|
||||
$this->formatSslStatusSummary($domainName, $hostname, $newStatus, $oldStatus),
|
||||
$domainId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SSL status alert to external channels.
|
||||
*/
|
||||
public function sendSslStatusAlert(
|
||||
array $domain,
|
||||
array $notificationChannels,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null,
|
||||
?string $validTo = null,
|
||||
?string $error = null
|
||||
): array {
|
||||
$message = $this->formatSslStatusMessage($domain, $hostname, $newStatus, $oldStatus, $validTo, $error);
|
||||
$results = [];
|
||||
|
||||
foreach ($notificationChannels as $channel) {
|
||||
$config = json_decode($channel['channel_config'], true);
|
||||
$success = $this->send(
|
||||
$channel['channel_type'],
|
||||
$config,
|
||||
$message,
|
||||
[
|
||||
'subject' => $this->getSslNotificationTitle($newStatus) . ': ' . $hostname,
|
||||
'domain' => $domain['domain_name'],
|
||||
'domain_id' => $domain['id'],
|
||||
'hostname' => $hostname,
|
||||
'new_status' => $newStatus,
|
||||
'old_status' => $oldStatus,
|
||||
'valid_to' => $validTo,
|
||||
'error' => $error,
|
||||
]
|
||||
);
|
||||
|
||||
$results[] = [
|
||||
'channel' => $channel['channel_type'],
|
||||
'success' => $success,
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSL status label for human-readable messages.
|
||||
*/
|
||||
public static function getSslStatusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'valid' => 'Valid',
|
||||
'expiring' => 'Expiring Soon',
|
||||
'expired' => 'Expired',
|
||||
'invalid' => 'Invalid',
|
||||
default => ucfirst(str_replace('_', ' ', $status)),
|
||||
};
|
||||
}
|
||||
|
||||
private function getSslNotificationTitle(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'valid' => 'SSL Certificate Recovered',
|
||||
'expiring' => 'SSL Certificate Expiring Soon',
|
||||
'expired' => 'SSL Certificate Expired',
|
||||
'invalid' => 'SSL Certificate Check Failed',
|
||||
default => 'SSL Status Changed',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatSslStatusSummary(
|
||||
string $domainName,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null
|
||||
): string {
|
||||
$hostText = $hostname === $domainName ? $domainName : "{$hostname} ({$domainName})";
|
||||
$newLabel = self::getSslStatusLabel($newStatus);
|
||||
|
||||
if ($oldStatus !== null) {
|
||||
$oldLabel = self::getSslStatusLabel($oldStatus);
|
||||
return "{$hostText} - SSL status changed from {$oldLabel} to {$newLabel}";
|
||||
}
|
||||
|
||||
return "{$hostText} - SSL status is {$newLabel}";
|
||||
}
|
||||
|
||||
private function formatSslStatusMessage(
|
||||
array $domain,
|
||||
string $hostname,
|
||||
string $newStatus,
|
||||
?string $oldStatus = null,
|
||||
?string $validTo = null,
|
||||
?string $error = null
|
||||
): string {
|
||||
$domainName = $domain['domain_name'];
|
||||
$validToText = $validTo ? date('F j, Y H:i', strtotime($validTo)) : 'Unknown';
|
||||
$oldLabel = $oldStatus !== null ? self::getSslStatusLabel($oldStatus) : null;
|
||||
|
||||
return match ($newStatus) {
|
||||
'valid' => "✅ SSL RECOVERED: {$hostname} is now using a valid certificate.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Valid until: {$validToText}",
|
||||
|
||||
'expiring' => "⚠️ SSL EXPIRING SOON: {$hostname} is approaching certificate expiration.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Valid until: {$validToText}",
|
||||
|
||||
'expired' => "🚨 SSL EXPIRED: {$hostname} has an expired certificate.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
"Expired on: {$validToText}",
|
||||
|
||||
'invalid' => "❌ SSL INVALID: {$hostname} failed certificate validation.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Domain: {$domainName}\n" .
|
||||
($error ? "Error: {$error}\n" : ''),
|
||||
|
||||
default => "ℹ️ SSL STATUS CHANGE: {$hostname} changed SSL status.\n\n" .
|
||||
($oldLabel ? "Previous status: {$oldLabel}\n" : '') .
|
||||
"Current status: " . self::getSslStatusLabel($newStatus) . "\n" .
|
||||
"Domain: {$domainName}"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old read notifications (cleanup)
|
||||
*/
|
||||
|
||||
390
app/Services/SslService.php
Normal file
390
app/Services/SslService.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -152,15 +152,25 @@
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
|
||||
<input type="checkbox"
|
||||
name="dns_monitoring_enabled"
|
||||
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
|
||||
<input type="checkbox"
|
||||
name="dns_monitoring_enabled"
|
||||
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
|
||||
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable DNS Monitoring</span>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, DNS records will be checked for changes and you'll receive alerts</p>
|
||||
</div>
|
||||
</label>
|
||||
<label id="ssl-monitoring" class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
|
||||
<input type="checkbox"
|
||||
name="ssl_monitoring_enabled"
|
||||
{{ domain.ssl_monitoring_enabled|default(0) ? 'checked' : '' }}
|
||||
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable SSL Monitoring</span>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, the root certificate and any monitored SSL endpoints will be checked automatically</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
|
||||
@@ -191,9 +191,15 @@
|
||||
<i class="fas fa-lock text-indigo-500 dark:text-indigo-400 mr-2" style="font-size: 10px;"></i>
|
||||
<span class="text-xs text-gray-700 dark:text-slate-300">SSL</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-minus-circle mr-1" style="font-size: 9px;"></i>Coming Soon
|
||||
{% if domain.ssl_monitoring_enabled|default(0) %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
|
||||
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -1,379 +1,507 @@
|
||||
<!-- SSL TAB CONTENT -->
|
||||
{% set sslStats = sslStats|default({
|
||||
total: 0,
|
||||
valid: 0,
|
||||
expiring: 0,
|
||||
expired: 0,
|
||||
invalid: 0,
|
||||
issues: 0
|
||||
}) %}
|
||||
{% set sslCertificates = sslCertificates|default([]) %}
|
||||
{% set sslMonitoringEnabled = domain.ssl_monitoring_enabled|default(0) %}
|
||||
{% set rootCertificate = sslCertificates|filter(cert => cert.is_root)|first %}
|
||||
|
||||
<!-- Preview Banner -->
|
||||
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
|
||||
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">SSL certificate monitoring is coming soon. This is a design preview with sample data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Actions Bar -->
|
||||
<div class="mb-3 flex flex-wrap gap-3 justify-between items-center">
|
||||
<div class="flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<input type="text" id="ssl-search" placeholder="Search certificates..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
|
||||
<option value="all">All Certificates</option>
|
||||
<option value="valid">Valid Only</option>
|
||||
<option value="expiring">Expiring Soon</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
<button onclick="checkAllCertificates()" class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Subdomain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default) -->
|
||||
<div id="ssl-bulk-actions" class="hidden mb-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
|
||||
<button type="button" onclick="bulkCheckSSL()" class="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 9px;"></i>
|
||||
Check Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkDeleteSSL()" class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-1.5" style="font-size: 9px;"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">3</p>
|
||||
</div>
|
||||
<i class="fas fa-lock text-gray-400 dark:text-slate-500" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-0.5">2</p>
|
||||
</div>
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring Soon</p>
|
||||
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">1</p>
|
||||
</div>
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 dark:text-orange-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-0.5">1</p>
|
||||
</div>
|
||||
<i class="fas fa-times-circle text-red-500 dark:text-red-400" style="font-size: 18px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-3 flex justify-between items-center">
|
||||
<div class="text-xs text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">1</span> to <span class="font-semibold text-gray-900 dark:text-white">3</span> of <span class="font-semibold text-gray-900 dark:text-white">3</span> certificate(s)
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select class="px-2 py-1 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded text-xs">
|
||||
<option>10</option>
|
||||
<option selected>25</option>
|
||||
<option>50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Certificates List -->
|
||||
<div class="space-y-3">
|
||||
<!-- Cert 1 (root) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="1" data-status="valid">
|
||||
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">example.com <span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span></h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||
Valid & Trusted
|
||||
</span>
|
||||
{% if not sslMonitoringEnabled %}
|
||||
<div class="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">SSL monitoring is disabled</h3>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the SSL cron. Enable it in Edit to monitor the root certificate and tracked SSL endpoints.</p>
|
||||
<a href="/domains/{{ domain.id }}/edit#ssl-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
|
||||
<i class="fas fa-edit mr-1"></i>Enable SSL monitoring in Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Oct 05, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jan 08, 2026</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">65 days</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">Let's Encrypt Authority X3</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">www.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
|
||||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">SSL certificate monitoring</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
Track the default root certificate and any monitored HTTPS endpoints, including custom ports.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-shield-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
{{ rootCertificate ? 'Check Root SSL (443)' : 'Start Root Monitoring (443)' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/refresh-all" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="openAddSslEndpointModal()" class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cert 2 (mail subdomain) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="2" data-status="valid">
|
||||
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="2" onchange="updateSSLBulkActions()">
|
||||
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">mail.example.com</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||
Valid & Trusted
|
||||
</span>
|
||||
</div>
|
||||
{% if sslStats.total > 0 %}
|
||||
<div class="grid grid-cols-2 xl:grid-cols-5 gap-3">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-1">{{ sslStats.total }}</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Aug 01, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jul 28, 2026</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">270 days</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">DigiCert Inc.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">mail.example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">smtp.example.com</span></div>
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">imap.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-1">{{ sslStats.valid }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring</p>
|
||||
<p class="text-lg font-semibold text-amber-600 dark:text-amber-400 mt-1">{{ sslStats.expiring }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expired</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.expired }}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-1">{{ sslStats.invalid }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||
<span>
|
||||
<i class="far fa-clock mr-1"></i>
|
||||
Last checked: {{ domain.ssl_last_checked ? domain.ssl_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
id="ssl-search"
|
||||
placeholder="Search monitored endpoints..."
|
||||
class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"
|
||||
>
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
|
||||
<option value="all">All certificates</option>
|
||||
<option value="valid">Valid only</option>
|
||||
<option value="expiring">Expiring soon</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ssl-bulk-actions" class="hidden bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" onclick="submitBulkSslAction('refresh')" class="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 9px;"></i>
|
||||
Check Selected
|
||||
</button>
|
||||
<button type="button" onclick="submitBulkSslAction('delete')" class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-1.5" style="font-size: 9px;"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cert 3 (api - expired) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-red-200 dark:border-red-800 overflow-hidden ssl-cert-item" data-cert-id="3" data-status="expired">
|
||||
<div class="px-4 py-2 bg-red-50 dark:bg-red-500/10 border-b border-red-200 dark:border-red-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="3" onchange="updateSSLBulkActions()">
|
||||
<i class="fas fa-lock text-red-600 dark:text-red-400 mr-2" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">api.example.com</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-refresh" id="ssl-bulk-refresh-form" class="hidden">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="certificate_ids" id="ssl-bulk-refresh-ids">
|
||||
</form>
|
||||
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/bulk-delete" id="ssl-bulk-delete-form" class="hidden">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="certificate_ids" id="ssl-bulk-delete-ids">
|
||||
</form>
|
||||
|
||||
<div id="ssl-no-results" class="hidden bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6 text-center">
|
||||
<i class="fas fa-search text-gray-300 dark:text-slate-600 mb-2" style="font-size: 28px;"></i>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">No certificates match the current filter</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Try a different search term or status filter.</p>
|
||||
</div>
|
||||
|
||||
<div id="ssl-list" class="space-y-3">
|
||||
{% for certificate in sslCertificates %}
|
||||
{% set validityText = 'Unknown' %}
|
||||
{% if certificate.days_remaining is not null %}
|
||||
{% if certificate.days_remaining < 0 %}
|
||||
{% set validityText = (certificate.days_remaining|abs) ~ ' days ago' %}
|
||||
{% else %}
|
||||
{% set validityText = certificate.days_remaining ~ ' days' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div
|
||||
class="ssl-cert-item bg-white dark:bg-slate-800 rounded-lg border-2 {{ certificate.card_border_class }} overflow-hidden"
|
||||
data-status="{{ certificate.status }}"
|
||||
data-search="{{ (certificate.display_target ~ ' ' ~ certificate.hostname ~ ' ' ~ (certificate.issuer_name|default('')) ~ ' ' ~ (certificate.issuer_organization|default('')) ~ ' ' ~ (certificate.subject_name|default('')) ~ ' ' ~ (certificate.subject_organization|default('')))|lower }}"
|
||||
>
|
||||
<div class="px-4 py-2 border-b {{ certificate.header_class }}">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{% if certificate.can_delete %}
|
||||
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ certificate.id }}" onchange="updateSSLBulkActions()">
|
||||
{% endif %}
|
||||
<i class="fas fa-lock {{ certificate.accent_class }}" style="font-size: 14px;"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ certificate.display_target }}
|
||||
{% if certificate.is_root %}
|
||||
<span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{{ certificate.is_trusted ? 'Trusted certificate' : 'Certificate issue detected' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded border {{ certificate.status_badge_class }}">
|
||||
<i class="fas {{ certificate.status_icon }} mr-1" style="font-size: 9px;"></i>
|
||||
{{ certificate.status_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Issued</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{{ certificate.valid_from ? certificate.valid_from|date('M d, Y H:i') : 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Expires</span>
|
||||
<span class="text-xs font-semibold {{ certificate.accent_class }}">
|
||||
{{ certificate.valid_to ? certificate.valid_to|date('M d, Y H:i') : 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Time left</span>
|
||||
<span class="text-xs font-semibold {{ certificate.accent_class }}">
|
||||
{% if certificate.days_remaining is not null and certificate.days_remaining < 0 %}
|
||||
Expired {{ validityText }}
|
||||
{% else %}
|
||||
{{ validityText }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.issuer_name|default('Unknown issuer') }}</p>
|
||||
{% if certificate.issuer_organization and certificate.issuer_organization != certificate.issuer_name %}
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Organization</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.issuer_organization }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{% if certificate.is_self_signed %}
|
||||
Self-signed
|
||||
{% elseif certificate.is_trusted %}
|
||||
Trusted
|
||||
{% else %}
|
||||
Not trusted
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if certificate.last_error %}
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error details</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-400">{{ certificate.last_error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1 max-h-28 overflow-auto pr-1">
|
||||
{% if certificate.san_list is not empty %}
|
||||
{% for san in certificate.san_list %}
|
||||
<div class="flex items-center text-xs">
|
||||
<i class="fas fa-check {{ certificate.accent_class }} mr-1.5" style="font-size: 9px;"></i>
|
||||
<span class="text-gray-900 dark:text-white font-mono break-all">{{ san }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">No SAN entries recorded.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Subject</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.subject_name|default('Unknown') }}</span>
|
||||
</div>
|
||||
{% if certificate.subject_organization and certificate.subject_organization != certificate.subject_name %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Subject org</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white text-right max-w-[60%] break-all">{{ certificate.subject_organization }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Signature</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.signature_algorithm|default('Unknown') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Key</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">
|
||||
{{ certificate.key_type|default('Unknown') }}{% if certificate.key_bits %} {{ certificate.key_bits }} bits{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-600 dark:text-slate-400">Version</span>
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-white">{{ certificate.certificate_version|default('Unknown') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="far fa-clock mr-1"></i>
|
||||
Last checked: {{ certificate.last_checked ? certificate.last_checked|date('M d, Y H:i') : 'Never' }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if certificate.is_root and not certificate.can_delete %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
|
||||
Check Now
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/refresh" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
|
||||
Check Now
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/{{ certificate.id }}/delete" class="inline" onsubmit="return confirm('Remove SSL monitoring for {{ certificate.display_target }}?');">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-1" style="font-size: 9px;"></i>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded border border-red-200 dark:border-red-800">
|
||||
<i class="fas fa-times-circle mr-1" style="font-size: 9px;"></i>
|
||||
EXPIRED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Sep 26, 2024</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-red-700 dark:text-red-400">Sep 30, 2025</span></div>
|
||||
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-red-600 dark:text-red-400">30 days (expired)</span></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||
<i class="fas fa-lock text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No SSL certificates monitored yet</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">
|
||||
Start with the root domain on port 443, or add specific hosts and custom HTTPS ports you want to monitor.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<input type="hidden" name="hostname" value="@">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-shield-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check Root SSL (443)
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/refresh-all" class="inline">
|
||||
{{ csrf_field()|raw }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||
Check All
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="openAddSslEndpointModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Add SSL Endpoint Modal #}
|
||||
<div id="addSslEndpointModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full" onclick="event.stopPropagation()">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/ssl/add">
|
||||
{{ csrf_field()|raw }}
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Add SSL Endpoint</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label for="ssl-endpoint-hostname" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Endpoint</label>
|
||||
<input type="text" id="ssl-endpoint-hostname" name="hostname" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="mail, mail:8443, or mail.{{ domain.domain_name }}:8443">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
Use <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">@</code> for root, <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">@:8443</code> for root on custom port, or <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">mail</code>, <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">mail:8443</code>, or full hostname under {{ domain.domain_name }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||
<div class="mt-1.5">
|
||||
<p class="text-xs text-gray-900 dark:text-white font-medium">Self-Signed</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">⚠️ Not Trusted</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
|
||||
<button type="button" onclick="closeAddSslEndpointModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary-dark">
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
||||
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error Details</p>
|
||||
<p class="text-xs text-red-700 dark:text-red-400">Certificate has expired</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">api.example.com</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 11:00</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">Page <span class="font-semibold text-gray-900 dark:text-white">1</span> of <span class="font-semibold text-gray-900 dark:text-white">1</span></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-left"></i></button>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-left"></i> Previous</button>
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">1</span>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed">Next <i class="fas fa-angle-right"></i></button>
|
||||
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getSelectedSSLIds() {
|
||||
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map((checkbox) => checkbox.value);
|
||||
}
|
||||
|
||||
function updateSSLBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.ssl-checkbox:checked');
|
||||
const selectedIds = getSelectedSSLIds();
|
||||
const bulkActions = document.getElementById('ssl-bulk-actions');
|
||||
const selectedCount = document.getElementById('ssl-selected-count');
|
||||
if (checkboxes.length > 0) {
|
||||
|
||||
if (!bulkActions || !selectedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = `${checkboxes.length} certificate(s) selected`;
|
||||
selectedCount.textContent = `${selectedIds.length} endpoint(s) selected`;
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
selectedCount.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearSSLSelection() {
|
||||
document.querySelectorAll('.ssl-checkbox').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.ssl-checkbox').forEach((checkbox) => {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
updateSSLBulkActions();
|
||||
}
|
||||
function getSelectedSSLIds() {
|
||||
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map(cb => cb.value);
|
||||
}
|
||||
function bulkCheckSSL() {
|
||||
const ids = getSelectedSSLIds();
|
||||
console.log('Checking SSL certificates:', ids);
|
||||
}
|
||||
function bulkDeleteSSL() {
|
||||
const ids = getSelectedSSLIds();
|
||||
if (confirm(`Delete ${ids.length} certificate(s)? This action cannot be undone.`)) {
|
||||
console.log('Deleting SSL certificates:', ids);
|
||||
|
||||
function submitBulkSslAction(action) {
|
||||
const selectedIds = getSelectedSSLIds();
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete' && !window.confirm(`Remove SSL monitoring for ${selectedIds.length} endpoint(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.getElementById(`ssl-bulk-${action}-ids`);
|
||||
const form = document.getElementById(`ssl-bulk-${action}-form`);
|
||||
|
||||
if (!input || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = selectedIds.join(',');
|
||||
form.submit();
|
||||
}
|
||||
function checkAllCertificates() {
|
||||
console.log('Checking all certificates...');
|
||||
}
|
||||
document.getElementById('ssl-search')?.addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
item.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
document.getElementById('ssl-filter')?.addEventListener('change', function(e) {
|
||||
const filter = e.target.value;
|
||||
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||
if (filter === 'all') {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
const status = item.dataset.status;
|
||||
item.style.display = status === filter ? '' : 'none';
|
||||
|
||||
function applySslFilters() {
|
||||
const searchInput = document.getElementById('ssl-search');
|
||||
const filterSelect = document.getElementById('ssl-filter');
|
||||
const noResults = document.getElementById('ssl-no-results');
|
||||
const items = document.querySelectorAll('.ssl-cert-item');
|
||||
|
||||
if (!searchInput || !filterSelect || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerm = searchInput.value.trim().toLowerCase();
|
||||
const filter = filterSelect.value;
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach((item) => {
|
||||
const haystack = item.dataset.search || '';
|
||||
const status = item.dataset.status || '';
|
||||
const matchesSearch = searchTerm === '' || haystack.includes(searchTerm);
|
||||
const matchesFilter = filter === 'all' || status === filter;
|
||||
const visible = matchesSearch && matchesFilter;
|
||||
|
||||
item.style.display = visible ? '' : 'none';
|
||||
if (visible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (noResults) {
|
||||
noResults.classList.toggle('hidden', visibleCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('ssl-search')?.addEventListener('input', applySslFilters);
|
||||
document.getElementById('ssl-filter')?.addEventListener('change', applySslFilters);
|
||||
|
||||
function openAddSslEndpointModal() {
|
||||
document.getElementById('addSslEndpointModal')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddSslEndpointModal() {
|
||||
const modal = document.getElementById('addSslEndpointModal');
|
||||
modal?.classList.add('hidden');
|
||||
modal?.querySelector('form')?.reset();
|
||||
}
|
||||
|
||||
document.getElementById('addSslEndpointModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAddSslEndpointModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -120,8 +120,19 @@
|
||||
<button onclick="switchTab('ssl')" id="tab-ssl" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||
<i class="fas fa-lock mr-1.5" style="font-size: 10px;"></i>
|
||||
SSL
|
||||
<span class="ml-1.5 px-1.5 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">2</span>
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">1</span>
|
||||
{% if not (domain.ssl_monitoring_enabled|default(0)) %}
|
||||
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="SSL monitoring disabled"></i>
|
||||
{% else %}
|
||||
{% if sslStats.total|default(0) > 0 %}
|
||||
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ sslStats.total }}</span>
|
||||
{% endif %}
|
||||
{% if sslStats.expiring|default(0) > 0 %}
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400 text-xs font-semibold rounded">{{ sslStats.expiring }}</span>
|
||||
{% endif %}
|
||||
{% if sslStats.issues|default(0) > 0 %}
|
||||
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">{{ sslStats.issues }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</button>
|
||||
<button onclick="switchTab('dns')" id="tab-dns" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||
<i class="fas fa-network-wired mr-1.5" style="font-size: 10px;"></i>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
||||
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
||||
{% set lastDnsCheckRun = settings.last_dns_check_run|default(null) %}
|
||||
{% set currentSslCheckInterval = settings.ssl_check_interval_hours|default('12') %}
|
||||
{% set lastSslCheckRun = settings.last_ssl_check_run|default(null) %}
|
||||
{% set currentVer = appSettings.app_version|default('0') %}
|
||||
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
||||
|
||||
@@ -792,6 +794,10 @@
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
|
||||
<code>php cron/check_dns.php</code>
|
||||
</div>
|
||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">SSL certificate check</p>
|
||||
<code>php cron/check_ssl.php</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -814,6 +820,12 @@
|
||||
<code>0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">SSL check (every {{ currentSslCheckInterval }}h)</p>
|
||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
||||
<code>0 */{{ currentSslCheckInterval }} * * * php {{ cronPath|replace({'check_domains.php': 'check_ssl.php'}) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the paths to match your server installation</p>
|
||||
</div>
|
||||
@@ -824,7 +836,7 @@
|
||||
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
|
||||
Last Cronjob Run
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
|
||||
@@ -869,6 +881,23 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">SSL</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_ssl.php</p>
|
||||
</div>
|
||||
{% if lastSslCheckRun %}
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-gray-700 dark:text-slate-300">{{ lastSslCheckRun|date('M d, Y H:i') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex items-center text-sm">
|
||||
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Never run</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -893,6 +922,13 @@
|
||||
</div>
|
||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">SSL Cron Log</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">SSL certificate monitoring logs</p>
|
||||
</div>
|
||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/ssl_cron.log</code>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
||||
|
||||
Reference in New Issue
Block a user