From 5916daa293b64956ed0f76215433789b8f19a359 Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Sun, 8 Mar 2026 21:12:09 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 50 +- app/Controllers/DomainController.php | 677 ++++++++++++++- app/Controllers/InstallerController.php | 7 +- app/Models/Setting.php | 2 +- app/Models/SslCertificate.php | 166 ++++ app/Services/NotificationService.php | 145 ++++ app/Services/SslService.php | 390 +++++++++ app/Views/domains/edit.twig | 16 +- app/Views/domains/tabs/overview.twig | 10 +- app/Views/domains/tabs/ssl.twig | 798 ++++++++++-------- app/Views/domains/view-detailed.twig | 15 +- app/Views/settings/index.twig | 38 +- cron/check_ssl.php | 374 ++++++++ .../migrations/000_initial_schema_v1.1.0.sql | 42 +- .../migrations/028_add_ssl_monitoring.sql | 68 ++ database/migrations/README.md | 5 + routes/web.php | 6 + 17 files changed, 2460 insertions(+), 349 deletions(-) create mode 100644 app/Models/SslCertificate.php create mode 100644 app/Services/SslService.php create mode 100644 cron/check_ssl.php create mode 100644 database/migrations/028_add_ssl_monitoring.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e38520..683682a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 84e9e8c..2120e19 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -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 + */ + 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) */ diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 46a96e8..3fa729d 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -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'; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index a61f4cd..fd5074c 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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'); } /** diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php new file mode 100644 index 0000000..2f73c04 --- /dev/null +++ b/app/Models/SslCertificate.php @@ -0,0 +1,166 @@ +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 + */ + 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(); + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 4259ea7..e404b03 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -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) */ diff --git a/app/Services/SslService.php b/app/Services/SslService.php new file mode 100644 index 0000000..a653b35 --- /dev/null +++ b/app/Services/SslService.php @@ -0,0 +1,390 @@ +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 + ); + } +} diff --git a/app/Views/domains/edit.twig b/app/Views/domains/edit.twig index bb4afa1..c43649a 100644 --- a/app/Views/domains/edit.twig +++ b/app/Views/domains/edit.twig @@ -152,15 +152,25 @@ + diff --git a/app/Views/domains/tabs/overview.twig b/app/Views/domains/tabs/overview.twig index 3209511..ade69bd 100644 --- a/app/Views/domains/tabs/overview.twig +++ b/app/Views/domains/tabs/overview.twig @@ -191,9 +191,15 @@ SSL - - Coming Soon + {% if domain.ssl_monitoring_enabled|default(0) %} + + Active + {% else %} + + Disabled + + {% endif %}
diff --git a/app/Views/domains/tabs/ssl.twig b/app/Views/domains/tabs/ssl.twig index e72d094..f5e6d71 100644 --- a/app/Views/domains/tabs/ssl.twig +++ b/app/Views/domains/tabs/ssl.twig @@ -1,379 +1,507 @@ - +{% 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

-

SSL certificate monitoring is coming soon. This is a design preview with sample data.

-
-
-
- - -
-
-
- - -
-
-
- - - -
-
- - - - - -
-
-
-
-

Total

-

3

-
- -
-
-
-
-
-

Valid

-

2

-
- -
-
-
-
-
-

Expiring Soon

-

1

-
- -
-
-
-
-
-

Invalid

-

1

-
- -
-
-
- - -
-
- Showing 1 to 3 of 3 certificate(s) -
-
- - -
-
- -
- -
-
-
-
- -
-

example.com Root

-

Certificate monitoring active

-
-
- - - Valid & Trusted - + {% if not sslMonitoringEnabled %} +
+
+ +
+

SSL monitoring is disabled

+

This domain is not checked by the SSL cron. Enable it in Edit to monitor the root certificate and tracked SSL endpoints.

+ + Enable SSL monitoring in Edit +
-
-
-
-
- -
-
Issued:Oct 05, 2025
-
Expires:Jan 08, 2026
-
Valid for:65 days
-
-
-
- -
-

Let's Encrypt Authority X3

-

✓ Trusted CA

-
-
-
-
-
- -
-
example.com
-
www.example.com
-
-
-
- -
-
Signature:SHA256-RSA
-
Key Size:2048 bits
-
Version:v3
-
-
-
+
+ {% else %} +
+
+
+

SSL certificate monitoring

+

+ Track the default root certificate and any monitored HTTPS endpoints, including custom ports. +

-
-
Last checked: Today 10:00
-
- -
+
+
+ {{ csrf_field()|raw }} + + +
+
+ {{ csrf_field()|raw }} + +
+
- -
-
-
-
- - -
-

mail.example.com

-

Certificate monitoring active

-
-
- - - Valid & Trusted - -
+ {% if sslStats.total > 0 %} +
+
+

Total

+

{{ sslStats.total }}

-
-
-
-
- -
-
Issued:Aug 01, 2025
-
Expires:Jul 28, 2026
-
Valid for:270 days
-
-
-
- -
-

DigiCert Inc.

-

✓ Trusted CA

-
-
-
-
-
- -
-
mail.example.com
-
smtp.example.com
-
imap.example.com
-
-
-
- -
-
Signature:SHA256-RSA
-
Key Size:2048 bits
-
Version:v3
-
-
-
-
-
-
Last checked: Today 10:00
-
- - -
+
+

Valid

+

{{ sslStats.valid }}

+
+
+

Expiring

+

{{ sslStats.expiring }}

+
+
+

Expired

+

{{ sslStats.expired }}

+
+
+

Invalid

+

{{ sslStats.invalid }}

+
+
+ +
+ + + Last checked: {{ domain.ssl_last_checked ? domain.ssl_last_checked|date('M d, Y H:i') : 'Never' }} + +
+ +
+
+ + +
+
+ +
+
+ + - -
-
-
-
- - -
-

api.example.com

-

Certificate monitoring active

+ + + + + + +
+ {% 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 %} +
+
+
+
+ {% if certificate.can_delete %} + + {% endif %} + +
+

+ {{ certificate.display_target }} + {% if certificate.is_root %} + Root + {% endif %} +

+

+ {{ certificate.is_trusted ? 'Trusted certificate' : 'Certificate issue detected' }} +

+
+
+ + + {{ certificate.status_label }} + +
+
+ +
+
+
+
+ +
+
+ Issued + + {{ certificate.valid_from ? certificate.valid_from|date('M d, Y H:i') : 'Unknown' }} + +
+
+ Expires + + {{ certificate.valid_to ? certificate.valid_to|date('M d, Y H:i') : 'Unknown' }} + +
+
+ Time left + + {% if certificate.days_remaining is not null and certificate.days_remaining < 0 %} + Expired {{ validityText }} + {% else %} + {{ validityText }} + {% endif %} + +
+
+
+ +
+ +
+

{{ certificate.issuer_name|default('Unknown issuer') }}

+ {% if certificate.issuer_organization and certificate.issuer_organization != certificate.issuer_name %} +
+ Organization + {{ certificate.issuer_organization }} +
+ {% endif %} +

+ {% if certificate.is_self_signed %} + Self-signed + {% elseif certificate.is_trusted %} + Trusted + {% else %} + Not trusted + {% endif %} +

+
+
+ + {% if certificate.last_error %} +
+

Error details

+

{{ certificate.last_error }}

+
+ {% endif %} +
+ +
+
+ +
+ {% if certificate.san_list is not empty %} + {% for san in certificate.san_list %} +
+ + {{ san }} +
+ {% endfor %} + {% else %} +

No SAN entries recorded.

+ {% endif %} +
+
+ +
+ +
+
+ Subject + {{ certificate.subject_name|default('Unknown') }} +
+ {% if certificate.subject_organization and certificate.subject_organization != certificate.subject_name %} +
+ Subject org + {{ certificate.subject_organization }} +
+ {% endif %} +
+ Signature + {{ certificate.signature_algorithm|default('Unknown') }} +
+
+ Key + + {{ certificate.key_type|default('Unknown') }}{% if certificate.key_bits %} {{ certificate.key_bits }} bits{% endif %} + +
+
+ Version + {{ certificate.certificate_version|default('Unknown') }} +
+
+
+
+
+ +
+
+ + Last checked: {{ certificate.last_checked ? certificate.last_checked|date('M d, Y H:i') : 'Never' }} +
+
+ {% if certificate.is_root and not certificate.can_delete %} +
+ {{ csrf_field()|raw }} + + +
+ {% else %} +
+ {{ csrf_field()|raw }} + +
+
+ {{ csrf_field()|raw }} + +
+ {% endif %}
- - - EXPIRED -
-
-
-
-
- -
-
Issued:Sep 26, 2024
-
Expires:Sep 30, 2025
-
Valid for:30 days (expired)
+ {% endfor %} +
+ {% else %} +
+ +

No SSL certificates monitored yet

+

+ Start with the root domain on port 443, or add specific hosts and custom HTTPS ports you want to monitor. +

+
+
+ {{ csrf_field()|raw }} + + +
+
+ {{ csrf_field()|raw }} + +
+ +
+
+ {% endif %} + + {# Add SSL Endpoint Modal #} + + {% endif %}
- -
-
Page 1 of 1
-
- - - 1 - - -
-
- diff --git a/app/Views/domains/view-detailed.twig b/app/Views/domains/view-detailed.twig index d89b42e..b79670f 100644 --- a/app/Views/domains/view-detailed.twig +++ b/app/Views/domains/view-detailed.twig @@ -120,8 +120,19 @@
+
+

SSL certificate check

+ php cron/check_ssl.php +
@@ -814,6 +820,12 @@ 0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}
+
+

SSL check (every {{ currentSslCheckInterval }}h)

+
+ 0 */{{ currentSslCheckInterval }} * * * php {{ cronPath|replace({'check_domains.php': 'check_ssl.php'}) }} +
+

Update the paths to match your server installation

@@ -824,7 +836,7 @@ Last Cronjob Run -
+

Domain / WHOIS

@@ -869,6 +881,23 @@
{% endif %}
+
+
+

SSL

+

check_ssl.php

+
+ {% if lastSslCheckRun %} +
+ + {{ lastSslCheckRun|date('M d, Y H:i') }} +
+ {% else %} +
+ + Never run +
+ {% endif %} +
@@ -893,6 +922,13 @@
logs/dns_cron.log
+
+
+

SSL Cron Log

+

SSL certificate monitoring logs

+
+ logs/ssl_cron.log +

TLD Import Log

diff --git a/cron/check_ssl.php b/cron/check_ssl.php new file mode 100644 index 0000000..1c7224b --- /dev/null +++ b/cron/check_ssl.php @@ -0,0 +1,374 @@ +#!/usr/bin/env php +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 + ); +} diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index 89bbf43..65c0b99 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -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)') diff --git a/database/migrations/028_add_ssl_monitoring.sql b/database/migrations/028_add_ssl_monitoring.sql new file mode 100644 index 0000000..7ef8b53 --- /dev/null +++ b/database/migrations/028_add_ssl_monitoring.sql @@ -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; diff --git a/database/migrations/README.md b/database/migrations/README.md index 80e6a57..b029bab 100644 --- a/database/migrations/README.md +++ b/database/migrations/README.md @@ -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` diff --git a/routes/web.php b/routes/web.php index a39a2fc..1f2ee36 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']);