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

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

View File

@@ -5,6 +5,42 @@ All notable changes to Domain Monitor will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.5] - 2026-03-08
### Added
- **Twig Templating** - All PHP views migrated to Twig; `twig/twig` added as dependency
- **Twig-Only Rendering** - Removed legacy PHP view fallbacks; ErrorHandler renders via Twig with safe escaped HTML fallback on failure
- **DNS Monitoring** - Track DNS record changes with DnsService (lookup, crt.sh discovery, Cloudflare detection, IP enrichment), DnsRecord model for snapshots and diffs, per-domain `dns_monitoring_enabled` toggle, manual and scheduled refresh via `cron/check_dns.php`
- **SSL Certificate Monitoring** - Track TLS certificates with SslService and SslCertificate model; validity, expiry, issuer, SAN list; per-domain `ssl_monitoring_enabled`; add/refresh/delete endpoints for root and custom hostnames/ports; `cron/check_ssl.php` for scheduled checks
- **Domain View Tabs** - New tabbed domain view: Overview, DNS, Billing, Notifications, SSL, WHOIS
- **Domain View Template Setting** - Choose `detailed` (tabbed) or `legacy` view per installation
- **Cron Staleness Warnings** - Settings shows warnings when domain/DNS/SSL cron runs are overdue
- **Timezone on Installer Routes** - App timezone now applied even on `/install` and `/install/update` when installed, so upgrade notifications use correct timezone
### Changed
- **2FA Flows** - Twig templates for setup, verify, backup-codes; TwoFactorService silences deprecated QR code warnings
- **Settings Page** - Timezone lists, notification preset selection, cron path display, cached update state, rollback availability
- **Avatar and 2FA Data in Controllers** - ProfileController and UserController pass avatar and two-factor info to Twig views
- **EmailHelper** - Safer subject handling
- **TldRegistry** - Search improvements
- **Domain Sorting** - Uses effective status (e.g. `expiring_soon`) for ordering
- **Discord Channel** - Null-safe field handling
### Technical
- **Core** - `Core\TwigService` for Twig rendering; Controller and Router always use Twig
- **Models** - `DnsRecord`, `SslCertificate`
- **Services** - `DnsService` (DNS lookup, crt.sh, Cloudflare detection), `SslService` (certificate fetch and parsing)
- **DomainController** - `performWhoisRefresh`/`performDnsRefresh`, `refreshWhois`, `refreshDns`, `refreshAll`; SSL endpoints: `addSslHost`, `refreshAllSsl`, `bulkRefreshSsl`, `bulkDeleteSsl`, `refreshSsl`, `deleteSsl`
- **NotificationService** - Notifications when DNS or SSL monitoring is toggled
- **domains table** - `dns_last_checked`, `dns_monitoring_enabled`, `crtsh_last_fetched`, `ssl_last_checked`, `ssl_monitoring_enabled`
- **settings** - `domain_view_template`, `dns_check_interval_hours`, `last_dns_check_run`, `ssl_check_interval_hours`, `last_ssl_check_run`
### Migrations
- `027_add_dns_monitoring.sql` - dns_records table, domain columns, DNS cron settings
- `028_add_ssl_monitoring.sql` - ssl_certificates table, domain columns, SSL cron settings, app version 1.1.5
---
## [1.1.4] - 2026-03-02
### Added
@@ -471,8 +507,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [x] Export functionality (CSV, JSON) (completed - v1.1.3, TLD Registry - v1.1.4)
- [x] Import domains from CSV/JSON (completed - v1.1.3, TLD Registry - v1.1.4)
- [ ] Domain transfer tracking
- [ ] DNS record monitoring
- [ ] SSL certificate monitoring
- [x] DNS record monitoring (completed - v1.1.5)
- [x] SSL certificate monitoring (completed - v1.1.5)
- [ ] Downtime monitoring
- [x] 2FA for login (completed - v1.1.0)
- [ ] Mobile app
@@ -491,6 +527,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version History
### 1.1.5 (2026-03-08)
- **Twig Templating** - All PHP views migrated to Twig; Twig-only rendering with safe error fallback
- **DNS Monitoring** - DnsService, DnsRecord model, crt.sh discovery, Cloudflare detection, per-domain toggle, `cron/check_dns.php`
- **SSL Certificate Monitoring** - SslService, SslCertificate model, add/refresh/delete endpoints, `cron/check_ssl.php`
- **Domain View Tabs** - Overview, DNS, Billing, Notifications, SSL, WHOIS; `domain_view_template` setting (detailed/legacy)
- **Cron Staleness Warnings** - Settings shows overdue warnings for domain/DNS/SSL cron runs
- **Timezone on Installer** - App timezone applied on `/install` and `/install/update` when installed
- **2FA/Settings** - Twig templates for 2FA, timezone lists, notification presets, cron path in Settings
- Migrations: `027_add_dns_monitoring.sql`, `028_add_ssl_monitoring.sql`
### 1.1.4 (2026-03-02)
- **TLD Registry Import & Export** - CSV/JSON export/import for TLD entries with WHOIS, RDAP, registry URL data
- **Manual TLD Creation** - Modal form to add custom TLDs with multi-level support (.co.uk, .co.za, .com.au)

