diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index f939b41..84e9e8c 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -608,6 +608,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; $tagsInput = trim($_POST['tags'] ?? ''); $manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null; @@ -642,6 +643,7 @@ class DomainController extends Controller $this->domainModel->update($id, [ 'notification_group_id' => $groupId, 'is_active' => $isActive, + 'dns_monitoring_enabled' => $dnsMonitoringEnabled, 'expiration_date' => $manualExpirationDate ]); @@ -663,6 +665,24 @@ class DomainController extends Controller $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) { + $notificationService = new \App\Services\NotificationService(); + + if ($dnsMonitoringEnabled) { + $message = "🟒 DNS monitoring has been ENABLED for {$domain['domain_name']}\n\n" . + "DNS records will be checked for changes and you'll receive alerts when they change."; + $subject = "βœ… DNS Monitoring Enabled: {$domain['domain_name']}"; + } else { + $message = "πŸ”΄ DNS monitoring has been DISABLED for {$domain['domain_name']}\n\n" . + "DNS records will no longer be checked. You will not receive DNS change alerts."; + $subject = "⏸️ DNS Monitoring Disabled: {$domain['domain_name']}"; + } + + $notificationService->sendToGroup($groupId, $subject, $message); + } // Also send notification if group changed and monitoring is active if (!$statusChanged && $isActive && $oldGroupId != $groupId) { @@ -697,50 +717,26 @@ class DomainController extends Controller $this->redirect('/domains/' . $id); } - public function refresh($params = []) + /** + * Perform WHOIS lookup and persist results. + * @return string|null Status message on success, null on failure. + */ + private function performWhoisRefresh(int $id, array $domain): ?string { - $id = $params['id'] ?? 0; - $domain = $this->checkDomainAccess($id); - - if (!$domain) { - $_SESSION['error'] = 'Domain not found'; - $this->redirect('/domains'); - return; - } - - // Log domain refresh start $logger = new \App\Services\Logger(); - $logger->info('Domain refresh started', [ - 'domain_id' => $id, - 'domain_name' => $domain['domain_name'], - 'user_id' => \Core\Auth::id(), - 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' - ]); - // Get fresh WHOIS information $whoisData = $this->whoisService->getDomainInfo($domain['domain_name']); if (!$whoisData) { - $logger->error('Domain refresh failed - WHOIS data not retrieved', [ + $logger->error('WHOIS refresh failed', [ 'domain_id' => $id, 'domain_name' => $domain['domain_name'], 'user_id' => \Core\Auth::id() ]); - - $_SESSION['error'] = 'Could not retrieve WHOIS information'; - // Check if we came from view page - $referer = $_SERVER['HTTP_REFERER'] ?? ''; - if (strpos($referer, '/domains/' . $id) !== false) { - $this->redirect('/domains/' . $id); - } else { - $this->redirect('/domains'); - } - return; + return null; } - // Use WHOIS expiration date if available, otherwise preserve manual expiration date $expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date']; - $status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData); $this->domainModel->update($id, [ @@ -754,8 +750,7 @@ class DomainController extends Controller 'whois_data' => json_encode($whoisData) ]); - // Log successful domain refresh - $logger->info('Domain refresh completed successfully', [ + $logger->info('WHOIS refresh completed', [ 'domain_id' => $id, 'domain_name' => $domain['domain_name'], 'new_status' => $status, @@ -764,19 +759,129 @@ class DomainController extends Controller 'user_id' => \Core\Auth::id() ]); - $_SESSION['success'] = 'Domain information refreshed'; - - // Check if we came from view page or list page + return 'WHOIS updated'; + } + + /** + * Perform DNS lookup and persist results. + * @return string Status message (always returns, even on zero records). + */ + private function performDnsRefresh(int $id, array $domain): string + { + $logger = new \App\Services\Logger('dns'); + + $dnsService = new \App\Services\DnsService(); + $dnsModel = new \App\Models\DnsRecord(); + + // Feed previously known hosts so manual refresh doesn't lose crt.sh-discovered subdomains + $existingHosts = $dnsModel->getDistinctHosts($id); + $records = $dnsService->lookup($domain['domain_name'], $existingHosts); + $totalRecords = array_sum(array_map('count', $records)); + + if ($totalRecords === 0) { + $logger->warning('DNS refresh returned no records', [ + 'domain_name' => $domain['domain_name'], + ]); + return 'DNS: no records found'; + } + + // Enrich A/AAAA records with IP details (PTR, ASN, geo) and store in raw_data + $ips = []; + foreach (['A', 'AAAA'] as $type) { + if (!empty($records[$type])) { + foreach ($records[$type] as $r) { + if (!empty($r['value'])) { + $ips[] = $r['value']; + } + } + } + } + if (!empty($ips)) { + $ipDetails = $dnsService->lookupIpDetails($ips); + foreach (['A', 'AAAA'] as $type) { + if (!empty($records[$type])) { + foreach ($records[$type] as &$rec) { + if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) { + $rec['raw']['_ip_info'] = $ipDetails[$rec['value']]; + } + } + unset($rec); + } + } + } + + $stats = $dnsModel->saveSnapshot($id, $records); + $this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]); + + $logger->info('DNS refresh completed', [ + 'domain_name' => $domain['domain_name'], + 'total' => $totalRecords, + 'added' => $stats['added'], + 'updated' => $stats['updated'], + 'removed' => $stats['removed'], + ]); + + return "DNS updated ({$totalRecords} records)"; + } + + /** + * Redirect back to the originating page (domain view or list). + */ + private function redirectBackToDomain(int $id, string $hash = ''): void + { $referer = $_SERVER['HTTP_REFERER'] ?? ''; if (strpos($referer, '/domains/' . $id) !== false) { - // Came from view page, go back to view page - $this->redirect('/domains/' . $id); + $this->redirect('/domains/' . $id . $hash); } else { - // Came from list page, stay on list page $this->redirect('/domains'); } } + public function refreshWhois($params = []) + { + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $result = $this->performWhoisRefresh($id, $domain); + + if ($result === null) { + $_SESSION['error'] = 'Could not retrieve WHOIS information'; + } else { + $_SESSION['success'] = 'WHOIS information refreshed'; + } + + $this->redirectBackToDomain($id); + } + + public function refreshAll($params = []) + { + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $messages = []; + $messages[] = $this->performWhoisRefresh($id, $domain) ?? 'WHOIS failed'; + if (!empty($domain['dns_monitoring_enabled'])) { + $messages[] = $this->performDnsRefresh($id, $domain); + } else { + $messages[] = 'DNS skipped (monitoring disabled)'; + } + + $_SESSION['success'] = 'Domain refreshed: ' . implode(', ', $messages); + $this->redirectBackToDomain($id); + } + public function delete($params = []) { $id = $params['id'] ?? 0; @@ -842,11 +947,39 @@ class DomainController extends Controller $availableTags = $tagModel->getAllWithUsage(); } - $this->view('domains/view', [ + // Get DNS records for the DNS tab + $dnsModel = new \App\Models\DnsRecord(); + $dnsRecords = $dnsModel->getByDomainGrouped($id); + $dnsRecordCount = $dnsModel->countByDomain($id); + $dnsHasCloudflare = $dnsModel->hasCloudflare($id); + + // Extract cached IP details (PTR, ASN, geo) from stored raw_data + $dnsIpDetails = []; + foreach (['A', 'AAAA'] as $type) { + if (!empty($dnsRecords[$type])) { + foreach ($dnsRecords[$type] as $r) { + if (!empty($r['raw_data']) && !empty($r['value'])) { + $raw = json_decode($r['raw_data'], true); + if (!empty($raw['_ip_info'])) { + $dnsIpDetails[$r['value']] = $raw['_ip_info']; + } + } + } + } + } + + $viewTemplate = $settingModel->getValue('domain_view_template', 'detailed'); + $templateName = $viewTemplate === 'detailed' ? 'domains/view-detailed' : 'domains/view'; + + $this->view($templateName, [ 'domain' => $formattedDomain, 'whoisData' => $whoisData, 'logs' => $logs, 'availableTags' => $availableTags, + 'dnsRecords' => $dnsRecords, + 'dnsRecordCount' => $dnsRecordCount, + 'dnsHasCloudflare' => $dnsHasCloudflare, + 'dnsIpDetails' => $dnsIpDetails, 'title' => $domain['domain_name'] ]); } @@ -1279,9 +1412,14 @@ class DomainController extends Controller // Validate notes length $lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes'); + + $settingModel = new \App\Models\Setting(); + $viewTemplate = $settingModel->getValue('domain_view_template', 'detailed'); + $redirect = '/domains/' . $id . ($viewTemplate === 'detailed' ? '#overview' : ''); + if ($lengthError) { $_SESSION['error'] = $lengthError; - $this->redirect('/domains/' . $id); + $this->redirect($redirect); return; } @@ -1290,7 +1428,7 @@ class DomainController extends Controller ]); $_SESSION['success'] = 'Notes updated successfully'; - $this->redirect('/domains/' . $id); + $this->redirect($redirect); } public function bulkAddTags() @@ -1615,6 +1753,32 @@ class DomainController extends Controller $this->redirect('/domains'); } + // ======================================== + // DNS MONITORING + // ======================================== + + public function refreshDns($params = []) + { + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $result = $this->performDnsRefresh($id, $domain); + + if (strpos($result, 'no records') !== false) { + $_SESSION['warning'] = 'No DNS records found for this domain'; + } else { + $_SESSION['success'] = $result; + } + + $this->redirectBackToDomain($id, '#dns'); + } + /** * Get tags for specific domains (API endpoint) */ diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 64fa04c..46a96e8 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -57,6 +57,7 @@ class InstallerController extends Controller '024_add_status_notifications_v1.1.2.sql', '025_add_update_system_v1.1.3.sql', '026_update_app_version_v1.1.4.sql', + '027_add_dns_monitoring.sql', ]; try { @@ -200,6 +201,7 @@ class InstallerController extends Controller '024_add_status_notifications_v1.1.2.sql', '025_add_update_system_v1.1.3.sql', '026_update_app_version_v1.1.4.sql', + '027_add_dns_monitoring.sql', ]; } @@ -423,6 +425,7 @@ class InstallerController extends Controller '024_add_status_notifications_v1.1.2.sql', '025_add_update_system_v1.1.3.sql', '026_update_app_version_v1.1.4.sql', + '027_add_dns_monitoring.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 23be609..bb53dc1 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -136,6 +136,21 @@ class SettingsController extends Controller // Rollback availability $rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']); + // Cron staleness: show warning if last run is overdue + $intervalHours = (int)($settings['check_interval_hours'] ?? 24); + $domainStaleThreshold = $intervalHours * 1.5; // e.g. 36h for 24h interval + $dnsStaleThreshold = 24; // DNS cron runs every 6h, 24h = overdue + $domainCronStale = false; + $dnsCronStale = false; + if (!empty($settings['last_check_run'])) { + $hoursSince = (time() - strtotime($settings['last_check_run'])) / 3600; + $domainCronStale = $hoursSince > $domainStaleThreshold; + } + if (!empty($settings['last_dns_check_run'])) { + $hoursSince = (time() - strtotime($settings['last_dns_check_run'])) / 3600; + $dnsCronStale = $hoursSince > $dnsStaleThreshold; + } + $this->view('settings/index', [ 'settings' => $settings, 'appSettings' => $appSettings, @@ -154,6 +169,8 @@ class SettingsController extends Controller 'cachedUpdateAvailable' => $cachedUpdateAvailable, 'cachedUpdateData' => $cachedUpdateData, 'rollbackAvailable' => $rollbackAvailable, + 'domainCronStale' => $domainCronStale, + 'dnsCronStale' => $dnsCronStale, 'title' => 'Settings' ]); } @@ -316,9 +333,16 @@ class SettingsController extends Controller // Update registration settings $registrationEnabled = isset($_POST['registration_enabled']) ? '1' : '0'; $requireEmailVerification = isset($_POST['require_email_verification']) ? '1' : '0'; - + $this->settingModel->setValue('registration_enabled', $registrationEnabled); $this->settingModel->setValue('require_email_verification', $requireEmailVerification); + + // Update domain view template + $viewTemplate = trim($_POST['domain_view_template'] ?? 'detailed'); + if (!in_array($viewTemplate, ['legacy', 'detailed'])) { + $viewTemplate = 'detailed'; + } + $this->settingModel->setValue('domain_view_template', $viewTemplate); $_SESSION['success'] = 'Application settings updated successfully'; $this->redirect('/settings#app'); diff --git a/app/Controllers/TwoFactorController.php b/app/Controllers/TwoFactorController.php index c7446fc..b2df9b2 100644 --- a/app/Controllers/TwoFactorController.php +++ b/app/Controllers/TwoFactorController.php @@ -205,6 +205,7 @@ class TwoFactorController extends Controller $this->view('2fa/verify', [ 'user' => $user, + 'canSendEmailCode' => !empty($user['email_verified']), 'title' => 'Two-Factor Authentication' ]); } diff --git a/app/Helpers/EmailHelper.php b/app/Helpers/EmailHelper.php index bfc860e..fb8fbd8 100644 --- a/app/Helpers/EmailHelper.php +++ b/app/Helpers/EmailHelper.php @@ -511,7 +511,10 @@ class EmailHelper public static function getEmailSubject(array $data): string { if (isset($data['domain'])) { - $daysLeft = $data['days_left']; + $daysLeft = $data['days_left'] ?? null; + if ($daysLeft === null) { + return "⚠️ Domain Expiration Alert: {$data['domain']}"; + } if ($daysLeft <= 0) { return "🚨 URGENT: Domain {$data['domain']} has EXPIRED"; } diff --git a/app/Models/DnsRecord.php b/app/Models/DnsRecord.php new file mode 100644 index 0000000..231e863 --- /dev/null +++ b/app/Models/DnsRecord.php @@ -0,0 +1,216 @@ +db->prepare( + "SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC, priority ASC" + ); + $stmt->execute([$domainId]); + $rows = $stmt->fetchAll(); + + $grouped = []; + foreach ($rows as $row) { + $type = $row['record_type']; + if (!isset($grouped[$type])) { + $grouped[$type] = []; + } + $grouped[$type][] = $row; + } + + return $grouped; + } + + /** + * Get all DNS records for a domain (flat list) + */ + public function getByDomain(int $domainId): array + { + $stmt = $this->db->prepare( + "SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC" + ); + $stmt->execute([$domainId]); + return $stmt->fetchAll(); + } + + /** + * Count DNS records for a domain + */ + public function countByDomain(int $domainId): int + { + $stmt = $this->db->prepare("SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND record_type != 'SOA'"); + $stmt->execute([$domainId]); + return (int)$stmt->fetchColumn(); + } + + /** + * Get distinct non-root host labels for a domain. + * Used to preserve previously discovered subdomains across refreshes. + */ + public function getDistinctHosts(int $domainId): array + { + $stmt = $this->db->prepare( + "SELECT DISTINCT host FROM dns_records WHERE domain_id = ? AND host != '@'" + ); + $stmt->execute([$domainId]); + return array_column($stmt->fetchAll(), 'host'); + } + + /** + * Check if a domain has any Cloudflare-proxied records + */ + public function hasCloudflare(int $domainId): bool + { + $stmt = $this->db->prepare( + "SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND is_cloudflare = 1" + ); + $stmt->execute([$domainId]); + return (int)$stmt->fetchColumn() > 0; + } + + /** + * Save a snapshot of DNS records for a domain. + * Updates existing records, inserts new ones, removes stale ones. + * @return array{added: int, updated: int, removed: int} + */ + public function saveSnapshot(int $domainId, array $groupedRecords): array + { + $stats = ['added' => 0, 'updated' => 0, 'removed' => 0]; + $now = date('Y-m-d H:i:s'); + $seenIds = []; + + foreach ($groupedRecords as $type => $records) { + foreach ($records as $record) { + $host = $record['host'] ?? '@'; + $value = $record['value'] ?? ''; + $ttl = $record['ttl'] ?? null; + $priority = $record['priority'] ?? null; + $isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0; + $rawData = isset($record['raw']) ? json_encode($record['raw']) : null; + + $existing = $this->findExisting($domainId, $type, $host, $value, $priority); + + if ($existing) { + $this->db->prepare( + "UPDATE dns_records SET ttl = ?, is_cloudflare = ?, raw_data = ?, last_seen_at = ?, updated_at = ? WHERE id = ?" + )->execute([$ttl, $isCloudflare, $rawData, $now, $now, $existing['id']]); + $seenIds[] = $existing['id']; + $stats['updated']++; + } else { + $stmt = $this->db->prepare( + "INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]); + $seenIds[] = (int)$this->db->lastInsertId(); + $stats['added']++; + } + } + } + + // Remove records that no longer exist + if (!empty($seenIds)) { + $placeholders = implode(',', array_fill(0, count($seenIds), '?')); + $deleteStmt = $this->db->prepare( + "DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})" + ); + $deleteStmt->execute(array_merge([$domainId], $seenIds)); + $stats['removed'] = $deleteStmt->rowCount(); + } else { + // No records found at all β€” remove everything + $deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?"); + $deleteStmt->execute([$domainId]); + $stats['removed'] = $deleteStmt->rowCount(); + } + + return $stats; + } + + /** + * Find an existing record by its natural key + */ + private function findExisting(int $domainId, string $type, string $host, string $value, ?int $priority): ?array + { + if ($priority !== null) { + $stmt = $this->db->prepare( + "SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority = ? LIMIT 1" + ); + $stmt->execute([$domainId, $type, $host, $value, $priority]); + } else { + $stmt = $this->db->prepare( + "SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority IS NULL LIMIT 1" + ); + $stmt->execute([$domainId, $type, $host, $value]); + } + + $result = $stmt->fetch(); + return $result ?: null; + } + + /** + * Delete all DNS records for a domain + */ + public function deleteByDomain(int $domainId): bool + { + $stmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?"); + return $stmt->execute([$domainId]); + } + + /** + * Get record counts grouped by type for a domain + */ + public function getCountsByType(int $domainId): array + { + $stmt = $this->db->prepare( + "SELECT record_type, COUNT(*) as count FROM dns_records WHERE domain_id = ? GROUP BY record_type ORDER BY record_type" + ); + $stmt->execute([$domainId]); + $rows = $stmt->fetchAll(); + + $counts = []; + foreach ($rows as $row) { + $counts[$row['record_type']] = (int)$row['count']; + } + return $counts; + } + + /** + * Get previous snapshot as grouped records (for diff comparison). + * Reconstructs the same format that DnsService::lookup() returns. + */ + public function getPreviousSnapshot(int $domainId): array + { + $records = $this->getByDomainGrouped($domainId); + $grouped = []; + + foreach ($records as $type => $rows) { + $grouped[$type] = []; + foreach ($rows as $row) { + $entry = [ + 'host' => $row['host'], + 'value' => $row['value'], + 'ttl' => $row['ttl'] ? (int)$row['ttl'] : null, + ]; + if ($row['priority'] !== null) { + $entry['priority'] = (int)$row['priority']; + } + if ($row['is_cloudflare']) { + $entry['is_cloudflare'] = true; + } + $grouped[$type][] = $entry; + } + } + + return $grouped; + } +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php index 057b938..585aa41 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -259,6 +259,24 @@ class Domain extends Model return $stats; } + /** + * Get effective status for sorting (active + daysLeft within threshold = expiring_soon) + */ + private static function getEffectiveStatusForSort(array $domain, int $expiringThreshold): string + { + $status = $domain['status'] ?? ''; + if ($status === 'inactive') { + return 'inactive'; + } + if ($status === 'active' && !empty($domain['expiration_date'])) { + $daysLeft = (int) floor((strtotime($domain['expiration_date']) - time()) / 86400); + if ($daysLeft <= $expiringThreshold && $daysLeft >= 0) { + return 'expiring_soon'; + } + } + return $status ?: 'error'; + } + /** * Get filtered, sorted, and paginated domains */ @@ -332,10 +350,33 @@ class Domain extends Model $totalDomains = count($domains); // Apply sorting - usort($domains, function($a, $b) use ($sortBy, $sortOrder) { + usort($domains, function($a, $b) use ($sortBy, $sortOrder, $expiringThreshold) { $aVal = $a[$sortBy] ?? ''; $bVal = $b[$sortBy] ?? ''; - + + // When sorting by status: use effective status (active + daysLeft<=threshold = expiring_soon) and logical priority order + if ($sortBy === 'status') { + $aVal = self::getEffectiveStatusForSort($a, $expiringThreshold); + $bVal = self::getEffectiveStatusForSort($b, $expiringThreshold); + $priority = [ + 'expired' => 1, + 'redemption_period' => 2, + 'pending_delete' => 3, + 'expiring_soon' => 4, + 'active' => 5, + 'available' => 6, + 'error' => 7, + 'inactive' => 8, + ]; + $aOrder = $priority[$aVal] ?? 99; + $bOrder = $priority[$bVal] ?? 99; + $comparison = $aOrder <=> $bOrder; + if ($comparison === 0) { + $comparison = strcasecmp($a['domain_name'] ?? '', $b['domain_name'] ?? ''); + } + return $sortOrder === 'desc' ? -$comparison : $comparison; + } + $comparison = strcasecmp($aVal, $bVal); return $sortOrder === 'desc' ? -$comparison : $comparison; }); diff --git a/app/Models/TldRegistry.php b/app/Models/TldRegistry.php index 44234cf..eccc21f 100644 --- a/app/Models/TldRegistry.php +++ b/app/Models/TldRegistry.php @@ -114,13 +114,14 @@ class TldRegistry extends Model */ public function search(string $search): array { - $search = '%' . $search . '%'; + $tldNorm = strtolower(trim(trim($search), '.')); + $suffix = '%.' . $tldNorm; $sql = "SELECT * FROM tld_registry - WHERE (LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?)) + WHERE (LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?) ORDER BY tld ASC"; $stmt = $this->db->prepare($sql); - $stmt->execute([$search, $search, $search]); + $stmt->execute([$tldNorm, $suffix]); return $stmt->fetchAll(); } @@ -144,11 +145,12 @@ class TldRegistry extends Model $whereConditions = []; $params = []; - // Search filter + // Search filter: exact match OR TLDs ending with .{search} (e.g. "za" -> .za, .co.za, .net.za) if (!empty($search)) { - $searchParam = '%' . $search . '%'; - $whereConditions[] = "(LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))"; - $params = array_merge($params, [$searchParam, $searchParam, $searchParam]); + $tldNorm = strtolower(trim(trim($search), '.')); + $suffix = '%.' . $tldNorm; + $whereConditions[] = "(LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)"; + $params = array_merge($params, [$tldNorm, $suffix]); } // Status filter diff --git a/app/Services/Channels/DiscordChannel.php b/app/Services/Channels/DiscordChannel.php index ce0bf74..53d8b16 100644 --- a/app/Services/Channels/DiscordChannel.php +++ b/app/Services/Channels/DiscordChannel.php @@ -73,12 +73,12 @@ class DiscordChannel implements NotificationChannelInterface ], [ 'name' => 'Days Left', - 'value' => $data['days_left'], + 'value' => (string) ($data['days_left'] ?? 'N/A'), 'inline' => true ], [ 'name' => 'Expiration Date', - 'value' => $data['expiration_date'], + 'value' => $data['expiration_date'] ?? 'N/A', 'inline' => true ] ]; diff --git a/app/Services/DnsService.php b/app/Services/DnsService.php new file mode 100644 index 0000000..9b8fd2b --- /dev/null +++ b/app/Services/DnsService.php @@ -0,0 +1,766 @@ + 'A', + DNS_AAAA => 'AAAA', + DNS_MX => 'MX', + DNS_TXT => 'TXT', + DNS_NS => 'NS', + DNS_CNAME => 'CNAME', + DNS_SOA => 'SOA', + DNS_SRV => 'SRV', + DNS_CAA => 'CAA', + ]; + + public function __construct() + { + $this->logger = new Logger('dns'); + } + + // ======================================================================== + // MAIN LOOKUP + // ======================================================================== + + /** + * Comprehensive DNS lookup for a domain. + * Scans root + common subdomains + targets extracted from NS/MX/CNAME. + * Resolves NS/MX targets to A/AAAA IPs. + * + * @param string $domain The domain to scan + * @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans) + */ + public function lookup(string $domain, array $extraSubdomains = []): array + { + $this->logger->info("DNS lookup started", ['domain' => $domain]); + + $records = [ + 'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [], + 'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [], + ]; + $seen = []; // "TYPE:host:value" dedup keys + + // Phase 1: Root domain β€” query each type individually + foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) { + $this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen); + } + + // Phase 1b: DNS_ALL fallback to catch anything we missed + $this->queryAllFallback($domain, $domain, $records, $seen); + + // Phase 1c: gethostbynamel fallback for A records + if (empty($records['A'])) { + $ips = @gethostbynamel($domain); + if (is_array($ips)) { + foreach ($ips as $ip) { + $this->addIfNew('A', [ + 'host' => '@', 'value' => $ip, 'ttl' => 0, + 'is_cloudflare' => $this->isCloudflareIp($ip), + 'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0], + ], $records, $seen); + } + } + } + + // Phase 2: Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV + $candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains); + foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) { + foreach ($records[$type] as $rec) { + $target = rtrim($rec['value'] ?? '', '.'); + if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) { + $sub = str_replace('.' . $domain, '', strtolower($target)); + if ($sub && !in_array($sub, $candidates)) { + $candidates[] = $sub; + } + } + } + } + $candidates = array_unique($candidates); + + // Phase 3: Probe subdomains β€” fast checkdnsrr existence test first + $discovered = []; + foreach ($candidates as $sub) { + $fqdn = "{$sub}.{$domain}"; + if ($this->subdomainExists($fqdn)) { + $discovered[] = $sub; + } + } + + // Phase 4: Deep scan discovered subdomains (A, AAAA, CNAME, TXT) + foreach ($discovered as $sub) { + $fqdn = "{$sub}.{$domain}"; + $this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen); + $this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen); + $this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen); + // TXT only for known useful subdomains + if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) { + $this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen); + } + } + + // Phase 4b: Special TXT subdomains (always query even if not "discovered") + foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) { + $fqdn = "{$sub}.{$domain}"; + $this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen); + } + + // Phase 5: Resolve MX targets that are under this domain β€” add their A/AAAA records + foreach ($records['MX'] as $mxRec) { + $target = rtrim($mxRec['value'] ?? '', '.'); + if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) { + $this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen); + $this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen); + } + } + + // Phase 6: Resolve NS server IPs β€” store in raw data for display + foreach ($records['NS'] as &$nsRec) { + $nsHost = rtrim($nsRec['value'] ?? '', '.'); + if ($nsHost) { + $nsIps = $this->resolveHostIps($nsHost); + $nsRec['raw']['_ns_ips'] = $nsIps; + } + } + unset($nsRec); + + // Sort A/AAAA: root first, then alphabetical + foreach (['A', 'AAAA'] as $type) { + usort($records[$type], function ($a, $b) { + if ($a['host'] === '@') return -1; + if ($b['host'] === '@') return 1; + return strcmp($a['host'], $b['host']); + }); + } + + $totalRecords = array_sum(array_map('count', $records)); + $this->logger->info("DNS lookup completed", [ + 'domain' => $domain, + 'total_records' => $totalRecords, + 'subdomains_discovered' => count($discovered), + ]); + + return $records; + } + + // ======================================================================== + // LOOKUP HELPERS + // ======================================================================== + + /** + * Query a FQDN for a specific record type and collect deduplicated results. + */ + private function queryAndCollect( + string $fqdn, int $dnsConst, string $typeName, + string $baseDomain, array &$records, array &$seen + ): void { + try { + $raw = @dns_get_record($fqdn, $dnsConst); + if ($raw === false || empty($raw)) { + return; + } + foreach ($raw as $entry) { + $parsed = $this->parseRecord($typeName, $entry, $baseDomain); + if ($parsed) { + $this->addIfNew($typeName, $parsed, $records, $seen); + } + } + } catch (\Throwable $e) { + // Non-existent subdomain or network issue β€” not worth logging + } + } + + /** + * DNS_ALL fallback to catch records missed by individual queries. + */ + private function queryAllFallback(string $fqdn, string $baseDomain, array &$records, array &$seen): void + { + try { + $all = @dns_get_record($fqdn, DNS_ALL); + if (!is_array($all) || empty($all)) { + return; + } + foreach ($all as $entry) { + $type = strtoupper($entry['type'] ?? ''); + if ($type && isset($records[$type])) { + $parsed = $this->parseRecord($type, $entry, $baseDomain); + if ($parsed) { + $this->addIfNew($type, $parsed, $records, $seen); + } + } + } + } catch (\Throwable $e) { + // Ignore + } + } + + /** + * Add a record only if it hasn't been seen before (dedup). + */ + private function addIfNew(string $type, array $parsed, array &$records, array &$seen): void + { + $priority = $parsed['priority'] ?? ''; + $dedupKey = "{$type}:{$parsed['host']}:{$parsed['value']}:{$priority}"; + if (isset($seen[$dedupKey])) { + return; + } + $seen[$dedupKey] = true; + $records[$type][] = $parsed; + } + + /** + * Fast existence check for a subdomain. + */ + private function subdomainExists(string $fqdn): bool + { + if (@checkdnsrr($fqdn, 'A')) return true; + if (@checkdnsrr($fqdn, 'AAAA')) return true; + if (@checkdnsrr($fqdn, 'CNAME')) return true; + $ip = @gethostbyname($fqdn); + return ($ip !== $fqdn); + } + + /** + * Resolve a hostname to its A and AAAA IPs. + */ + private function resolveHostIps(string $hostname): array + { + $ips = ['ipv4' => [], 'ipv6' => []]; + + $a = @dns_get_record($hostname, DNS_A); + if (is_array($a)) { + foreach ($a as $r) { + if (!empty($r['ip'])) { + $ips['ipv4'][] = $r['ip']; + } + } + } + + $aaaa = @dns_get_record($hostname, DNS_AAAA); + if (is_array($aaaa)) { + foreach ($aaaa as $r) { + if (!empty($r['ipv6'])) { + $ips['ipv6'][] = $r['ipv6']; + } + } + } + + return $ips; + } + + // ======================================================================== + // CERTIFICATE TRANSPARENCY (crt.sh) + // ======================================================================== + + /** + * Discover subdomains via crt.sh Certificate Transparency logs. + * Returns an array of subdomain labels (e.g. ['www', 'mail', 'api']). + * Slow/unreliable β€” use only in cron, not on manual refresh. + */ + public function crtshSubdomains(string $domain): array + { + $url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json'; + + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'ignore_errors' => true, + 'header' => "User-Agent: DomainMonitor/1.0\r\n", + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $json = @file_get_contents($url, false, $ctx); + if ($json === false) { + $this->logger->warning('crt.sh request failed', ['domain' => $domain]); + return []; + } + + $entries = @json_decode($json, true); + if (!is_array($entries)) { + return []; + } + + $subdomains = []; + $domainLower = strtolower($domain); + + foreach ($entries as $entry) { + $name = $entry['name_value'] ?? ''; + foreach (explode("\n", $name) as $n) { + $n = strtolower(trim($n)); + $n = ltrim($n, '*.'); + if (empty($n)) continue; + + if ($n === $domainLower) continue; + + if (str_ends_with($n, '.' . $domainLower)) { + $sub = str_replace('.' . $domainLower, '', $n); + if ($sub !== '' && !isset($subdomains[$sub])) { + $subdomains[$sub] = true; + } + } + } + } + + $result = array_keys($subdomains); + $this->logger->info('crt.sh discovery completed', [ + 'domain' => $domain, + 'subdomains_found' => count($result), + ]); + + return $result; + } + + // ======================================================================== + // RECORD PARSING + // ======================================================================== + + /** + * Parse a raw dns_get_record entry into a normalized record. + */ + private function parseRecord(string $type, array $entry, string $domain): ?array + { + $host = $entry['host'] ?? $domain; + $hostLower = strtolower($host); + $domainLower = strtolower($domain); + + // Skip records that resolved to external domains (e.g. CNAME target chains) + if ($hostLower !== $domainLower && !str_ends_with($hostLower, '.' . $domainLower)) { + return null; + } + + $hostLabel = ($hostLower === $domainLower) + ? '@' + : str_ireplace('.' . $domain, '', $host); + $ttl = $entry['ttl'] ?? null; + + switch ($type) { + case 'A': + $ip = $entry['ip'] ?? ''; + if (empty($ip)) return null; + return [ + 'host' => $hostLabel, + 'value' => $ip, + 'ttl' => $ttl, + 'is_cloudflare' => $this->isCloudflareIp($ip), + 'raw' => $entry, + ]; + + case 'AAAA': + $ip = $entry['ipv6'] ?? ''; + if (empty($ip)) return null; + return [ + 'host' => $hostLabel, + 'value' => $ip, + 'ttl' => $ttl, + 'is_cloudflare' => $this->isCloudflareIpv6($ip), + 'raw' => $entry, + ]; + + case 'MX': + $target = $entry['target'] ?? ''; + if (empty($target)) return null; + return [ + 'host' => $hostLabel, + 'value' => $target, + 'priority' => $entry['pri'] ?? 0, + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + case 'TXT': + $txt = $entry['txt'] ?? ''; + if (empty($txt)) return null; + return [ + 'host' => $hostLabel, + 'value' => $txt, + 'ttl' => $ttl, + 'txt_type' => $this->classifyTxtRecord($txt), + 'raw' => $entry, + ]; + + case 'NS': + $target = $entry['target'] ?? ''; + if (empty($target)) return null; + return [ + 'host' => $hostLabel, + 'value' => $target, + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + case 'CNAME': + $target = $entry['target'] ?? ''; + if (empty($target)) return null; + return [ + 'host' => $hostLabel, + 'value' => $target, + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + case 'SOA': + return [ + 'host' => $hostLabel, + 'value' => $entry['mname'] ?? '', + 'rname' => $entry['rname'] ?? '', + 'serial' => $entry['serial'] ?? 0, + 'refresh' => $entry['refresh'] ?? 0, + 'retry' => $entry['retry'] ?? 0, + 'expire' => $entry['expire'] ?? 0, + 'minimum' => $entry['minimum-ttl'] ?? 0, + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + case 'SRV': + $target = $entry['target'] ?? ''; + if (empty($target)) return null; + return [ + 'host' => $hostLabel, + 'value' => $target, + 'priority' => $entry['pri'] ?? 0, + 'weight' => $entry['weight'] ?? 0, + 'port' => $entry['port'] ?? 0, + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + case 'CAA': + $value = ($entry['flags'] ?? 0) . ' ' . ($entry['tag'] ?? '') . ' "' . ($entry['value'] ?? '') . '"'; + return [ + 'host' => $hostLabel, + 'value' => $value, + 'flags' => $entry['flags'] ?? 0, + 'tag' => $entry['tag'] ?? '', + 'ca' => $entry['value'] ?? '', + 'ttl' => $ttl, + 'raw' => $entry, + ]; + + default: + return null; + } + } + + /** + * Classify a TXT record's purpose. + */ + private function classifyTxtRecord(string $value): string + { + $lower = strtolower($value); + if (str_starts_with($lower, 'v=spf1')) return 'SPF'; + if (str_starts_with($lower, 'v=dkim1')) return 'DKIM'; + if (str_starts_with($lower, 'v=dmarc1')) return 'DMARC'; + if (str_contains($lower, 'google-site-verification')) return 'Google Verification'; + if (str_contains($lower, 'ms=')) return 'Microsoft Verification'; + if (str_contains($lower, 'facebook-domain-verification')) return 'Facebook Verification'; + if (str_contains($lower, 'apple-domain-verification')) return 'Apple Verification'; + if (str_contains($lower, 'amazonses:')) return 'Amazon SES'; + if (str_contains($lower, 'docusign')) return 'DocuSign'; + if (str_contains($lower, 'atlassian-domain-verification')) return 'Atlassian Verification'; + if (str_contains($lower, '_mta-sts')) return 'MTA-STS'; + return 'TXT'; + } + + // ======================================================================== + // CLOUDFLARE DETECTION + // ======================================================================== + + public function isCloudflareIp(string $ip): bool + { + if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + $ipLong = ip2long($ip); + if ($ipLong === false) { + return false; + } + + foreach (self::CLOUDFLARE_IPV4_RANGES as $cidr) { + [$subnet, $mask] = explode('/', $cidr); + $subnetLong = ip2long($subnet); + $maskLong = ~((1 << (32 - (int)$mask)) - 1); + + if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) { + return true; + } + } + + return false; + } + + public function isCloudflareIpv6(string $ip): bool + { + if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return false; + } + + $ipBin = inet_pton($ip); + if ($ipBin === false) { + return false; + } + + foreach (self::CLOUDFLARE_IPV6_RANGES as $cidr) { + [$subnet, $prefixLen] = explode('/', $cidr); + $subnetBin = inet_pton($subnet); + if ($subnetBin === false) { + continue; + } + + $prefixLen = (int)$prefixLen; + $fullBytes = intdiv($prefixLen, 8); + $remainingBits = $prefixLen % 8; + + $match = true; + for ($i = 0; $i < $fullBytes; $i++) { + if ($ipBin[$i] !== $subnetBin[$i]) { + $match = false; + break; + } + } + + if ($match && $remainingBits > 0 && $fullBytes < 16) { + $bitmask = 0xFF << (8 - $remainingBits) & 0xFF; + if ((ord($ipBin[$fullBytes]) & $bitmask) !== (ord($subnetBin[$fullBytes]) & $bitmask)) { + $match = false; + } + } + + if ($match) { + return true; + } + } + + return false; + } + + // ======================================================================== + // IP DETAILS (PTR, ASN, GEO) + // ======================================================================== + + /** + * Batch-lookup IP details (ASN, PTR, org, country) for a list of IPs. + * PTR via gethostbyaddr(); ASN/geo via ip-api.com batch. + */ + public function lookupIpDetails(array $ips): array + { + $unique = array_values(array_unique(array_filter($ips))); + if (empty($unique)) { + return []; + } + + $result = []; + + foreach ($unique as $ip) { + $ptr = @gethostbyaddr($ip); + $result[$ip] = [ + 'reverse' => ($ptr !== false && $ptr !== $ip) ? $ptr : '', + ]; + } + + $requestBody = []; + foreach ($unique as $ip) { + $requestBody[] = [ + 'query' => $ip, + 'fields' => 'status,query,as,asname,isp,org,country,countryCode,regionName,city,hosting', + ]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nUser-Agent: DomainMonitor/1.0", + 'content' => json_encode($requestBody), + 'timeout' => 5, + ], + ]); + + $response = @file_get_contents('http://ip-api.com/batch', false, $context); + if ($response !== false) { + $data = json_decode($response, true); + if (is_array($data)) { + foreach ($data as $item) { + if (($item['status'] ?? '') === 'success' && isset($item['query'])) { + $result[$item['query']] = array_merge($result[$item['query']] ?? [], $item); + } + } + } + } else { + $this->logger->warning('ip-api.com batch request failed'); + } + + return $result; + } + + // ======================================================================== + // DIFF / NOTIFICATIONS + // ======================================================================== + + /** + * Compare two sets of DNS records and return changes. + */ + public function diffRecords(array $oldRecords, array $newRecords): array + { + $changes = ['added' => [], 'removed' => [], 'changed' => []]; + + $oldFlat = $this->flattenRecords($oldRecords); + $newFlat = $this->flattenRecords($newRecords); + + foreach ($newFlat as $key => $record) { + if (!isset($oldFlat[$key])) { + $changes['added'][] = $record; + } elseif ($oldFlat[$key]['value'] !== $record['value']) { + $changes['changed'][] = [ + 'record' => $record, + 'old_value' => $oldFlat[$key]['value'], + 'new_value' => $record['value'], + ]; + } + } + + foreach ($oldFlat as $key => $record) { + if (!isset($newFlat[$key])) { + $changes['removed'][] = $record; + } + } + + return $changes; + } + + private function flattenRecords(array $grouped): array + { + $flat = []; + foreach ($grouped as $type => $records) { + foreach ($records as $record) { + $host = $record['host'] ?? '@'; + $value = $record['value'] ?? ''; + $priority = $record['priority'] ?? ''; + $key = "{$type}:{$host}:{$value}:{$priority}"; + $flat[$key] = array_merge($record, ['record_type' => $type]); + } + } + return $flat; + } + + public function formatChangesSummary(array $changes, string $domain): string + { + $parts = []; + + if (!empty($changes['added'])) { + $parts[] = count($changes['added']) . " new record(s) added"; + } + if (!empty($changes['removed'])) { + $parts[] = count($changes['removed']) . " record(s) removed"; + } + if (!empty($changes['changed'])) { + $parts[] = count($changes['changed']) . " record(s) changed"; + } + + return empty($parts) ? '' : "DNS changes detected for {$domain}: " . implode(', ', $parts); + } + + public function formatChangesDetail(array $changes, string $domain): string + { + $lines = ["πŸ”„ DNS Changes Detected: {$domain}\n"]; + + if (!empty($changes['added'])) { + $lines[] = "βž• New Records:"; + foreach ($changes['added'] as $r) { + $type = $r['record_type'] ?? 'UNKNOWN'; + $lines[] = " {$type} {$r['host']} β†’ {$r['value']}"; + } + $lines[] = ''; + } + + if (!empty($changes['removed'])) { + $lines[] = "βž– Removed Records:"; + foreach ($changes['removed'] as $r) { + $type = $r['record_type'] ?? 'UNKNOWN'; + $lines[] = " {$type} {$r['host']} β†’ {$r['value']}"; + } + $lines[] = ''; + } + + if (!empty($changes['changed'])) { + $lines[] = "✏️ Changed Records:"; + foreach ($changes['changed'] as $c) { + $type = $c['record']['record_type'] ?? 'UNKNOWN'; + $lines[] = " {$type} {$c['record']['host']}: {$c['old_value']} β†’ {$c['new_value']}"; + } + $lines[] = ''; + } + + return implode("\n", $lines); + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index ad035f4..4259ea7 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -665,6 +665,54 @@ class NotificationService } } + // ======================================== + // DNS MONITORING NOTIFICATIONS + // ======================================== + + /** + * Create a DNS change notification (in-app / bell icon) + */ + public function notifyDnsChange(int $userId, string $domainName, int $domainId, string $summary): void + { + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'dns_change', + 'DNS Records Changed', + "{$domainName} - {$summary}", + $domainId + ); + } + + /** + * Send DNS change alert to external channels + */ + public function sendDnsChangeAlert(array $domain, array $notificationChannels, string $detailMessage): array + { + $results = []; + + foreach ($notificationChannels as $channel) { + $config = json_decode($channel['channel_config'], true); + $success = $this->send( + $channel['channel_type'], + $config, + $detailMessage, + [ + 'subject' => "DNS Changes: {$domain['domain_name']}", + 'domain' => $domain['domain_name'], + 'domain_id' => $domain['id'], + ] + ); + + $results[] = [ + 'channel' => $channel['channel_type'], + 'success' => $success, + ]; + } + + return $results; + } + /** * Delete old read notifications (cleanup) */ diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index 8f7b3a0..2483bb3 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -848,7 +848,7 @@ class WhoisService // Check if domain is not found/available $whoisDataLower = strtolower($whoisData); - // More specific patterns to avoid false positives + // Exact line match (original patterns) if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) || preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) || preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) { @@ -856,6 +856,12 @@ class WhoisService $data['registrar'] = 'Not Registered'; return $data; } + // Broader patterns for formats that don't match exact line (e.g. .rs "%ERROR:103: Domain is not registered", .io "Domain not found.", .co "The queried object does not exist: DOMAIN NOT FOUND") + if (preg_match('/domain\s+is\s+not\s+registered|domain\s+not\s+found\.?(\s|$)|queried\s+object\s+does\s+not\s+exist|%error:\d+:\s*domain/i', $whoisDataLower)) { + $data['status'][] = 'AVAILABLE'; + $data['registrar'] = 'Not Registered'; + return $data; + } // Special handling for .eu domains that are available // EURid returns "Status: AVAILABLE" in a specific format @@ -1196,7 +1202,10 @@ class WhoisService } // No expiration date and no clear status indicators - // This should only happen for newly added domains or error cases + // Fallback: registrar "Not Registered" means domain is available (e.g. parseWhoisData set it but status was lost in merge) + if (!empty($whoisData['registrar']) && $whoisData['registrar'] === 'Not Registered') { + return 'available'; + } // Return error to avoid incorrectly marking registered domains as available return 'error'; } diff --git a/app/Views/domains/edit.twig b/app/Views/domains/edit.twig index 0ca09e1..bb4afa1 100644 --- a/app/Views/domains/edit.twig +++ b/app/Views/domains/edit.twig @@ -140,15 +140,25 @@ -
-
+ + + +
+ + + +
+ + +
+
+

