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 @@ -
+ Choose which template to use when viewing domain details at /domains/{id}
+
Last cronjob run times are shown in the System tab
php cron/check_domains.php
+ Domain / WHOIS check
+php cron/check_domains.php
+ DNS record check
+php cron/check_dns.php
+ 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
+Domain / WHOIS
+check_domains.php
+DNS
+check_dns.php
+Update the path to match your server installation
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