View File

@@ -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)
*/

View File

@@ -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';

View File

@@ -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');
}
/**

View 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();
}
}

View File

@@ -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
View File

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

View File

@@ -161,6 +161,16 @@
<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 -->

View File

@@ -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">

View File

@@ -1,53 +1,123 @@
<!-- 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 class="space-y-3">
{% 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>
<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>
<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>
<!-- 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>
{% 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 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">
<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>
<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">
</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 Subdomain
Add Endpoint
</button>
</div>
</div>
</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">
{% 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="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>
<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">
<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="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">
<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>
@@ -57,323 +127,381 @@
</button>
</div>
</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>
<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>
<!-- 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>
<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>
<!-- 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 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">
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
{% 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-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>
<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 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 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 md:grid-cols-2 gap-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>
<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 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>
<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>
<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 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>
<!-- 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>
</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>
<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>
<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>
<!-- 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>
</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>
</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>
{% 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 has expired</p>
<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">
<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>
<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">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>
</div>
</div>
</div>
</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>
<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>
</div>
</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 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>
</form>
</div>
</div>
</div>
{% endif %}
</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>

View File

@@ -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>

View File

@@ -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>

374
cron/check_ssl.php Normal file
View File

@@ -0,0 +1,374 @@
#!/usr/bin/env php
<?php
/**
* SSL Certificate Monitoring Cron Job
*
* Checks tracked SSL endpoints for active domains with SSL monitoring enabled.
* If no root endpoint is tracked yet, the root domain falls back to port 443.
* Sends notifications when an SSL state changes or when the first monitored
* baseline already has an issue.
*
* Usage: php cron/check_ssl.php
* Recommended schedule: run at minute 0 every 12 hours.
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use App\Models\Domain;
use App\Models\NotificationChannel;
use App\Models\NotificationGroup;
use App\Models\NotificationLog;
use App\Models\Setting;
use App\Models\SslCertificate;
use App\Models\User;
use App\Services\Logger;
use App\Services\NotificationService;
use App\Services\SslService;
use Core\Database;
if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
new Database();
$domainModel = new Domain();
$sslModel = new SslCertificate();
$channelModel = new NotificationChannel();
$groupModel = new NotificationGroup();
$logModel = new NotificationLog();
$settingModel = new Setting();
$userModel = new User();
$sslService = new SslService();
$notificationService = new NotificationService();
$logger = new Logger('ssl-cron');
try {
$appSettings = $settingModel->getAppSettings();
date_default_timezone_set($appSettings['app_timezone']);
} catch (\Exception $e) {
date_default_timezone_set('UTC');
}
$logFile = __DIR__ . '/../logs/ssl_cron.log';
$startTime = microtime(true);
logMessage("=== Starting SSL check cron job ===");
$domains = $domainModel->where('is_active', 1);
$domains = array_values(array_filter($domains, static fn(array $domain): bool => ($domain['ssl_monitoring_enabled'] ?? 0) == 1));
logMessage("Found " . count($domains) . " domain(s) with SSL monitoring enabled");
$stats = [
'checked_domains' => 0,
'checked_hosts' => 0,
'issues_detected' => 0,
'notifications_sent' => 0,
'in_app_notifications' => 0,
'errors' => 0,
'status_changes' => 0,
];
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
foreach ($domains as $domain) {
$domainName = strtolower($domain['domain_name']);
$domainStart = microtime(true);
logMessage("Checking SSL: {$domainName}");
try {
$targets = $sslModel->getDistinctTargets($domain['id']);
$hasTrackedRootTarget = false;
foreach ($targets as $target) {
if ($target['hostname'] === $domainName) {
$hasTrackedRootTarget = true;
break;
}
}
if (!$hasTrackedRootTarget) {
$targets[] = [
'hostname' => $domainName,
'port' => 443,
];
}
usort($targets, static function (array $a, array $b): int {
$hostnameCompare = strcasecmp($a['hostname'], $b['hostname']);
if ($hostnameCompare !== 0) {
return $hostnameCompare;
}
return ((int)$a['port']) <=> ((int)$b['port']);
});
$domainIssues = 0;
$domainStatusChanges = 0;
foreach ($targets as $target) {
$hostname = $target['hostname'];
$port = (int)($target['port'] ?? 443);
$endpointLabel = $sslService->formatTargetLabel($hostname, $port);
$existing = $sslModel->findByDomainAndHost($domain['id'], $hostname, $port);
$previousStatus = $existing['status'] ?? null;
$snapshot = $sslService->fetchCertificateSnapshot($hostname, $port);
$sslModel->saveSnapshot($domain['id'], $hostname, $snapshot, $port);
$stats['checked_hosts']++;
$status = $snapshot['status'];
$isIssue = in_array($status, ['expiring', 'expired', 'invalid'], true);
if ($isIssue) {
$domainIssues++;
$stats['issues_detected']++;
}
$statusChanged = $previousStatus !== null && $previousStatus !== $status;
$firstIssueBaseline = $previousStatus === null && $isIssue;
logMessage(
" {$endpointLabel}: {$status}" .
($snapshot['valid_to'] ? " (valid_to: {$snapshot['valid_to']})" : '') .
($snapshot['last_error'] ? " (error: {$snapshot['last_error']})" : '')
);
if (!$statusChanged && !$firstIssueBaseline) {
continue;
}
$domainStatusChanges++;
$stats['status_changes']++;
sendExternalSslNotifications(
$domain,
$endpointLabel,
$status,
$previousStatus,
$snapshot,
$channelModel,
$logModel,
$notificationService,
$stats
);
sendInAppSslNotifications(
$domain,
$endpointLabel,
$status,
$previousStatus,
$isolationMode,
$userModel,
$groupModel,
$notificationService,
$stats
);
}
$domainModel->update($domain['id'], ['ssl_last_checked' => date('Y-m-d H:i:s')]);
$stats['checked_domains']++;
if ($domainStatusChanges === 0) {
logMessage(" -> No SSL status changes detected");
} else {
logMessage(" -> {$domainStatusChanges} SSL status change(s) detected");
}
if ($domainIssues > 0) {
logMessage(" -> {$domainIssues} issue host(s) currently detected");
}
logTimeSince($domainStart);
usleep(250000);
} catch (\Exception $e) {
logMessage(" x Error: " . $e->getMessage());
logTimeSince($domainStart);
$logger->error('SSL check failed', [
'domain' => $domainName,
'error' => $e->getMessage(),
]);
$stats['errors']++;
}
}
$settingModel->setValue('last_ssl_check_run', date('Y-m-d H:i:s'));
logMessage("\n=== SSL cron job completed ===");
logMessage("Domains checked: {$stats['checked_domains']}");
logMessage("Endpoints checked: {$stats['checked_hosts']}");
logMessage("Status changes: {$stats['status_changes']}");
logMessage("Issue endpoints: {$stats['issues_detected']}");
logMessage("External notifications: {$stats['notifications_sent']}");
logMessage("In-app notifications: {$stats['in_app_notifications']}");
logMessage("Errors: {$stats['errors']}");
logMessage("Execution time: " . formatElapsedTime(microtime(true) - $startTime));
logMessage("============================\n");
exit(0);
function sendExternalSslNotifications(
array $domain,
string $hostname,
string $status,
?string $previousStatus,
array $snapshot,
NotificationChannel $channelModel,
NotificationLog $logModel,
NotificationService $notificationService,
array &$stats
): void {
if (empty($domain['notification_group_id'])) {
return;
}
$channels = $channelModel->getActiveByGroupId($domain['notification_group_id']);
if (empty($channels)) {
return;
}
logMessage(" -> Sending SSL alerts to " . count($channels) . " channel(s)");
$results = $notificationService->sendSslStatusAlert(
$domain,
$channels,
$hostname,
$status,
$previousStatus,
$snapshot['valid_to'] ?? null,
$snapshot['last_error'] ?? null
);
foreach ($results as $result) {
$success = $result['success'];
if ($success) {
$stats['notifications_sent']++;
}
logMessage($success
? " + Sent to {$result['channel']}"
: " - Failed: {$result['channel']}"
);
$logModel->log(
$domain['id'],
'ssl_status_' . $status,
$result['channel'],
"SSL status for {$hostname}: {$status}",
$success,
$success ? null : 'Failed to send SSL status notification'
);
}
}
function sendInAppSslNotifications(
array $domain,
string $hostname,
string $status,
?string $previousStatus,
string $isolationMode,
User $userModel,
NotificationGroup $groupModel,
NotificationService $notificationService,
array &$stats
): void {
$usersToNotify = [];
if ($isolationMode === 'isolated') {
$userId = $domain['user_id'] ?? null;
if (!$userId && !empty($domain['notification_group_id'])) {
$group = $groupModel->find($domain['notification_group_id']);
$userId = $group['user_id'] ?? null;
}
if ($userId) {
$usersToNotify[] = $userId;
}
} else {
foreach ($userModel->where('is_active', 1) as $user) {
$usersToNotify[] = $user['id'];
}
}
if (empty($usersToNotify)) {
return;
}
$notifiedCount = 0;
foreach ($usersToNotify as $userId) {
try {
$notificationService->notifySslStatusChange(
$userId,
$domain['domain_name'],
$hostname,
$domain['id'],
$status,
$previousStatus
);
$notifiedCount++;
} catch (\Exception $e) {
logMessage(" ! In-app SSL notification failed for user {$userId}: " . $e->getMessage());
}
}
if ($notifiedCount > 0) {
logMessage(" -> Notified {$notifiedCount} user(s) in-app");
$stats['in_app_notifications'] += $notifiedCount;
}
}
function logMessage(string $message): void
{
global $logFile;
$timestamp = date('Y-m-d H:i:s');
$line = "[{$timestamp}] {$message}\n";
file_put_contents($logFile, $line, FILE_APPEND);
echo $line;
}
function logTimeSince(float $since): void
{
logMessage(" -> " . formatDuration(microtime(true) - $since));
}
function formatDuration(float $seconds): string
{
if ($seconds < 60) {
return sprintf('%.1fs', $seconds);
}
$minutes = (int) floor($seconds / 60);
$remaining = $seconds - ($minutes * 60);
return $minutes . 'm ' . sprintf('%.1fs', $remaining);
}
function formatElapsedTime(float $seconds): string
{
if ($seconds < 60) {
return sprintf('%.2f seconds', $seconds);
}
if ($seconds < 3600) {
$minutes = (int) floor($seconds / 60);
$remaining = $seconds - ($minutes * 60);
return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining);
}
$hours = (int) floor($seconds / 3600);
$minutes = (int) floor(($seconds - ($hours * 3600)) / 60);
$remaining = $seconds - ($hours * 3600) - ($minutes * 60);
return sprintf(
'%d hour%s %d minute%s %.2f seconds',
$hours,
$hours !== 1 ? 's' : '',
$minutes,
$minutes !== 1 ? 's' : '',
$remaining
);
}

View File

@@ -142,10 +142,14 @@ CREATE TABLE IF NOT EXISTS domains (
abuse_email VARCHAR(255),
last_checked TIMESTAMP NULL,
dns_last_checked TIMESTAMP NULL,
ssl_last_checked TIMESTAMP NULL,
crtsh_last_fetched DATETIME NULL DEFAULT NULL,
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
whois_data JSON,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
dns_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 1,
ssl_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 0,
user_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -368,6 +372,38 @@ CREATE TABLE IF NOT EXISTS dns_records (
INDEX idx_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- SSL certificates table for tracking monitored TLS endpoints
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_id INT NOT NULL,
hostname VARCHAR(255) NOT NULL,
port INT NOT NULL DEFAULT 443,
status ENUM('valid', 'expiring', 'expired', 'invalid') NOT NULL DEFAULT 'invalid',
is_trusted TINYINT(1) NOT NULL DEFAULT 0,
is_self_signed TINYINT(1) NOT NULL DEFAULT 0,
valid_from DATETIME NULL,
valid_to DATETIME NULL,
days_remaining INT NULL,
issuer_name VARCHAR(255) NULL,
subject_name VARCHAR(255) NULL,
serial_number VARCHAR(255) NULL,
signature_algorithm VARCHAR(100) NULL,
key_bits INT NULL,
key_type VARCHAR(20) NULL,
certificate_version VARCHAR(20) NULL,
san_list JSON NULL,
last_checked DATETIME NULL,
last_error TEXT NULL,
raw_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
UNIQUE KEY uniq_domain_host_port (domain_id, hostname, port),
INDEX idx_ssl_domain_id (domain_id),
INDEX idx_ssl_status (status),
INDEX idx_ssl_valid_to (valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- SYSTEM SETTINGS
-- =====================================================
@@ -389,7 +425,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
('app_name', 'Domain Monitor', 'string', 'Application name'),
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
('app_timezone', 'UTC', 'string', 'Application timezone'),
('app_version', '1.1.4', 'string', 'Application version number'),
('app_version', '1.1.5', 'string', 'Application version number'),
-- Email settings
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),
@@ -431,6 +467,10 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran'),
-- SSL monitoring settings
('ssl_check_interval_hours', '12', 'string', 'SSL certificate check interval in hours'),
('last_ssl_check_run', NULL, 'datetime', 'Last time SSL cron job ran'),
-- Update system settings
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')

View File

@@ -0,0 +1,68 @@
-- SSL Monitoring - Add ssl_certificates table for tracking monitored TLS endpoints
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_id INT NOT NULL,
hostname VARCHAR(255) NOT NULL,
port INT NOT NULL DEFAULT 443,
status ENUM('valid', 'expiring', 'expired', 'invalid') NOT NULL DEFAULT 'invalid',
is_trusted TINYINT(1) NOT NULL DEFAULT 0,
is_self_signed TINYINT(1) NOT NULL DEFAULT 0,
valid_from DATETIME NULL,
valid_to DATETIME NULL,
days_remaining INT NULL,
issuer_name VARCHAR(255) NULL,
subject_name VARCHAR(255) NULL,
serial_number VARCHAR(255) NULL,
signature_algorithm VARCHAR(100) NULL,
key_bits INT NULL,
key_type VARCHAR(20) NULL,
certificate_version VARCHAR(20) NULL,
san_list JSON NULL,
last_checked DATETIME NULL,
last_error TEXT NULL,
raw_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
UNIQUE KEY uniq_domain_host_port (domain_id, hostname, port),
INDEX idx_ssl_domain_id (domain_id),
INDEX idx_ssl_status (status),
INDEX idx_ssl_valid_to (valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- SSL Monitoring - Add per-domain toggle, timestamps, and cron settings
ALTER TABLE domains
ADD COLUMN ssl_last_checked TIMESTAMP NULL AFTER dns_last_checked,
ADD COLUMN ssl_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=SSL monitoring active, 0=disabled' AFTER dns_monitoring_enabled;
-- Preserve existing monitored SSL domains when upgrading
UPDATE domains d
SET d.ssl_monitoring_enabled = 1
WHERE EXISTS (
SELECT 1
FROM ssl_certificates s
WHERE s.domain_id = d.id
);
-- Carry forward the latest stored SSL check time
UPDATE domains d
JOIN (
SELECT domain_id, MAX(last_checked) AS max_checked
FROM ssl_certificates
GROUP BY domain_id
) s ON s.domain_id = d.id
SET d.ssl_last_checked = s.max_checked;
-- Add SSL monitoring cron settings
INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
('ssl_check_interval_hours', '12', 'string', 'SSL certificate check interval in hours'),
('last_ssl_check_run', NULL, 'datetime', 'Last time SSL cron job ran')
ON DUPLICATE KEY UPDATE setting_key=setting_key;
-- Update application version to 1.1.5
UPDATE settings
SET setting_value = '1.1.5'
WHERE setting_key = 'app_version';
INSERT INTO migrations (migration) VALUES ('028_add_ssl_monitoring.sql')
ON DUPLICATE KEY UPDATE migration=migration;

View File

@@ -35,6 +35,11 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
- `021_add_avatar_field.sql` - User avatar field
- `022_add_pushover_channel_type.sql` - Pushover notification channel support
- `023_update_app_version_to_1.1.1.sql` - Update version to 1.1.1
- `024_add_status_notifications_v1.1.2.sql` - Status notification triggers
- `025_add_update_system_v1.1.3.sql` - In-app update system
- `026_update_app_version_v1.1.4.sql` - Update version to 1.1.4
- `027_add_dns_monitoring.sql` - DNS monitoring tables and settings
- `028_add_ssl_monitoring.sql` - SSL certificate monitoring table, per-domain toggles, timestamps, and cron settings
**Upgrade via:** Web updater at `/install/update`

View File

@@ -86,6 +86,12 @@ $router->post('/domains/{id}/update', [DomainController::class, 'update']);
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
$router->post('/domains/{id}/ssl/add', [DomainController::class, 'addSslHost']);
$router->post('/domains/{id}/ssl/refresh-all', [DomainController::class, 'refreshAllSsl']);
$router->post('/domains/{id}/ssl/bulk-refresh', [DomainController::class, 'bulkRefreshSsl']);
$router->post('/domains/{id}/ssl/bulk-delete', [DomainController::class, 'bulkDeleteSsl']);
$router->post('/domains/{id}/ssl/{certificateId}/refresh', [DomainController::class, 'refreshSsl']);
$router->post('/domains/{id}/ssl/{certificateId}/delete', [DomainController::class, 'deleteSsl']);
$router->post('/domains/{id}/refresh-all', [DomainController::class, 'refreshAll']);
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);