+ + Transaction History + 5 +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateTypeAmountCompany/SellerInvoicePayment MethodStatusNotes
Jan 15, 2020Purchase$1,200.00Sedo MarketplaceSEDO-2020-001234Credit CardPaidInitial domain purchase from Sedo marketplace
+
+
+ + +
+ +
+
+

+ + Expense Breakdown +

+
+
+ +
+
+ + +
+
+

+ + Expense Timeline +

+
+
+ +
+
+
+ + + + + diff --git a/app/Views/domains/tabs/dns.twig b/app/Views/domains/tabs/dns.twig new file mode 100644 index 0000000..a87854c --- /dev/null +++ b/app/Views/domains/tabs/dns.twig @@ -0,0 +1,507 @@ +{# DNS TAB CONTENT #} + +{% set totalDnsRecords = dnsRecordCount|default(0) %} +{% set dnsMonitoringEnabled = domain.dns_monitoring_enabled|default(1) %} + +{% if not dnsMonitoringEnabled %} + +
+
+ +
+

DNS monitoring is disabled

+

This domain is not checked by the DNS cron. Enable it in Edit to track DNS changes.

+ + Enable DNS monitoring in Edit + +
+
+
+{% else %} +{% if totalDnsRecords == 0 %} + +
+ +

No DNS Records Yet

+

Click "Refresh DNS" to fetch the current DNS records for this domain.

+ {% if domain %} + + {{ csrf_field()|raw }} + + + {% endif %} +
+{% else %} + + +
+
+

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

+ {% if dnsHasCloudflare|default(false) %} + + + Cloudflare Detected + + {% endif %} +
+ {% if domain and dnsMonitoringEnabled %} +
+ {{ csrf_field()|raw }} + +
+ {% endif %} +
+ + +
+ + {# ===== SOA Record (Start of Authority) β€” shown first ===== #} + {% if dnsRecords['SOA'] is defined and dnsRecords['SOA']|length > 0 %} +
+
+

+ + SOA Record (Start of Authority) +

+
+ {% for record in dnsRecords['SOA'] %} + {% set rawData = record.raw_data ? record.raw_data|from_json : null %} +
+
+
+ +

{{ record.value }}

+
+
+ +

{{ rawData.rname|default('N/A') }}

+
+
+ +

{{ rawData.serial|default('N/A') }}

+
+
+ +

{{ record.ttl }}s

+
+
+
+
+ +

{{ rawData.refresh|default('N/A') }}s

+
+
+ +

{{ rawData.retry|default('N/A') }}s

+
+
+ +

{{ rawData.expire|default('N/A') }}s

+
+
+ +

{{ rawData['minimum-ttl']|default('N/A') }}s

+
+
+
+ {% endfor %} +
+ {% endif %} + + {# ===== A Records ===== #} + {% if dnsRecords['A'] is defined and dnsRecords['A']|length > 0 %} +
+
+

+ + A Records (IPv4) + {{ dnsRecords['A']|length }} +

+
+
+ + + + + + + + + + + + {% for record in dnsRecords['A'] %} + {% set ipInfo = dnsIpDetails[record.value]|default(null) %} + + + + + + + + {% endfor %} + +
HostIP AddressPTRASNTTL
+ {% if record.host == '@' %} + @ (root) + {% else %} + {{ record.host }} + {% endif %} + + {{ record.value }} + {% if record.is_cloudflare %} + + {% endif %} + + {{ ipInfo.reverse|default('-') }} + + {% if ipInfo and ipInfo.as %} +
+ {% if ipInfo.countryCode %} + + {% endif %} +
+
{{ ipInfo.as|split(' ')|first }}
+
{{ ipInfo.org|default(ipInfo.isp|default('')) }}
+ {% if ipInfo.city or ipInfo.regionName %} +
{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}
+ {% endif %} +
+
+ {% else %} + - + {% endif %} +
{{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== AAAA Records ===== #} + {% if dnsRecords['AAAA'] is defined and dnsRecords['AAAA']|length > 0 %} +
+
+

+ + AAAA Records (IPv6) + {{ dnsRecords['AAAA']|length }} +

+
+
+ + + + + + + + + + + + {% for record in dnsRecords['AAAA'] %} + {% set ipInfo = dnsIpDetails[record.value]|default(null) %} + + + + + + + + {% endfor %} + +
HostIPv6 AddressPTRASNTTL
+ {% if record.host == '@' %} + @ (root) + {% else %} + {{ record.host }} + {% endif %} + + {{ record.value }} + {% if record.is_cloudflare %} + + {% endif %} + + {{ ipInfo.reverse|default('-') }} + + {% if ipInfo and ipInfo.as %} +
+ {% if ipInfo.countryCode %} + + {% endif %} +
+
{{ ipInfo.as|split(' ')|first }}
+
{{ ipInfo.org|default(ipInfo.isp|default('')) }}
+ {% if ipInfo.city or ipInfo.regionName %} +
{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}
+ {% endif %} +
+
+ {% else %} + - + {% endif %} +
{{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== CNAME Records ===== #} + {% if dnsRecords['CNAME'] is defined and dnsRecords['CNAME']|length > 0 %} +
+
+

+ + CNAME Records (Aliases) + {{ dnsRecords['CNAME']|length }} +

+
+
+ + + + + + + + + + {% for record in dnsRecords['CNAME'] %} + + + + + + {% endfor %} + +
AliasTargetTTL
{{ record.host }}{{ record.value }}{{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== MX Records ===== #} + {% if dnsRecords['MX'] is defined and dnsRecords['MX']|length > 0 %} +
+
+

+ + MX Records (Mail Exchange) + {{ dnsRecords['MX']|length }} +

+
+
+ + + + + + + + + + {% for record in dnsRecords['MX'] %} + + + + + + {% endfor %} + +
PriorityMail ServerTTL
+ {{ record.priority }} + {{ record.value }}{{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== TXT Records ===== #} + {% if dnsRecords['TXT'] is defined and dnsRecords['TXT']|length > 0 %} +
+
+

+ + TXT Records + {{ dnsRecords['TXT']|length }} +

+
+
+ {% for record in dnsRecords['TXT'] %} +
+
+ {% set val = record.value|lower %} + {% if val starts with 'v=spf1' %} + {% set txtType = 'SPF' %} + {% elseif val starts with 'v=dkim1' %} + {% set txtType = 'DKIM' %} + {% elseif val starts with 'v=dmarc1' %} + {% set txtType = 'DMARC' %} + {% elseif 'google-site-verification' in val %} + {% set txtType = 'Google' %} + {% elseif 'ms=' in val %} + {% set txtType = 'Microsoft' %} + {% elseif 'facebook-domain-verification' in val %} + {% set txtType = 'Facebook' %} + {% else %} + {% set txtType = 'TXT' %} + {% endif %} + {{ txtType }} +

{{ record.value }}

+
+
+ {% endfor %} +
+
+ {% endif %} + + {# ===== NS Records ===== #} + {% if dnsRecords['NS'] is defined and dnsRecords['NS']|length > 0 %} +
+
+

+ + NS Records (Name Servers) + {{ dnsRecords['NS']|length }} +

+
+
+ + + + + + + + + + + + {% for record in dnsRecords['NS'] %} + {% set rawData = record.raw_data ? record.raw_data|from_json : null %} + {% set nsIps = rawData ? rawData._ns_ips|default(null) : null %} + + + + + + + + {% endfor %} + +
#NameserverIPv4IPv6TTL
+
{{ loop.index }}
+
{{ record.value }} + {% if nsIps and nsIps.ipv4|default([])|length > 0 %} + {{ nsIps.ipv4|join(', ') }} + {% else %}-{% endif %} + + {% if nsIps and nsIps.ipv6|default([])|length > 0 %} + {{ nsIps.ipv6|join(', ') }} + {% else %}-{% endif %} + {{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== SRV Records ===== #} + {% if dnsRecords['SRV'] is defined and dnsRecords['SRV']|length > 0 %} +
+
+

+ + SRV Records (Services) + {{ dnsRecords['SRV']|length }} +

+
+
+ + + + + + + + + + + + + {% for record in dnsRecords['SRV'] %} + {% set rawData = record.raw_data ? record.raw_data|from_json : {} %} + + + + + + + + + {% endfor %} + +
ServiceTargetPortPriorityWeightTTL
{{ record.host }}{{ record.value }}{{ rawData.port|default('-') }}{{ record.priority|default('-') }}{{ rawData.weight|default('-') }}{{ record.ttl }}s
+
+
+ {% endif %} + + {# ===== CAA Records ===== #} + {% if dnsRecords['CAA'] is defined and dnsRecords['CAA']|length > 0 %} +
+
+

+ + CAA Records (Certificate Authority) + {{ dnsRecords['CAA']|length }} +

+
+
+ + + + + + + + + + + {% for record in dnsRecords['CAA'] %} + {% set rawData = record.raw_data ? record.raw_data|from_json : {} %} + + + + + + + {% endfor %} + +
TagValue (CA)FlagsTTL
+ {{ rawData.tag|default('-') }} + {{ rawData.value|default(record.value) }}{{ rawData.flags|default('0') }}{{ record.ttl }}s
+
+
+ {% endif %} + +
+{% endif %} +{% endif %} + + diff --git a/app/Views/domains/tabs/notification.twig b/app/Views/domains/tabs/notification.twig new file mode 100644 index 0000000..b805b47 --- /dev/null +++ b/app/Views/domains/tabs/notification.twig @@ -0,0 +1,131 @@ +
+
+

+ + Notification History + ({{ logs|length }}) +

+ {% if logs is not empty %} +
+ + + + + +
+ {% endif %} +
+
+ {% if logs is empty %} +
+ +

No notifications sent yet

+
+ {% else %} +
+ + + + + + + + + + + {% for log in logs %} + {% set nt = log.notification_type|default('') %} + {% set logType = (nt == 'expired' or (nt|slice(0, 13)) == 'expiring_in_') ? 'expiration' : (((nt|slice(0, 7)) == 'domain_') ? 'status' : ((nt == 'dns_change') ? 'dns' : 'other')) %} + + + + + + + {% endfor %} + +
ChannelStatusDateMessage
+ + {{ log.channel_type|capitalize }} + + + {% set logStatusClass = log.status == 'sent' ? 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400' : 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400' %} + + {{ log.status|capitalize }} + + {{ log.sent_at|date('M j, H:i') }} + {{ log.message }} +
+
+ + {% endif %} +
+
+ +{% if logs is not empty %} + +{% endif %} diff --git a/app/Views/domains/tabs/overview.twig b/app/Views/domains/tabs/overview.twig new file mode 100644 index 0000000..3209511 --- /dev/null +++ b/app/Views/domains/tabs/overview.twig @@ -0,0 +1,284 @@ + + + +
+ +
+ +
+
+

+ + Domain Information +

+
+
+
+
+ Registrar: + {{ domain.registrar ?? 'Unknown' }} +
+ {% if domain.registrar_url is not empty %} +
+ Registrar URL: + + + Visit + +
+ {% endif %} +
+ Expires: + {% set expiryColor = domain.expiryColor|default('gray') %} + + {% if domain.expiration_date is defined and domain.expiration_date %} + {{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %} + {% if domain.isManualExpiration %} + + Manual + + {% endif %} + {% else %} + Unknown + {% endif %} + +
+
+ Created: + {% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %} +
+
+ Last Updated: + {% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %} +
+
+ Last Checked: + {% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %} +
+
+
+
+ + +
+
+

+ + Financial Summary +

+ +
+
+
+
+ Purchase Price: + $12.99 +
+
+ Renewal Cost: + $14.99 / yr +
+
+ Total Spent: + $42.97 +
+
+
+ Next Renewal: + {% if domain.expiration_date is defined and domain.expiration_date %} + + {{ domain.expiration_date|date('M d, Y') }} + + {% else %} + - + {% endif %} +
+
+
+
+

+ + Sample data — billing features coming soon +

+
+
+
+ + +
+
+

+ + Notes +

+ +
+
+ +
+ {% if domain.notes is not empty %} +
{{ domain.notes }}
+ {% else %} +

No notes yet.

+ {% endif %} +
+ + +
+
+
+ + +
+ +
+
+

+ + Monitoring Status +

+
+
+
+
+
+ + WHOIS +
+ {% if domain.is_active %} + + Active + + {% else %} + + Disabled + + {% endif %} +
+
+
+ + SSL +
+ + Coming Soon + +
+
+
+ + DNS +
+ {% if domain.dns_monitoring_enabled|default(1) %} + + Active + + {% else %} + + Disabled + + {% endif %} +
+
+
+ + Notification Group +
+ {% if domain.group_name is not empty %} + + {{ domain.group_name }} + + {% else %} + + Assign Group + + {% endif %} +
+
+
+
+ + + {% if domain.group_name is not empty %} +
+
+

+ + Active Channels + {{ domain.group_name }} +

+
+
+ {% if domain.channels is not empty %} +
+ {% for channel in domain.channels %} +
+ + {{ channel.channel_type|capitalize }} +
+ {% endfor %} +
+

{{ domain.activeChannelCount|default(0) }} of {{ domain.channels|length }} channels active

+ {% else %} +

No channels configured for this group

+ {% endif %} +
+
+ {% endif %} + + +
+
+

+ + Domain Status Codes + {% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %} + {{ domain.parsedStatuses|length }} + {% endif %} +

+
+
+
+ {% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %} + {% for status in domain.parsedStatuses %} + {{ status|replace({'_':' '})|title }} + {% endfor %} + {% else %} + No status codes + {% endif %} +
+
+
+
+
diff --git a/app/Views/domains/tabs/ssl.twig b/app/Views/domains/tabs/ssl.twig new file mode 100644 index 0000000..e72d094 --- /dev/null +++ b/app/Views/domains/tabs/ssl.twig @@ -0,0 +1,379 @@ + + + +
+
+ +
+

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 + +
+
+
+
+
+
+ +
+
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
+
+
+
+
+
+
Last checked: Today 10:00
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+

mail.example.com

+

Certificate monitoring active

+
+
+ + + Valid & Trusted + +
+
+
+
+
+
+ +
+
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
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+

api.example.com

+

Certificate monitoring active

+
+
+ + + EXPIRED + +
+
+
+
+
+
+ +
+
Issued:Sep 26, 2024
+
Expires:Sep 30, 2025
+
Valid for:30 days (expired)
+
+
+
+ +
+

Self-Signed

+

⚠️ Not Trusted

+
+
+
+

Error Details

+

Certificate has expired

+
+
+
+
+ +
+
api.example.com
+
+
+
+ +
+
Signature:SHA256-RSA
+
Key Size:2048 bits
+
Version:v3
+
+
+
+
+
+
Last checked: Today 11:00
+
+ + +
+
+
+
+
+ + +
+
Page 1 of 1
+
+ + + 1 + + +
+
+ + diff --git a/app/Views/domains/tabs/whois.twig b/app/Views/domains/tabs/whois.twig new file mode 100644 index 0000000..939ffca --- /dev/null +++ b/app/Views/domains/tabs/whois.twig @@ -0,0 +1,241 @@ + + +{% if not domain.is_active %} + +
+
+ +
+

Active monitoring is disabled

+

This domain is not checked by the cron. You will not receive status or expiration alerts.

+ + Enable active monitoring in Edit + +
+
+
+{% endif %} + + +
+ +
+
+

+ + Registration +

+
+ {{ csrf_field()|raw }} + + +
+
+
+
+
+ +

{{ domain.registrar ?? 'Unknown' }}

+
+
+ + {% if domain.registrar_url is defined and domain.registrar_url %} + + + Visit Registrar + + {% else %} + - + {% endif %} +
+
+ +

{{ whoisData.iana_id ?? '-' }}

+
+
+ + {% if domain.abuse_email %} + {{ domain.abuse_email }} + {% else %} + - + {% endif %} +
+
+ +

{{ whoisData.whois_server ?? '-' }}

+
+
+
+
+ + +
+
+

+ + Registrant +

+
+
+
+
+ +

{{ whoisData.owner ?? '-' }}

+
+
+ +

{{ whoisData.organization ?? '-' }}

+
+
+ + {% if whoisData.email is defined and whoisData.email %} + {{ whoisData.email }} + {% else %} + - + {% endif %} +
+
+ +

{{ whoisData.country ?? '-' }}

+
+
+ + + + Enabled + +
+
+
+
+ + +
+
+

+ + Important Dates +

+
+
+
+
+
+ +
+
+

Created

+

{% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %}

+
+
+
+
+ +
+
+

Last Updated

+

{% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %}

+
+
+
+
+ +
+
+

Expires

+

+ {% if domain.expiration_date is defined and domain.expiration_date %} + {{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %} + {% else %}-{% endif %} +

+
+
+
+
+ +
+
+

Last Checked

+

{% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %}

+
+
+
+
+
+
+ + +
+ +
+
+

+ + Name Servers + {% if whoisData.nameservers is defined and whoisData.nameservers is not empty %} + {{ whoisData.nameservers|length }} + {% endif %} +

+
+
+
+ {% if whoisData.nameservers is defined and whoisData.nameservers is not empty %} + {% for ns in whoisData.nameservers %} +
+
{{ loop.index }}
+ {{ ns }} +
+ {% endfor %} + {% else %} +
+ +

No nameservers

+
+ {% endif %} +
+
+
+ + +
+
+

+ + Domain Status Codes + {% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %} + {{ domain.parsedStatuses|length }} + {% endif %} +

+
+
+
+ {% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %} + {% for status in domain.parsedStatuses %} + {{ status|replace({'_':' '})|title }} + {% endfor %} + {% else %} + No status codes + {% endif %} +
+
+
+
+ + +
+ + +
diff --git a/app/Views/domains/view-detailed.twig b/app/Views/domains/view-detailed.twig new file mode 100644 index 0000000..d89b42e --- /dev/null +++ b/app/Views/domains/view-detailed.twig @@ -0,0 +1,269 @@ +{% extends "layout/base.twig" %} + +{% set title = 'Domain Details' %} +{% set pageTitle = domain.domain_name|default('Domain Details') %} +{% set pageDescription = 'Domain information and monitoring status' %} +{% set pageIcon = 'fas fa-globe' %} + +{% set daysLeft = domain.daysLeft %} +{% set domainStatus = domain.displayStatus %} +{% set expiryColor = domain.expiryColor %} + +{% block content %} + +
+
+ {% if domain %} + + + {{ domain.statusText }} + + {% if domain.displayStatus != 'available' %} + + + {{ domain.daysLeft is not null ? domain.daysLeft ~ ' days left' : 'No expiry date' }} + + {% endif %} + + + {{ domain.is_active ? 'Monitoring Active' : 'Monitoring Paused' }} + + {# Tags Display (match view.twig) #} + {% if domain.tags is not empty %} + {% set tags = domain.tags|split(',') %} + {% set tagColors = domain.tag_colors is not empty ? domain.tag_colors|split('|') : [] %} + {% set tagColorMap = {} %} + {% for availableTag in availableTags %} + {% set tagColorMap = tagColorMap|merge({(availableTag.name): availableTag.color}) %} + {% endfor %} + {% for tag in tags %} + {% set tag = tag|trim %} + {% set colorClass = tagColorMap[tag] ?? (tagColors[loop.index0] ?? 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border-gray-200 dark:border-slate-700') %} + + + {{ tag|capitalize }} + + {% endfor %} + {% endif %} + {% else %} + + + Active + + + + 65 days left + + + + Monitoring Active + + {% endif %} +
+
+ {% if domain %} +
+ {{ csrf_field()|raw }} + + +
+ + + Edit + +
+ {{ csrf_field()|raw }} + +
+ {% else %} + + + + Edit + + + {% endif %} + + + Back + +
+
+ + +
+
+ +
+
+ + +
+
+ {% include 'domains/tabs/overview.twig' %} +
+ + + + + + + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/Views/domains/view.twig b/app/Views/domains/view.twig index a3ac76c..e5a8296 100644 --- a/app/Views/domains/view.twig +++ b/app/Views/domains/view.twig @@ -50,11 +50,11 @@ {% endfor %}
-
+ {{ csrf_field() }}
diff --git a/app/Views/search/results.twig b/app/Views/search/results.twig index fa128a0..0d7f552 100644 --- a/app/Views/search/results.twig +++ b/app/Views/search/results.twig @@ -238,6 +238,7 @@
+ {{ csrf_field()|raw }}

Want to monitor this domain?

+ +
+

Domain View

+
+ + +

+ Choose which template to use when viewing domain details at /domains/{id} +

+
+
+
- -
- -
- {% if lastCheckRun %} -
- - {{ lastCheckRun|date('M d, Y H:i') }} -
- {% else %} -
- - Never run -
- {% endif %} -
+

Last cronjob run times are shown in the System tab

@@ -772,27 +777,99 @@
- +

- Cron Job Command + Cron Job Commands

-
- php cron/check_domains.php +
+
+

Domain / WHOIS check

+ php cron/check_domains.php +
+
+

DNS record check

+ php cron/check_dns.php +
- +

- Recommended Crontab Entry + Recommended Crontab Entries

-
- 0 */{{ currentCheckInterval }} * * * php {{ cronPath }} +
+
+

Domain check (every {{ currentCheckInterval }}h)

+
+ 0 */{{ currentCheckInterval }} * * * php {{ cronPath }} +
+
+
+

DNS check (every 6 hours)

+
+ 0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }} +
+
+
+

Update the paths to match your server installation

+
+ + +
+

+ + Last Cronjob Run +

+
+
+
+

Domain / WHOIS

+

check_domains.php

+
+ {% if lastCheckRun %} +
+ {% if domainCronStale|default(false) %} + + {{ lastCheckRun|date('M d, Y H:i') }} + {% else %} + + {{ lastCheckRun|date('M d, Y H:i') }} + {% endif %} +
+ {% else %} +
+ + Never run +
+ {% endif %} +
+
+
+

DNS

+

check_dns.php

+
+ {% if lastDnsCheckRun %} +
+ {% if dnsCronStale|default(false) %} + + {{ lastDnsCheckRun|date('M d, Y H:i') }} + {% else %} + + {{ lastDnsCheckRun|date('M d, Y H:i') }} + {% endif %} +
+ {% else %} +
+ + Never run +
+ {% endif %} +
-

Update the path to match your server installation

@@ -804,17 +881,24 @@
-

Cron Log

-

Domain check execution logs

+

Domain Cron Log

+

WHOIS / expiration check logs

logs/cron.log
+
+
+

DNS Cron Log

+

DNS record check logs

+
+ logs/dns_cron.log +

TLD Import Log

TLD registry import logs

- logs/tld_import_*.log + logs/tld_import.log
diff --git a/app/Views/tags/view.twig b/app/Views/tags/view.twig index 73030f1..0c47db0 100644 --- a/app/Views/tags/view.twig +++ b/app/Views/tags/view.twig @@ -213,7 +213,7 @@
- + {{ csrf_field() }}
diff --git a/cron/check_dns.php b/cron/check_dns.php new file mode 100644 index 0000000..bec6c3f --- /dev/null +++ b/cron/check_dns.php @@ -0,0 +1,856 @@ +#!/usr/bin/env php + [max] β€” (internal) crt.sh subprocess + * + * Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php + * + * NOTE: Requires a `crtsh_last_fetched` column on the domains table: + * ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL; + */ + +require_once __DIR__ . '/../vendor/autoload.php'; + +use Dotenv\Dotenv; +use App\Models\Domain; +use App\Models\DnsRecord; +use App\Models\NotificationChannel; +use App\Models\NotificationGroup; +use App\Models\NotificationLog; +use App\Models\Setting; +use App\Models\User; +use App\Services\DnsService; +use App\Services\NotificationService; +use App\Services\Logger; +use Core\Database; + +// ─── Bootstrap ─────────────────────────────────────────────────────────────── + +$dotenv = Dotenv::createImmutable(__DIR__ . '/..'); +$dotenv->load(); +new Database(); + +// ─── Crt.sh subprocess mode ───────────────────────────────────────────────── +// When invoked with --crtsh, this script acts as its own subprocess for +// crt.sh fetching. Outputs a JSON array of subdomains to stdout and exits. + +if (isset($argv[1]) && $argv[1] === '--crtsh') { + runCrtshSubprocess($argv); + exit(0); +} + +// ─── Main cron mode ───────────────────────────────────────────────────────── + +if (php_sapi_name() !== 'cli') { + fwrite(STDERR, "This script must be run from the command line.\n"); + exit(1); +} + +/** crt.sh subprocess hard kill (seconds). In practice crt.sh 503s in <60s, but HTTP timeout is 900s as insurance. */ +const CRTSH_TIMEOUT_SECONDS = 1800; + +/** Max unique subdomains from crt.sh per domain (0 = no limit) */ +const CRTSH_MAX_SUBDOMAINS = 100; + +/** How often to re-fetch crt.sh per domain (hours). New certs appear gradually. */ +const CRTSH_REFRESH_HOURS = 24; + +/** Microseconds to sleep between domains */ +const INTER_DOMAIN_DELAY_US = 500000; + +// Initialize services and models +$domainModel = new Domain(); +$dnsModel = new DnsRecord(); +$channelModel = new NotificationChannel(); +$groupModel = new NotificationGroup(); +$logModel = new NotificationLog(); +$notificationModel = new \App\Models\Notification(); +$settingModel = new Setting(); +$userModel = new User(); +$dnsService = new DnsService(); +$notificationService = new NotificationService(); +$logger = new Logger('dns-cron'); + +// Set timezone from settings +try { + $appSettings = $settingModel->getAppSettings(); + date_default_timezone_set($appSettings['app_timezone']); +} catch (\Exception $e) { + date_default_timezone_set('UTC'); +} + +$logFile = __DIR__ . '/../logs/dns_cron.log'; +$startTime = microtime(true); + +logMessage("=== Starting DNS check cron job ==="); + +$domains = $domainModel->where('is_active', 1); +$domains = array_values(array_filter($domains, fn($d) => ($d['dns_monitoring_enabled'] ?? 1) == 1)); +logMessage("Found " . count($domains) . " domain(s) with DNS monitoring enabled"); + +$stats = [ + 'checked' => 0, + 'changes_detected' => 0, + 'records_added' => 0, + 'records_removed' => 0, + 'records_changed' => 0, + 'notifications_sent' => 0, + 'in_app_notifications' => 0, + 'errors' => 0, + 'skipped_unresolved' => 0, + 'crtsh_skipped' => 0, + 'crtsh_fetched' => 0, + 'domains_with_changes' => [], +]; + +$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + +foreach ($domains as $domain) { + $domainName = $domain['domain_name']; + $domainStartTime = microtime(true); + logMessage("Checking DNS: $domainName"); + + try { + // Quick existence check β€” skip if domain doesn't resolve at all + if (!domainResolves($domainName)) { + logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping"); + logTimeSince($domainStartTime); + $stats['skipped_unresolved']++; + continue; + } + + $previousRecords = $dnsModel->getPreviousSnapshot($domain['id']); + $isFirstScan = empty($previousRecords); + + // Gather subdomain candidates: known hosts from DB + $existingHosts = $dnsModel->getDistinctHosts($domain['id']); + + // Decide whether to call crt.sh or use cached hosts + $ctSubs = []; + + if (shouldFetchCrtsh($domain, $existingHosts)) { + logMessage(" πŸ” crt.sh: fetching subdomains..."); + + [$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName); + + logMessage(" πŸ” crt.sh: " . count($ctSubs) . " subdomain(s) found"); + $stats['crtsh_fetched']++; + + // Update timestamp if server responded (200 OK). + // Empty [] is valid (no CT entries) β€” still counts as a successful fetch. + // Only skip update if all attempts 503'd / timed out. + if ($crtshOk) { + $domainModel->update($domain['id'], [ + 'crtsh_last_fetched' => date('Y-m-d H:i:s'), + ]); + } + } else { + logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in " + . crtshHoursUntilRefresh($domain) . "h)"); + $stats['crtsh_skipped']++; + } + + $extraSubs = array_unique(array_merge($existingHosts, $ctSubs)); + + // Fetch fresh DNS records + $newRecords = $dnsService->lookup($domainName, $extraSubs); + $totalRecords = array_sum(array_map('count', $newRecords)); + + if ($totalRecords === 0) { + logMessage(" ⚠ No DNS records found for $domainName"); + logTimeSince($domainStartTime); + $stats['errors']++; + continue; + } + + // Enrich A/AAAA records with IP details (PTR, ASN, geo) + enrichIpDetails($newRecords, $dnsService); + + // Save snapshot + $saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords); + $domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]); + + $stats['checked']++; + logMessage(" βœ“ $totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})"); + + if ($isFirstScan) { + logMessage(" β†’ First scan β€” baseline saved"); + } + + // Detect changes + $changes = $dnsService->diffRecords($previousRecords, $newRecords); + $hasChanges = !empty($changes['added']) || !empty($changes['removed']) || !empty($changes['changed']); + + if (!$hasChanges) { + logMessage(" β†’ No changes detected"); + logTimeSince($domainStartTime); + usleep(INTER_DOMAIN_DELAY_US); + continue; + } + + $stats['changes_detected']++; + $stats['records_added'] += count($changes['added']); + $stats['records_removed'] += count($changes['removed']); + $stats['records_changed'] += count($changes['changed']); + + $summary = $dnsService->formatChangesSummary($changes, $domainName); + $detail = $dnsService->formatChangesDetail($changes, $domainName); + logMessage(" πŸ”„ $summary"); + + $stats['domains_with_changes'][] = [ + 'domain' => $domainName, + 'added' => count($changes['added']), + 'removed' => count($changes['removed']), + 'changed' => count($changes['changed']), + ]; + + // Send external notifications (channel alerts) + sendExternalNotifications( + $domain, $domainModel, $channelModel, $logModel, + $notificationService, $detail, $summary, $stats, $logger + ); + + // Create in-app notifications (bell icon) + sendInAppNotifications( + $domain, $domainName, $isolationMode, $userModel, $groupModel, + $notificationService, $summary, $stats + ); + + logTimeSince($domainStartTime); + usleep(INTER_DOMAIN_DELAY_US); + + } catch (\Exception $e) { + logMessage(" βœ— Error: " . $e->getMessage()); + logTimeSince($domainStartTime); + $logger->error("DNS check failed", [ + 'domain' => $domainName, + 'error' => $e->getMessage(), + ]); + $stats['errors']++; + } +} + +$settingModel->setValue('last_dns_check_run', date('Y-m-d H:i:s')); +printSummary($stats, $startTime); +exit(0); + + +// ═════════════════════════════════════════════════════════════════════════════ +// Crt.sh smart caching +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Should we fetch crt.sh for this domain right now? + * + * Skip if we already have enough known hosts and fetched recently. + * Always fetch on first scan or if we have very few known hosts. + * + * NOTE: Requires a `crtsh_last_fetched` DATETIME column on the domains table. + * ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL; + */ +function shouldFetchCrtsh(array $domain, array $existingHosts): bool +{ + // Always fetch if we've never successfully fetched before + $lastFetched = $domain['crtsh_last_fetched'] ?? null; + if (empty($lastFetched)) { + return true; + } + + // Respect the refresh interval β€” even if domain has few hosts, + // crt.sh already answered (maybe with [] or few results). Don't hammer it. + $hoursSince = (time() - strtotime($lastFetched)) / 3600; + return $hoursSince >= CRTSH_REFRESH_HOURS; +} + +/** + * Hours remaining until next crt.sh refresh (for log messages). + */ +function crtshHoursUntilRefresh(array $domain): string +{ + $lastFetched = $domain['crtsh_last_fetched'] ?? null; + if (empty($lastFetched)) { + return '0'; + } + $hoursSince = (time() - strtotime($lastFetched)) / 3600; + $remaining = max(0, CRTSH_REFRESH_HOURS - $hoursSince); + return sprintf('%.1f', $remaining); +} + + +// ═════════════════════════════════════════════════════════════════════════════ +// Crt.sh subprocess (self-invocation with hard timeout) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Internal crt.sh subprocess entry point. + * Called when this script is invoked with: --crtsh [max_subdomains] + * Outputs a JSON array of subdomains to stdout. + * + * Wildcard query ?q=%.domain.com with 5 retry attempts. + * All HTTP response details are written to stderr for real-time debugging. + */ +function runCrtshSubprocess(array $argv): void +{ + if (empty($argv[2])) { + fwrite(STDERR, "Usage: {$argv[0]} --crtsh [max_subdomains]\n"); + echo '[]'; + return; + } + + $domain = $argv[2]; + $maxSubdomains = isset($argv[3]) ? max(0, (int) $argv[3]) : 0; + $maxAttempts = 5; + $retryDelay = 10; + $httpTimeout = 900; + + $url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json'; + + try { + $result = []; + $gotHttp200 = false; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n"); + + $response = fetchCrtshWithDebug($url, $httpTimeout); + + // HTTP 200 β€” server answered, don't retry regardless of content + if ($response['status'] === 200) { + $gotHttp200 = true; + if (!empty($response['data'])) { + $result = extractSubdomains($response['data'], $domain); + fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n"); + } else { + fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n"); + } + break; + } + + // Non-200 (503, timeout, connection error) β€” retry + if ($attempt < $maxAttempts) { + fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n"); + sleep($retryDelay); + } else { + fwrite(STDERR, "all $maxAttempts attempts failed\n"); + } + } + + // Apply cap + if ($maxSubdomains > 0 && count($result) > $maxSubdomains) { + fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n"); + $result = array_slice(array_values($result), 0, $maxSubdomains); + } else { + fwrite(STDERR, "result: " . count($result) . " subdomain(s)\n"); + } + + echo json_encode(['ok' => $gotHttp200, 'subs' => array_values($result)]); + } catch (\Throwable $e) { + fwrite(STDERR, "crt.sh error: " . $e->getMessage() . "\n"); + echo json_encode(['ok' => false, 'subs' => []]); + } +} + +/** + * Fetch a crt.sh URL with full debug output to stderr. + * Dumps HTTP response headers + body preview immediately so you see + * exactly what the server returned β€” like watching curl in real-time. + * + * @param string $url Full crt.sh URL + * @param int $timeout HTTP timeout in seconds + * @return array{status: int, body_length: int, data: array, time: float} + */ +function fetchCrtshWithDebug(string $url, int $timeout = 900): array +{ + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => $timeout, + 'ignore_errors' => true, + 'header' => implode("\r\n", [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept: application/json, text/plain, */*', + 'Accept-Language: en-US,en;q=0.9', + 'Connection: keep-alive', + ]), + ], + ]); + + $start = microtime(true); + $http_response_header = null; + $body = @file_get_contents($url, false, $ctx); + $elapsed = microtime(true) - $start; + + // ── Dump full response to stderr ────────────────────────────────── + fwrite(STDERR, "--- response ---\n"); + fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n"); + + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $h) { + fwrite(STDERR, "$h\n"); + } + } else { + fwrite(STDERR, "(no response headers β€” connection failed or timeout)\n"); + } + + $bodyLen = is_string($body) ? strlen($body) : 0; + fwrite(STDERR, "Body: $bodyLen bytes\n"); + + if (is_string($body) && $bodyLen > 0) { + // Show first 2000 chars of body so you can see errors, JSON start, etc. + $preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body; + fwrite(STDERR, $preview . "\n"); + } + + fwrite(STDERR, "--- end response ---\n"); + + // ── Parse status and JSON ───────────────────────────────────────── + $status = 0; + if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) { + $status = (int) $m[0]; + } + + $data = []; + if ($status === 200 && is_string($body) && $bodyLen > 2) { + $decoded = json_decode($body, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + + return [ + 'status' => $status, + 'body_length' => $bodyLen, + 'data' => $data, + 'time' => $elapsed, + ]; +} + +/** + * Extract unique subdomain names from raw crt.sh JSON response. + * + * Each entry has a `name_value` field that may contain multiple newline-separated + * names, including wildcards. We strip wildcards, filter to our target domain, + * and return only the subdomain prefixes (e.g. "www", "mail", "api"). + * + * @param array $crtshData Decoded JSON array from crt.sh + * @param string $domain The base domain (e.g. "example.com") + * @return string[] Unique subdomain prefixes + */ +function extractSubdomains(array $crtshData, string $domain): array +{ + $domainLower = strtolower($domain); + $suffix = '.' . $domainLower; + $suffixLen = strlen($suffix); + $subs = []; + + foreach ($crtshData as $entry) { + if (empty($entry['name_value'])) { + continue; + } + + foreach (explode("\n", $entry['name_value']) as $name) { + $name = strtolower(trim($name)); + + // Strip wildcard prefix + if (strpos($name, '*.') === 0) { + $name = substr($name, 2); + } + + // Skip the apex domain itself + if ($name === $domainLower) { + continue; + } + + // Must be a subdomain of our domain + if (substr($name, -$suffixLen) !== $suffix) { + continue; + } + + // Extract the subdomain part (everything before .domain.tld) + $sub = substr($name, 0, strlen($name) - $suffixLen); + if (!empty($sub)) { + $subs[$sub] = true; + } + } + } + + return array_keys($subs); +} + + +// ═════════════════════════════════════════════════════════════════════════════ +// Subprocess management (main process side) +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Spawn a subprocess of this script in --crtsh mode with a hard timeout. + * Relays stderr from the subprocess to logMessage in REAL-TIME so you see + * every HTTP response, retry, and status as it happens. + * + * @return array{0: string[], 1: bool} [subdomains, ok (true if server responded 200)] + */ +function fetchCrtshWithTimeout(string $domainName): array +{ + $phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php'; + $cmd = [$phpBin, __FILE__, '--crtsh', $domainName]; + + if (CRTSH_MAX_SUBDOMAINS > 0) { + $cmd[] = (string) CRTSH_MAX_SUBDOMAINS; + } + + $proc = proc_open($cmd, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, __DIR__ . '/..'); + + if (!is_resource($proc)) { + return [[], false]; + } + + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $start = time(); + $stdout = ''; + $stderrBuffer = ''; + + while (true) { + $status = proc_get_status($proc); + + if (!$status['running']) { + break; + } + + $elapsed = time() - $start; + + // Hard timeout β€” kill the subprocess + if ($elapsed >= CRTSH_TIMEOUT_SECONDS) { + $stdout .= drainStream($pipes[1]); + $stderrBuffer .= drainStream($pipes[2]); + flushStderrLines($stderrBuffer); + proc_terminate($proc, 9); + proc_close($proc); + logMessage(" βœ— crt.sh killed after {$elapsed}s (hard timeout)"); + return [[], false]; + } + + // Read available data from pipes + $readable = [$pipes[1], $pipes[2]]; + $w = $e = null; + if (@stream_select($readable, $w, $e, 0, 200000) > 0) { + foreach ($readable as $stream) { + $chunk = stream_get_contents($stream); + if ($stream === $pipes[1]) { + $stdout .= $chunk; + } else { + $stderrBuffer .= $chunk; + // Flush complete lines to terminal immediately + flushStderrLines($stderrBuffer); + } + } + } + usleep(100000); + } + + // Drain remaining output + $stdout .= stream_get_contents($pipes[1]); + $stderrBuffer .= stream_get_contents($pipes[2]); + flushStderrLines($stderrBuffer); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + + $decoded = json_decode($stdout, true); + $ok = is_array($decoded) && !empty($decoded['ok']); + $subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : []; + return [$subs, $ok]; +} + +/** + * Flush complete lines from stderr buffer to logMessage in real-time. + * Keeps any incomplete trailing line in the buffer for next call. + */ +function flushStderrLines(string &$buffer): void +{ + while (($pos = strpos($buffer, "\n")) !== false) { + $line = trim(substr($buffer, 0, $pos)); + $buffer = substr($buffer, $pos + 1); + if ($line !== '') { + logMessage(" ↳ $line"); + } + } +} + + +// ═════════════════════════════════════════════════════════════════════════════ +// DNS helpers +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Check whether a domain resolves at all (SOA, A, or AAAA). + */ +function domainResolves(string $domain): bool +{ + return @checkdnsrr($domain, 'SOA') + || @checkdnsrr($domain, 'A') + || @checkdnsrr($domain, 'AAAA'); +} + +/** + * Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo). + */ +function enrichIpDetails(array &$newRecords, DnsService $dnsService): void +{ + $ips = []; + foreach (['A', 'AAAA'] as $type) { + foreach ($newRecords[$type] ?? [] as $r) { + if (!empty($r['value'])) { + $ips[] = $r['value']; + } + } + } + + if (empty($ips)) { + return; + } + + $ipDetails = $dnsService->lookupIpDetails($ips); + + foreach (['A', 'AAAA'] as $type) { + if (empty($newRecords[$type])) { + continue; + } + foreach ($newRecords[$type] as &$rec) { + if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) { + $rec['raw']['_ip_info'] = $ipDetails[$rec['value']]; + } + } + unset($rec); + } +} + + +// ═════════════════════════════════════════════════════════════════════════════ +// Notification helpers +// ═════════════════════════════════════════════════════════════════════════════ + +/** + * Send external notifications via configured channels. + */ +function sendExternalNotifications( + array $domain, + Domain $domainModel, + NotificationChannel $channelModel, + NotificationLog $logModel, + NotificationService $notificationService, + string $detail, + string $summary, + array &$stats, + Logger $logger +): void { + if (empty($domain['notification_group_id'])) { + return; + } + + if ($logModel->wasSentRecently($domain['id'], 'dns_change', 6)) { + logMessage(" β†’ DNS change notification sent recently, skipping external"); + return; + } + + $channels = $channelModel->getActiveByGroupId($domain['notification_group_id']); + if (empty($channels)) { + return; + } + + logMessage(" πŸ“€ Sending alerts to " . count($channels) . " channel(s)"); + + $domainData = $domainModel->find($domain['id']); + $results = $notificationService->sendDnsChangeAlert($domainData, $channels, $detail); + + foreach ($results as $result) { + $ok = $result['success']; + logMessage($ok + ? " βœ“ Sent to {$result['channel']}" + : " βœ— Failed: {$result['channel']}" + ); + + if ($ok) { + $stats['notifications_sent']++; + } + + $logModel->log( + $domain['id'], + 'dns_change', + $result['channel'], + $summary, + $ok, + $ok ? null : 'Failed to send notification' + ); + } +} + +/** + * Create in-app (bell icon) notifications for relevant users. + */ +function sendInAppNotifications( + array $domain, + string $domainName, + string $isolationMode, + User $userModel, + NotificationGroup $groupModel, + NotificationService $notificationService, + string $summary, + 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; + } + + $db = Database::getConnection(); + $notifiedCount = 0; + + foreach ($usersToNotify as $userId) { + // Deduplicate: skip if already notified in the last 6 hours + $stmt = $db->prepare( + "SELECT COUNT(*) AS cnt FROM user_notifications + WHERE user_id = ? AND domain_id = ? AND type = 'dns_change' + AND created_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)" + ); + $stmt->execute([$userId, $domain['id']]); + $row = $stmt->fetch(); + + if ($row && $row['cnt'] > 0) { + continue; + } + + try { + $notificationService->notifyDnsChange($userId, $domainName, $domain['id'], $summary); + $notifiedCount++; + } catch (\Exception $e) { + logMessage(" ⚠ In-app notification failed for user $userId: " . $e->getMessage()); + } + } + + if ($notifiedCount > 0) { + logMessage(" πŸ”” Notified $notifiedCount user(s) in-app"); + $stats['in_app_notifications'] += $notifiedCount; + } +} + + +// ═════════════════════════════════════════════════════════════════════════════ +// Logging / formatting helpers +// ═════════════════════════════════════════════════════════════════════════════ + +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); + } + $m = (int) floor($seconds / 60); + $s = $seconds - $m * 60; + return $m . 'm ' . sprintf("%.1fs", $s); +} + +function formatElapsedTime(float $seconds): string +{ + if ($seconds < 60) { + return sprintf("%.2f seconds", $seconds); + } + if ($seconds < 3600) { + $m = (int) floor($seconds / 60); + $s = $seconds - $m * 60; + return sprintf("%d minute%s %.2f seconds", $m, $m !== 1 ? 's' : '', $s); + } + $h = (int) floor($seconds / 3600); + $m = (int) floor(($seconds - $h * 3600) / 60); + $s = $seconds - $h * 3600 - $m * 60; + return sprintf("%d hour%s %d minute%s %.2f seconds", + $h, $h !== 1 ? 's' : '', $m, $m !== 1 ? 's' : '', $s); +} + +/** + * Drain remaining data from a non-blocking stream and close it. + */ +function drainStream($stream): string +{ + if (!is_resource($stream)) { + return ''; + } + $data = stream_get_contents($stream); + fclose($stream); + return $data ?: ''; +} + +function printSummary(array $stats, float $startTime): void +{ + $elapsed = formatElapsedTime(microtime(true) - $startTime); + + logMessage("\n=== DNS cron job completed ==="); + logMessage("Domains checked: {$stats['checked']}"); + logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}"); + logMessage("Crt.sh fetched: {$stats['crtsh_fetched']}"); + logMessage("Crt.sh skipped (cached): {$stats['crtsh_skipped']}"); + logMessage("Changes detected: {$stats['changes_detected']}"); + logMessage("Records added: {$stats['records_added']}"); + logMessage("Records removed: {$stats['records_removed']}"); + logMessage("Records changed: {$stats['records_changed']}"); + logMessage("External notifications: {$stats['notifications_sent']}"); + logMessage("In-app notifications: {$stats['in_app_notifications']}"); + logMessage("Errors: {$stats['errors']}"); + logMessage("Execution time: $elapsed"); + + if (!empty($stats['domains_with_changes'])) { + logMessage("\n--- Domains with DNS changes ---"); + foreach ($stats['domains_with_changes'] as $info) { + logMessage(" {$info['domain']}: +{$info['added']} added, -{$info['removed']} removed, ~{$info['changed']} changed"); + } + } + + logMessage("==========================\n"); +} diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index b39f626..89bbf43 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -141,6 +141,7 @@ CREATE TABLE IF NOT EXISTS domains ( updated_date DATE, abuse_email VARCHAR(255), last_checked TIMESTAMP NULL, + dns_last_checked TIMESTAMP NULL, status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active', whois_data JSON, notes TEXT, @@ -341,6 +342,32 @@ CREATE TABLE IF NOT EXISTS tld_import_logs ( INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ===================================================== +-- DNS MONITORING +-- ===================================================== + +-- DNS records table for tracking DNS record changes +CREATE TABLE IF NOT EXISTS dns_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_id INT NOT NULL, + record_type VARCHAR(10) NOT NULL COMMENT 'A, AAAA, MX, TXT, NS, CNAME, SOA', + host VARCHAR(255) NOT NULL DEFAULT '@', + value TEXT NOT NULL, + ttl INT NULL, + priority INT NULL COMMENT 'MX priority', + is_cloudflare BOOLEAN DEFAULT FALSE, + raw_data JSON NULL COMMENT 'Full record data from dns_get_record()', + first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + 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, + INDEX idx_domain_id (domain_id), + INDEX idx_record_type (record_type), + INDEX idx_domain_type (domain_id, record_type), + INDEX idx_last_seen (last_seen_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ===================================================== -- SYSTEM SETTINGS -- ===================================================== @@ -397,6 +424,13 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES -- User isolation settings ('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'), +-- Domain view settings +('domain_view_template', 'detailed', 'string', 'Domain view template: detailed or default'), + +-- DNS monitoring settings +('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'), +('last_dns_check_run', NULL, 'datetime', 'Last time DNS 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/027_add_dns_monitoring.sql b/database/migrations/027_add_dns_monitoring.sql new file mode 100644 index 0000000..a815d12 --- /dev/null +++ b/database/migrations/027_add_dns_monitoring.sql @@ -0,0 +1,39 @@ +-- DNS Monitoring - Add dns_records table for tracking DNS record changes +CREATE TABLE IF NOT EXISTS dns_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + domain_id INT NOT NULL, + record_type VARCHAR(10) NOT NULL COMMENT 'A, AAAA, MX, TXT, NS, CNAME, SOA', + host VARCHAR(255) NOT NULL DEFAULT '@', + value TEXT NOT NULL, + ttl INT NULL, + priority INT NULL COMMENT 'MX priority', + is_cloudflare BOOLEAN DEFAULT FALSE, + raw_data JSON NULL COMMENT 'Full record data from dns_get_record()', + first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + 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, + INDEX idx_domain_id (domain_id), + INDEX idx_record_type (record_type), + INDEX idx_domain_type (domain_id, record_type), + INDEX idx_last_seen (last_seen_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Track when DNS was last checked per domain +ALTER TABLE domains ADD COLUMN dns_last_checked TIMESTAMP NULL AFTER last_checked; + +-- crt.sh subdomain fetch tracking +ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL COMMENT 'Last time crt.sh subdomains were fetched for this domain'; + +-- Toggle DNS monitoring per domain (WHOIS and DNS are separate) +ALTER TABLE domains ADD COLUMN dns_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=DNS monitoring active, 0=disabled' AFTER is_active; + +-- Add DNS check interval setting +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') +ON DUPLICATE KEY UPDATE setting_key=setting_key; + +INSERT INTO migrations (migration) VALUES ('027_add_dns_monitoring.sql') +ON DUPLICATE KEY UPDATE migration=migration; diff --git a/routes/web.php b/routes/web.php index 145c82e..a39a2fc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -84,7 +84,9 @@ $router->get('/domains/{id}', [DomainController::class, 'show']); $router->get('/domains/{id}/edit', [DomainController::class, 'edit']); $router->post('/domains/{id}/update', [DomainController::class, 'update']); $router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']); -$router->post('/domains/{id}/refresh', [DomainController::class, 'refresh']); +$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']); +$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']); +$router->post('/domains/{id}/refresh-all', [DomainController::class, 'refreshAll']); $router->post('/domains/{id}/delete', [DomainController::class, 'delete']); // Notification Groups