diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 2120e19..e6fe7b9 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -312,16 +312,20 @@ class DomainController extends Controller $skipped = 0; $errors = []; + $invalidImported = 0; + foreach ($domainsData as $row) { - $domainName = strtolower(trim($row['domain_name'] ?? '')); + $domainName = trim($row['domain_name'] ?? ''); if (empty($domainName)) { continue; } - // Remove protocol/www - $domainName = preg_replace('#^https?://#', '', $domainName); - $domainName = preg_replace('#^www\.#', '', $domainName); - $domainName = rtrim($domainName, '/'); + $domainCheck = \App\Helpers\InputValidator::validateRootDomain($domainName); + if (!$domainCheck['valid']) { + $invalidImported++; + continue; + } + $domainName = $domainCheck['domain']; if ($this->domainModel->existsByDomain($domainName)) { $skipped++; @@ -394,6 +398,7 @@ class DomainController extends Controller $msg = "{$added} domain(s) imported successfully"; if ($skipped > 0) $msg .= ", {$skipped} skipped (already exist)"; + if ($invalidImported > 0) $msg .= ", {$invalidImported} rejected (not root domains)"; if (!empty($errors)) $msg .= ", " . count($errors) . " failed"; $_SESSION['success'] = $msg; $this->redirect('/domains'); @@ -437,24 +442,19 @@ class DomainController extends Controller // CSRF Protection $this->verifyCsrf('/domains/create'); - $domainName = trim($_POST['domain_name'] ?? ''); + $domainName = \App\Helpers\InputValidator::sanitizeDomainInput(trim($_POST['domain_name'] ?? '')); $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; $tagsInput = trim($_POST['tags'] ?? ''); $userId = \Core\Auth::id(); - // Validate - if (empty($domainName)) { - $_SESSION['error'] = 'Domain name is required'; - $this->redirect('/domains/create'); - return; - } - - // Validate domain format - if (!\App\Helpers\InputValidator::validateDomain($domainName)) { - $_SESSION['error'] = 'Invalid domain name format (e.g., example.com)'; + // Validate root domain (not a subdomain, respects multi-level TLDs) + $domainCheck = \App\Helpers\InputValidator::validateRootDomain($domainName); + if (!$domainCheck['valid']) { + $_SESSION['error'] = $domainCheck['error']; $this->redirect('/domains/create'); return; } + $domainName = $domainCheck['domain']; // Validate tags $tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput); @@ -799,9 +799,8 @@ class DomainController extends Controller $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); + $records = $dnsService->refreshExisting($domain['domain_name'], $existingHosts); $totalRecords = array_sum(array_map('count', $records)); if ($totalRecords === 0) { @@ -811,30 +810,7 @@ class DomainController extends Controller 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); - } - } - } + $this->enrichIpDetails($records, $dnsService); $stats = $dnsModel->saveSnapshot($id, $records); $this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]); @@ -1409,8 +1385,20 @@ class DomainController extends Controller } } - // Split by new lines and clean - $domainNames = array_filter(array_map('trim', explode("\n", $domainsText))); + // Split by new lines, sanitize each, and filter empties + $rawLines = array_filter(array_map('trim', explode("\n", $domainsText))); + $domainNames = []; + $invalidDomains = []; + foreach ($rawLines as $line) { + $cleaned = \App\Helpers\InputValidator::sanitizeDomainInput($line); + if (empty($cleaned)) continue; + $check = \App\Helpers\InputValidator::validateRootDomain($cleaned); + if (!$check['valid']) { + $invalidDomains[] = $cleaned; + continue; + } + $domainNames[] = $check['domain']; + } $added = 0; $skipped = 0; @@ -1423,6 +1411,7 @@ class DomainController extends Controller $logger->info('Bulk domain add started', [ 'user_id' => $userId, 'domain_count' => count($domainNames), + 'invalid_count' => count($invalidDomains), 'notification_group_id' => $groupId, 'tags' => $tags ]); @@ -1497,6 +1486,7 @@ class DomainController extends Controller $message = "Added $added domain(s)"; if ($skipped > 0) $message .= ", skipped $skipped duplicate(s)"; + if (count($invalidDomains) > 0) $message .= ", " . count($invalidDomains) . " rejected (not root domains)"; if (count($errors) > 0) $message .= ", failed to add " . count($errors) . " domain(s)"; if ($availableCount > 0) { @@ -2048,12 +2038,42 @@ class DomainController extends Controller return; } + $logger = new \App\Services\Logger('transfer'); + try { - // Transfer domain $this->domainModel->update($domainId, ['user_id' => $targetUserId]); + + $groupUnlinked = false; + if (!empty($domain['notification_group_id'])) { + $groupModel = new \App\Models\NotificationGroup(); + $group = $groupModel->find($domain['notification_group_id']); + if ($group && $group['user_id'] != $targetUserId) { + $this->domainModel->update($domainId, ['notification_group_id' => null]); + $groupUnlinked = true; + } + } + + $tagModel = new \App\Models\Tag(); + $tagsRemoved = $tagModel->removeOtherUserTagsFromDomain($domainId, $targetUserId); + + $logger->info('Domain transferred', [ + 'domain_id' => $domainId, + 'domain_name' => $domain['domain_name'], + 'from_user_id' => $domain['user_id'], + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'group_unlinked' => $groupUnlinked, + 'tags_removed' => $tagsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); $_SESSION['success'] = "Domain '{$domain['domain_name']}' transferred to {$targetUser['username']}"; } catch (\Exception $e) { + $logger->error('Domain transfer failed', [ + 'domain_id' => $domainId, + 'to_user_id' => $targetUserId, + 'error' => $e->getMessage(), + ]); $_SESSION['error'] = 'Failed to transfer domain. Please try again.'; } @@ -2092,19 +2112,59 @@ class DomainController extends Controller return; } + $groupModel = new \App\Models\NotificationGroup(); + $tagModel = new \App\Models\Tag(); + $logger = new \App\Services\Logger('transfer'); + $transferred = 0; foreach ($domainIds as $domainId) { $domainId = (int)$domainId; if ($domainId > 0) { try { + $domain = $this->domainModel->find($domainId); $this->domainModel->update($domainId, ['user_id' => $targetUserId]); + + $groupUnlinked = false; + if ($domain && !empty($domain['notification_group_id'])) { + $group = $groupModel->find($domain['notification_group_id']); + if ($group && $group['user_id'] != $targetUserId) { + $this->domainModel->update($domainId, ['notification_group_id' => null]); + $groupUnlinked = true; + } + } + + $tagsRemoved = $tagModel->removeOtherUserTagsFromDomain($domainId, $targetUserId); + + $logger->info('Domain transferred (bulk)', [ + 'domain_id' => $domainId, + 'domain_name' => $domain['domain_name'] ?? 'unknown', + 'from_user_id' => $domain['user_id'] ?? null, + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'group_unlinked' => $groupUnlinked, + 'tags_removed' => $tagsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); + $transferred++; } catch (\Exception $e) { - // Continue with other domains + $logger->error('Domain transfer failed (bulk)', [ + 'domain_id' => $domainId, + 'to_user_id' => $targetUserId, + 'error' => $e->getMessage(), + ]); } } } + $logger->info('Bulk domain transfer completed', [ + 'transferred' => $transferred, + 'total_requested' => count($domainIds), + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'admin_user_id' => \Core\Auth::id(), + ]); + $_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}"; $this->redirect('/domains'); } @@ -2142,6 +2202,295 @@ class DomainController extends Controller $this->redirectBackToDomain($id, '#dns'); } + /** + * Discover DNS records via Quick Scan (synchronous) or Deep Scan (background). + */ + public function discoverDns($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logger = new \App\Services\Logger('dns'); + $mode = $_POST['mode'] ?? 'quick'; + + if ($mode === 'deep') { + $domainName = escapeshellarg($domain['domain_name']); + $scriptPath = realpath(__DIR__ . '/../../cron/discover_dns.php'); + + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $cmd = "start /b php " . escapeshellarg($scriptPath) . " --domain $domainName"; + } else { + $cmd = "nohup php " . escapeshellarg($scriptPath) . " --domain $domainName > /dev/null 2>&1 &"; + } + exec($cmd); + + $logger->info('Deep DNS scan started (background)', [ + 'domain_name' => $domain['domain_name'], + ]); + $_SESSION['info'] = 'Deep scan started in background. New records will appear when discovery completes — refresh the page to see them.'; + } else { + $dnsService = new \App\Services\DnsService(); + $dnsModel = new \App\Models\DnsRecord(); + + $records = $dnsService->quickScan($domain['domain_name']); + $totalRecords = array_sum(array_map('count', $records)); + + $this->enrichIpDetails($records, $dnsService); + + $stats = $dnsModel->saveSnapshot($id, $records); + $this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]); + + $logger->info('Quick DNS scan completed', [ + 'domain_name' => $domain['domain_name'], + 'total' => $totalRecords, + 'added' => $stats['added'], + ]); + $_SESSION['success'] = "Quick scan complete: {$totalRecords} records found"; + } + + $this->redirectBackToDomain($id, '#dns'); + } + + /** + * Add a single DNS record manually. + */ + public function addDnsRecord($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logger = new \App\Services\Logger('dns'); + $dnsModel = new \App\Models\DnsRecord(); + + $type = strtoupper(trim($_POST['record_type'] ?? '')); + $host = trim($_POST['host'] ?? '@'); + $value = trim($_POST['value'] ?? ''); + $ttl = !empty($_POST['ttl']) ? (int)$_POST['ttl'] : 3600; + $priority = !empty($_POST['priority']) ? (int)$_POST['priority'] : null; + + $validTypes = ['A', 'AAAA', 'MX', 'TXT', 'NS', 'CNAME', 'SOA', 'SRV', 'CAA']; + if (!in_array($type, $validTypes) || $value === '') { + $_SESSION['error'] = 'Invalid record type or missing value'; + $this->redirectBackToDomain($id, '#dns'); + return; + } + + $dnsModel->addManualRecord($id, $type, $host, $value, $ttl, $priority); + + $logger->info('Manual DNS record added', [ + 'domain_name' => $domain['domain_name'], + 'type' => $type, + 'host' => $host, + 'value' => $value, + ]); + + $_SESSION['success'] = "DNS record added: {$type} {$host}"; + $this->redirectBackToDomain($id, '#dns'); + } + + /** + * Delete a single DNS record. + */ + public function deleteDnsRecord($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $id = (int)($params['id'] ?? 0); + $recordId = (int)($params['recordId'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logger = new \App\Services\Logger('dns'); + $dnsModel = new \App\Models\DnsRecord(); + + if ($dnsModel->deleteRecord($recordId, $id)) { + $logger->info('DNS record deleted', [ + 'domain_name' => $domain['domain_name'], + 'record_id' => $recordId, + ]); + $_SESSION['success'] = 'DNS record deleted'; + } else { + $_SESSION['error'] = 'DNS record not found'; + } + + $this->redirectBackToDomain($id, '#dns'); + } + + /** + * Bulk delete DNS records. + */ + public function bulkDeleteDnsRecords($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logger = new \App\Services\Logger('dns'); + $dnsModel = new \App\Models\DnsRecord(); + + $ids = $_POST['record_ids'] ?? []; + if (!is_array($ids)) { + $ids = []; + } + $ids = array_map('intval', $ids); + + $count = $dnsModel->bulkDeleteRecords($ids, $id); + + $logger->info('Bulk DNS records deleted', [ + 'domain_name' => $domain['domain_name'], + 'count' => $count, + ]); + + $_SESSION['success'] = "Deleted {$count} DNS record(s)"; + $this->redirectBackToDomain($id, '#dns'); + } + + /** + * Import DNS records from a BIND zone file. + */ + public function importDnsZone($params = []) + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/domains'); + return; + } + + $this->verifyCsrf('/domains'); + + $id = (int)($params['id'] ?? 0); + $domain = $this->checkDomainAccess($id); + + if (!$domain) { + $_SESSION['error'] = 'Domain not found'; + $this->redirect('/domains'); + return; + } + + $logger = new \App\Services\Logger('dns'); + $dnsService = new \App\Services\DnsService(); + $dnsModel = new \App\Models\DnsRecord(); + + $content = ''; + if (!empty($_FILES['zone_file']['tmp_name'])) { + $content = file_get_contents($_FILES['zone_file']['tmp_name']); + } elseif (!empty($_POST['zone_content'])) { + $content = $_POST['zone_content']; + } + + if (trim($content) === '') { + $_SESSION['error'] = 'No zone file content provided'; + $this->redirectBackToDomain($id, '#dns'); + return; + } + + try { + $parsed = $dnsService->parseBindZone($content, $domain['domain_name']); + $totalParsed = array_sum(array_map('count', $parsed)); + + if ($totalParsed === 0) { + $_SESSION['error'] = 'No valid DNS records found in zone file'; + $this->redirectBackToDomain($id, '#dns'); + return; + } + + $count = $dnsModel->addImportedRecords($id, $parsed); + + $logger->info('DNS zone file imported', [ + 'domain_name' => $domain['domain_name'], + 'parsed' => $totalParsed, + 'imported' => $count, + ]); + + $_SESSION['success'] = "Imported {$count} DNS records from zone file"; + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to parse zone file: ' . $e->getMessage(); + $logger->error('DNS zone import failed', [ + 'domain_name' => $domain['domain_name'], + 'error' => $e->getMessage(), + ]); + } + + $this->redirectBackToDomain($id, '#dns'); + } + + /** + * Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo). + */ + private function enrichIpDetails(array &$records, \App\Services\DnsService $dnsService): void + { + $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); + } + } + } + } + /** * Add a monitored SSL hostname and fetch its certificate immediately. */ diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 3fa729d..0f2246e 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -59,8 +59,9 @@ class InstallerController extends Controller '026_update_app_version_v1.1.4.sql', '027_add_dns_monitoring.sql', '028_add_ssl_monitoring.sql', + '029_add_dns_record_source.sql', ]; - + try { $pdo = \Core\Database::getConnection(); @@ -204,9 +205,10 @@ class InstallerController extends Controller '026_update_app_version_v1.1.4.sql', '027_add_dns_monitoring.sql', '028_add_ssl_monitoring.sql', + '029_add_dns_record_source.sql', ]; } - + // If no migrations executed and no data - fresh install (use consolidated) if (empty($executed)) { return $freshInstallMigration; @@ -429,8 +431,9 @@ class InstallerController extends Controller '026_update_app_version_v1.1.4.sql', '027_add_dns_monitoring.sql', '028_add_ssl_monitoring.sql', + '029_add_dns_record_source.sql', ]; - + $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); foreach ($allIndividualMigrations as $migration) { try { @@ -664,7 +667,7 @@ class InstallerController extends Controller // Fallback: detect "to" version from which migrations were run if ($toVersion === $fromVersion) { - if (in_array('028_add_ssl_monitoring.sql', $executed)) { + if (in_array('029_add_dns_record_source.sql', $executed)) { $toVersion = '1.1.5'; } elseif (in_array('026_update_app_version_v1.1.4.sql', $executed)) { $toVersion = '1.1.4'; diff --git a/app/Controllers/NotificationGroupController.php b/app/Controllers/NotificationGroupController.php index 23860ba..033f7a7 100644 --- a/app/Controllers/NotificationGroupController.php +++ b/app/Controllers/NotificationGroupController.php @@ -1065,16 +1065,35 @@ class NotificationGroupController extends Controller return; } + $logger = new \App\Services\Logger('transfer'); + try { - // Transfer group $this->groupModel->update($groupId, ['user_id' => $targetUserId]); - // Also transfer all domains in this group $domainModel = new \App\Models\Domain(); - $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]); + $domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]); + + $tagModel = new \App\Models\Tag(); + $tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId); + + $logger->info('Notification group transferred', [ + 'group_id' => $groupId, + 'group_name' => $group['name'], + 'from_user_id' => $group['user_id'], + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'domains_transferred' => $domainsTransferred, + 'tags_removed' => $tagsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); $_SESSION['success'] = "Group '{$group['name']}' and its domains transferred to {$targetUser['username']}"; } catch (\Exception $e) { + $logger->error('Notification group transfer failed', [ + 'group_id' => $groupId, + 'to_user_id' => $targetUserId, + 'error' => $e->getMessage(), + ]); $_SESSION['error'] = 'Failed to transfer group. Please try again.'; } @@ -1113,25 +1132,51 @@ class NotificationGroupController extends Controller return; } + $domainModel = new \App\Models\Domain(); + $tagModel = new \App\Models\Tag(); + $logger = new \App\Services\Logger('transfer'); + $transferred = 0; foreach ($groupIds as $groupId) { $groupId = (int)$groupId; if ($groupId > 0) { try { - // Transfer group + $group = $this->groupModel->find($groupId); $this->groupModel->update($groupId, ['user_id' => $targetUserId]); - // Also transfer all domains in this group - $domainModel = new \App\Models\Domain(); - $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]); + $domainsTransferred = $domainModel->updateWhere(['notification_group_id' => $groupId], ['user_id' => $targetUserId]); + $tagsRemoved = $tagModel->removeOtherUserTagsFromDomainsByGroup($groupId, $targetUserId); + + $logger->info('Notification group transferred (bulk)', [ + 'group_id' => $groupId, + 'group_name' => $group['name'] ?? 'unknown', + 'from_user_id' => $group['user_id'] ?? null, + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'domains_transferred' => $domainsTransferred, + 'tags_removed' => $tagsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); $transferred++; } catch (\Exception $e) { - // Continue with other groups + $logger->error('Notification group transfer failed (bulk)', [ + 'group_id' => $groupId, + 'to_user_id' => $targetUserId, + 'error' => $e->getMessage(), + ]); } } } + $logger->info('Bulk notification group transfer completed', [ + 'transferred' => $transferred, + 'total_requested' => count($groupIds), + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'admin_user_id' => \Core\Auth::id(), + ]); + $_SESSION['success'] = "$transferred group(s) and their domains transferred to {$targetUser['username']}"; $this->redirect('/groups'); } diff --git a/app/Controllers/TagController.php b/app/Controllers/TagController.php index 0aa337e..9c891fb 100644 --- a/app/Controllers/TagController.php +++ b/app/Controllers/TagController.php @@ -570,11 +570,19 @@ class TagController extends Controller 'showing_to' => min($offset + $perPage, $total) ]; + $users = []; + if (\Core\Auth::isAdmin()) { + $userModel = new \App\Models\User(); + $currentUserId = \Core\Auth::id(); + $users = array_values(array_filter($userModel->all(), fn($u) => (int)$u['id'] !== $currentUserId)); + } + $this->view('tags/view', [ 'tag' => $tag, 'domains' => $paginatedDomains, 'filters' => $filters, - 'pagination' => $pagination + 'pagination' => $pagination, + 'users' => $users, ]); } @@ -770,6 +778,12 @@ class TagController extends Controller return; } + if ($tag['user_id'] === null) { + $_SESSION['error'] = 'Global tags cannot be transferred'; + $this->redirect('/tags'); + return; + } + $userModel = new \App\Models\User(); $targetUser = $userModel->find($targetUserId); if (!$targetUser) { @@ -778,9 +792,28 @@ class TagController extends Controller return; } + $logger = new \App\Services\Logger('transfer'); + if ($this->tagModel->update($tagId, ['user_id' => $targetUserId])) { + $domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId); + + $logger->info('Tag transferred', [ + 'tag_id' => $tagId, + 'tag_name' => $tag['name'], + 'from_user_id' => $tag['user_id'], + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'domain_associations_removed' => $domainsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); + $_SESSION['success'] = "Tag '{$tag['name']}' transferred to {$targetUser['username']}"; } else { + $logger->error('Tag transfer failed', [ + 'tag_id' => $tagId, + 'to_user_id' => $targetUserId, + 'error' => 'Model update returned false', + ]); $_SESSION['error'] = 'Failed to transfer tag. Please try again.'; } @@ -818,18 +851,50 @@ class TagController extends Controller return; } + $logger = new \App\Services\Logger('transfer'); + $transferred = 0; + $skippedGlobal = 0; foreach ($tagIds as $tagId) { $tagId = (int)$tagId; if ($tagId > 0) { $tag = $this->tagModel->find($tagId); + if ($tag && $tag['user_id'] === null) { + $skippedGlobal++; + continue; + } if ($tag && $this->tagModel->update($tagId, ['user_id' => $targetUserId])) { + $domainsRemoved = $this->tagModel->removeTagFromOtherUserDomains($tagId, $targetUserId); + + $logger->info('Tag transferred (bulk)', [ + 'tag_id' => $tagId, + 'tag_name' => $tag['name'], + 'from_user_id' => $tag['user_id'], + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'domain_associations_removed' => $domainsRemoved, + 'admin_user_id' => \Core\Auth::id(), + ]); + $transferred++; } } } - $_SESSION['success'] = $transferred . ' tag(s) transferred to ' . $targetUser['username']; + $logger->info('Bulk tag transfer completed', [ + 'transferred' => $transferred, + 'skipped_global' => $skippedGlobal, + 'total_requested' => count($tagIds), + 'to_user_id' => $targetUserId, + 'to_username' => $targetUser['username'], + 'admin_user_id' => \Core\Auth::id(), + ]); + + $msg = $transferred . ' tag(s) transferred to ' . $targetUser['username']; + if ($skippedGlobal > 0) { + $msg .= " ($skippedGlobal global tag(s) skipped)"; + } + $_SESSION['success'] = $msg; $this->redirect('/tags'); } } diff --git a/app/Helpers/CronHelper.php b/app/Helpers/CronHelper.php new file mode 100644 index 0000000..e877ecd --- /dev/null +++ b/app/Helpers/CronHelper.php @@ -0,0 +1,90 @@ +logFile = $logFile; + } + + /** + * Write a timestamped message to the log file and echo it. + */ + public function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + $line = "[{$timestamp}] {$message}\n"; + file_put_contents($this->logFile, $line, FILE_APPEND); + echo $line; + } + + /** + * Log elapsed time since a given microtime start. + */ + public function logTimeSince(float $since, string $prefix = ' ⏱ '): void + { + $this->log($prefix . self::formatDuration(microtime(true) - $since)); + } + + /** + * Short human-readable duration: "3.2s" or "2m 14.1s". + */ + public static function formatDuration(float $seconds): string + { + if ($seconds < 60) { + return sprintf('%.1fs', $seconds); + } + + $minutes = (int) floor($seconds / 60); + $remaining = $seconds - ($minutes * 60); + return $minutes . 'm ' . sprintf('%.1fs', $remaining); + } + + /** + * Verbose elapsed time: "3.25 seconds", "2 minutes 14.25 seconds", etc. + */ + public static function formatElapsedTime(float $seconds): string + { + if ($seconds < 60) { + return sprintf('%.2f seconds', $seconds); + } + + if ($seconds < 3600) { + $minutes = (int) floor($seconds / 60); + $remaining = $seconds - ($minutes * 60); + return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining); + } + + $hours = (int) floor($seconds / 3600); + $minutes = (int) floor(($seconds - ($hours * 3600)) / 60); + $remaining = $seconds - ($hours * 3600) - ($minutes * 60); + return sprintf( + '%d hour%s %d minute%s %.2f seconds', + $hours, + $hours !== 1 ? 's' : '', + $minutes, + $minutes !== 1 ? 's' : '', + $remaining + ); + } + + /** + * Check whether a hostname resolves at all (SOA, A, or AAAA). + */ + public static function hostnameResolves(string $hostname): bool + { + return @checkdnsrr($hostname, 'SOA') + || @checkdnsrr($hostname, 'A') + || @checkdnsrr($hostname, 'AAAA'); + } +} diff --git a/app/Helpers/DomainHelper.php b/app/Helpers/DomainHelper.php index a54d5ef..a39ed02 100644 --- a/app/Helpers/DomainHelper.php +++ b/app/Helpers/DomainHelper.php @@ -89,16 +89,17 @@ class DomainHelper /** * Get CSS class for expiry date styling + * Includes dark: variants for visibility on dark theme */ private static function getExpiryClass(?int $daysLeft): string { if ($daysLeft === null) return ''; - if ($daysLeft < 0) return 'text-red-600 font-semibold'; - if ($daysLeft <= 30) return 'text-orange-600 font-semibold'; - if ($daysLeft <= 90) return 'text-yellow-600'; + if ($daysLeft < 0) return 'text-red-600 dark:text-red-400 font-semibold'; + if ($daysLeft <= 30) return 'text-orange-600 dark:text-orange-400 font-semibold'; + if ($daysLeft <= 90) return 'text-yellow-600 dark:text-yellow-400'; - return ''; + return 'text-gray-600 dark:text-slate-400'; } /** diff --git a/app/Helpers/EmailHelper.php b/app/Helpers/EmailHelper.php index fb8fbd8..efd1b7f 100644 --- a/app/Helpers/EmailHelper.php +++ b/app/Helpers/EmailHelper.php @@ -507,9 +507,14 @@ class EmailHelper /** * Get email subject based on data + * Uses explicit subject when provided (DNS, SSL, etc.), otherwise domain expiration logic */ public static function getEmailSubject(array $data): string { + if (!empty($data['subject'])) { + return $data['subject']; + } + if (isset($data['domain'])) { $daysLeft = $data['days_left'] ?? null; if ($daysLeft === null) { diff --git a/app/Helpers/InputValidator.php b/app/Helpers/InputValidator.php index a896438..1586b0a 100644 --- a/app/Helpers/InputValidator.php +++ b/app/Helpers/InputValidator.php @@ -17,17 +17,90 @@ class InputValidator */ public static function validateDomain(string $domain): bool { - // Check length (max 253 characters per RFC 1035) if (strlen($domain) > 253 || strlen($domain) < 3) { return false; } - // Validate domain format - // Allows: example.com, sub.example.com, example.co.uk - // Pattern: alphanumeric with hyphens, dots between labels, valid TLD return (bool)preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain); } + /** + * Sanitize raw domain input — strips protocol, www prefix, trailing dots/slashes, paths. + * + * @return string Cleaned, lowercased domain (may still be invalid) + */ + public static function sanitizeDomainInput(string $input): string + { + $input = strtolower(trim($input)); + + $input = preg_replace('#^https?://#', '', $input); + $input = preg_replace('#/.*$#', '', $input); + $input = rtrim($input, '.'); + $input = trim($input); + + if (str_starts_with($input, 'www.')) { + $stripped = substr($input, 4); + if (substr_count($stripped, '.') >= 1) { + $input = $stripped; + } + } + + return $input; + } + + /** + * Validate that a domain is a registrable root domain (not a subdomain). + * Uses the tld_registry table to identify multi-level TLDs like .co.uk. + * + * @return array{valid: bool, domain: string, error: string|null} + */ + public static function validateRootDomain(string $domain): array + { + $domain = self::sanitizeDomainInput($domain); + + if (empty($domain)) { + return ['valid' => false, 'domain' => '', 'error' => 'Domain name is required']; + } + + if (!self::validateDomain($domain)) { + return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain format: $domain"]; + } + + $parts = explode('.', $domain); + if (count($parts) < 2) { + return ['valid' => false, 'domain' => $domain, 'error' => "Invalid domain: $domain"]; + } + + $tldModel = new \App\Models\TldRegistry(); + + $matchedTld = null; + for ($i = 1; $i < count($parts); $i++) { + $candidate = '.' . implode('.', array_slice($parts, $i)); + $tld = $tldModel->findByTld($candidate); + if ($tld) { + $matchedTld = $candidate; + $labelCount = $i; + break; + } + } + + if (!$matchedTld) { + $matchedTld = '.' . $parts[count($parts) - 1]; + $labelCount = count($parts) - 1; + } + + if ($labelCount !== 1) { + $rootDomain = $parts[$labelCount - 1] . $matchedTld; + return [ + 'valid' => false, + 'domain' => $domain, + 'error' => "\"$domain\" looks like a subdomain. Did you mean the root domain \"$rootDomain\"?" + ]; + } + + return ['valid' => true, 'domain' => $domain, 'error' => null]; + } + /** * Validate text field length * diff --git a/app/Models/DnsRecord.php b/app/Models/DnsRecord.php index 231e863..6a788b7 100644 --- a/app/Models/DnsRecord.php +++ b/app/Models/DnsRecord.php @@ -80,10 +80,12 @@ class DnsRecord extends Model /** * Save a snapshot of DNS records for a domain. - * Updates existing records, inserts new ones, removes stale ones. + * Updates existing records, inserts new ones. + * Only auto-removes stale records whose source is 'discovered' — manual and imported records are preserved. + * * @return array{added: int, updated: int, removed: int} */ - public function saveSnapshot(int $domainId, array $groupedRecords): array + public function saveSnapshot(int $domainId, array $groupedRecords, string $source = 'discovered'): array { $stats = ['added' => 0, 'updated' => 0, 'removed' => 0]; $now = date('Y-m-d H:i:s'); @@ -108,27 +110,26 @@ class DnsRecord extends Model $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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, 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]); + $stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $source, $now, $now, $now, $now]); $seenIds[] = (int)$this->db->lastInsertId(); $stats['added']++; } } } - // Remove records that no longer exist + // Only auto-remove stale discovered records — manual/imported records are never auto-deleted 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})" + "DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered' 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 = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered'"); $deleteStmt->execute([$domainId]); $stats['removed'] = $deleteStmt->rowCount(); } @@ -213,4 +214,82 @@ class DnsRecord extends Model return $grouped; } + + /** + * Delete a single DNS record belonging to a domain. + */ + public function deleteRecord(int $id, int $domainId): bool + { + $stmt = $this->db->prepare("DELETE FROM dns_records WHERE id = ? AND domain_id = ?"); + $stmt->execute([$id, $domainId]); + return $stmt->rowCount() > 0; + } + + /** + * Bulk delete DNS records belonging to a domain. + * + * @return int Number of records deleted + */ + public function bulkDeleteRecords(array $ids, int $domainId): int + { + if (empty($ids)) { + return 0; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $this->db->prepare( + "DELETE FROM dns_records WHERE domain_id = ? AND id IN ({$placeholders})" + ); + $stmt->execute(array_merge([$domainId], $ids)); + return $stmt->rowCount(); + } + + /** + * Add a single manually-created DNS record. + */ + public function addManualRecord(int $domainId, string $type, string $host, string $value, ?int $ttl = null, ?int $priority = null): int + { + $now = date('Y-m-d H:i:s'); + $stmt = $this->db->prepare( + "INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, source, first_seen_at, last_seen_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 'manual', ?, ?, ?, ?)" + ); + $stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $now, $now, $now, $now]); + return (int)$this->db->lastInsertId(); + } + + /** + * Bulk-insert records from a zone file import. + * + * @return int Number of records imported + */ + public function addImportedRecords(int $domainId, array $groupedRecords): int + { + $now = date('Y-m-d H:i:s'); + $count = 0; + + 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) { + continue; + } + + $stmt = $this->db->prepare( + "INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?, ?)" + ); + $stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]); + $count++; + } + } + + return $count; + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index a8cfb83..48df8c5 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -282,6 +282,66 @@ class Tag extends Model } } + /** + * Remove custom (non-global) tags from a domain that don't belong to the specified user. + * Global tags (user_id IS NULL) are preserved. + */ + public function removeOtherUserTagsFromDomain(int $domainId, int $keepUserId): int + { + $sql = "DELETE dt FROM domain_tags dt + JOIN tags t ON dt.tag_id = t.id + WHERE dt.domain_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$domainId, $keepUserId]); + $affected = $stmt->rowCount(); + + if ($affected > 0) { + $this->updateAllUsageCounts(); + } + + return $affected; + } + + /** + * Remove domain_tags for a tag where the domain doesn't belong to the specified user. + */ + public function removeTagFromOtherUserDomains(int $tagId, int $keepUserId): int + { + $sql = "DELETE dt FROM domain_tags dt + JOIN domains d ON dt.domain_id = d.id + WHERE dt.tag_id = ? AND d.user_id != ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$tagId, $keepUserId]); + $affected = $stmt->rowCount(); + + if ($affected > 0) { + $this->updateUsageCount($tagId); + } + + return $affected; + } + + /** + * Remove custom (non-global) tags from all domains in a notification group + * that don't belong to the specified user. + */ + public function removeOtherUserTagsFromDomainsByGroup(int $groupId, int $keepUserId): int + { + $sql = "DELETE dt FROM domain_tags dt + JOIN tags t ON dt.tag_id = t.id + JOIN domains d ON dt.domain_id = d.id + WHERE d.notification_group_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?"; + $stmt = $this->db->prepare($sql); + $stmt->execute([$groupId, $keepUserId]); + $affected = $stmt->rowCount(); + + if ($affected > 0) { + $this->updateAllUsageCounts(); + } + + return $affected; + } + /** * Get available colors for tags */ diff --git a/app/Services/Channels/DiscordChannel.php b/app/Services/Channels/DiscordChannel.php index 53d8b16..73f94b1 100644 --- a/app/Services/Channels/DiscordChannel.php +++ b/app/Services/Channels/DiscordChannel.php @@ -52,10 +52,11 @@ class DiscordChannel implements NotificationChannelInterface private function createEmbed(string $message, array $data): array { + $title = $data['subject'] ?? '🔔 Domain Monitor Alert'; $color = $this->getColorByDaysLeft($data['days_left'] ?? null); $embed = [ - 'title' => '🔔 Domain Expiration Alert', + 'title' => $title, 'description' => $message, 'color' => $color, 'timestamp' => date('c'), @@ -65,23 +66,22 @@ class DiscordChannel implements NotificationChannelInterface ]; if (isset($data['domain'])) { - $embed['fields'] = [ - [ - 'name' => 'Domain', - 'value' => $data['domain'], - 'inline' => true - ], - [ - 'name' => 'Days Left', - 'value' => (string) ($data['days_left'] ?? 'N/A'), - 'inline' => true - ], - [ - 'name' => 'Expiration Date', - 'value' => $data['expiration_date'] ?? 'N/A', - 'inline' => true - ] + $fields = [ + ['name' => 'Domain', 'value' => $data['domain'], 'inline' => true] ]; + + // Only add expiration fields for domain expiration alerts + if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) { + $fields[] = ['name' => 'Days Left', 'value' => (string) ($data['days_left'] ?? 'N/A'), 'inline' => true]; + $fields[] = ['name' => 'Expiration Date', 'value' => $data['expiration_date'] ?? 'N/A', 'inline' => true]; + } elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) { + $fields[] = ['name' => 'Hostname', 'value' => $data['hostname'], 'inline' => true]; + } + if (isset($data['new_status'])) { + $fields[] = ['name' => 'Status', 'value' => $data['new_status'], 'inline' => true]; + } + + $embed['fields'] = $fields; } return $embed; diff --git a/app/Services/Channels/MattermostChannel.php b/app/Services/Channels/MattermostChannel.php index ba66734..1d2f596 100644 --- a/app/Services/Channels/MattermostChannel.php +++ b/app/Services/Channels/MattermostChannel.php @@ -31,34 +31,30 @@ class MattermostChannel implements NotificationChannelInterface // Add attachments for richer formatting if domain data is available if (isset($data['domain'])) { $color = $this->getColorByDaysLeft($data['days_left'] ?? null); - + $title = $data['subject'] ?? '🔔 Domain Monitor Alert'; + + $fields = [ + ['short' => true, 'title' => 'Domain', 'value' => $data['domain']] + ]; + + // Only add expiration fields for domain expiration alerts + if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) { + $fields[] = ['short' => true, 'title' => 'Days Left', 'value' => (string) ($data['days_left'] ?? 'N/A')]; + $fields[] = ['short' => true, 'title' => 'Expiration Date', 'value' => $data['expiration_date'] ?? 'N/A']; + $fields[] = ['short' => true, 'title' => 'Registrar', 'value' => $data['registrar'] ?? 'N/A']; + } elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) { + $fields[] = ['short' => true, 'title' => 'Hostname', 'value' => $data['hostname']]; + } + if (isset($data['new_status'])) { + $fields[] = ['short' => true, 'title' => 'Status', 'value' => $data['new_status']]; + } + $payload['attachments'] = [ [ 'color' => $color, - 'title' => '🔔 Domain Expiration Alert', + 'title' => $title, 'text' => $message, - 'fields' => [ - [ - 'short' => true, - 'title' => 'Domain', - 'value' => $data['domain'] - ], - [ - 'short' => true, - 'title' => 'Days Left', - 'value' => $data['days_left'] ?? 'N/A' - ], - [ - 'short' => true, - 'title' => 'Expiration Date', - 'value' => $data['expiration_date'] ?? 'N/A' - ], - [ - 'short' => true, - 'title' => 'Registrar', - 'value' => $data['registrar'] ?? 'N/A' - ] - ], + 'fields' => $fields, 'footer' => 'Domain Monitor', 'ts' => time() ] diff --git a/app/Services/Channels/PushoverChannel.php b/app/Services/Channels/PushoverChannel.php index 6f13e15..a417c14 100644 --- a/app/Services/Channels/PushoverChannel.php +++ b/app/Services/Channels/PushoverChannel.php @@ -40,8 +40,10 @@ class PushoverChannel implements NotificationChannelInterface 'priority' => $priority, ]; - // Optional: Add title - if (isset($data['domain'])) { + // Optional: Add title - use subject when provided (DNS, SSL, etc.) + if (!empty($data['subject'])) { + $payload['title'] = $data['subject']; + } elseif (isset($data['domain'])) { $payload['title'] = '🔔 Domain Expiration Alert: ' . $data['domain']; } else { $payload['title'] = '🔔 Domain Monitor Notification'; diff --git a/app/Services/Channels/SlackChannel.php b/app/Services/Channels/SlackChannel.php index 176b106..073df68 100644 --- a/app/Services/Channels/SlackChannel.php +++ b/app/Services/Channels/SlackChannel.php @@ -53,12 +53,14 @@ class SlackChannel implements NotificationChannelInterface private function createBlocks(string $message, array $data): array { + $headerText = $data['subject'] ?? '🔔 Domain Monitor Alert'; + $blocks = [ [ 'type' => 'header', 'text' => [ 'type' => 'plain_text', - 'text' => '🔔 Domain Expiration Alert' + 'text' => $headerText ] ], [ @@ -71,26 +73,25 @@ class SlackChannel implements NotificationChannelInterface ]; if (isset($data['domain'])) { + $fields = [ + ['type' => 'mrkdwn', 'text' => "*Domain:*\n{$data['domain']}"] + ]; + + // Only add expiration fields for domain expiration alerts + if (array_key_exists('days_left', $data) || array_key_exists('expiration_date', $data)) { + $fields[] = ['type' => 'mrkdwn', 'text' => "*Days Left:*\n" . ($data['days_left'] ?? 'N/A')]; + $fields[] = ['type' => 'mrkdwn', 'text' => "*Expiration:*\n" . ($data['expiration_date'] ?? 'N/A')]; + $fields[] = ['type' => 'mrkdwn', 'text' => "*Registrar:*\n" . ($data['registrar'] ?? 'N/A')]; + } elseif (isset($data['hostname']) && $data['hostname'] !== $data['domain']) { + $fields[] = ['type' => 'mrkdwn', 'text' => "*Hostname:*\n{$data['hostname']}"]; + } + if (isset($data['new_status'])) { + $fields[] = ['type' => 'mrkdwn', 'text' => "*Status:*\n{$data['new_status']}"]; + } + $blocks[] = [ 'type' => 'section', - 'fields' => [ - [ - 'type' => 'mrkdwn', - 'text' => "*Domain:*\n{$data['domain']}" - ], - [ - 'type' => 'mrkdwn', - 'text' => "*Days Left:*\n{$data['days_left']}" - ], - [ - 'type' => 'mrkdwn', - 'text' => "*Expiration:*\n{$data['expiration_date']}" - ], - [ - 'type' => 'mrkdwn', - 'text' => "*Registrar:*\n{$data['registrar']}" - ] - ] + 'fields' => $fields ]; } diff --git a/app/Services/Channels/WebhookChannel.php b/app/Services/Channels/WebhookChannel.php index 60f0ce6..9ad1329 100644 --- a/app/Services/Channels/WebhookChannel.php +++ b/app/Services/Channels/WebhookChannel.php @@ -105,7 +105,7 @@ class WebhookChannel implements NotificationChannelInterface private function buildGenericPayload(string $message, array $data): array { return [ - 'event' => 'domain_expiration_alert', + 'event' => 'domain_monitor_alert', 'message' => $message, 'data' => $data, 'sent_at' => date('c') diff --git a/app/Services/DnsService.php b/app/Services/DnsService.php index 9b8fd2b..dafb5ae 100644 --- a/app/Services/DnsService.php +++ b/app/Services/DnsService.php @@ -86,124 +86,173 @@ class DnsService } // ======================================================================== - // MAIN LOOKUP + // DNS SCAN METHODS // ======================================================================== /** - * 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) + * Re-check only records that already exist in the database. + * Queries root domain for all types + known subdomain hosts. + * No wordlist brute force, no crt.sh. Used by the cron and Refresh button. */ - public function lookup(string $domain, array $extraSubdomains = []): array + public function refreshExisting(string $domain, array $existingHosts = []): array { - $this->logger->info("DNS lookup started", ['domain' => $domain]); + $this->logger->info("DNS refresh started", ['domain' => $domain, 'known_hosts' => count($existingHosts)]); - $records = [ - 'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [], - 'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [], - ]; - $seen = []; // "TYPE:host:value" dedup keys + [$records, $seen] = $this->queryRootDomain($domain); - // 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) { + // Query known subdomain hosts directly (no existence probe needed) + foreach ($existingHosts 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']); - }); - } + $this->resolveMxTargets($domain, $records, $seen); + $this->resolveNsIps($records); + $this->sortRecords($records); $totalRecords = array_sum(array_map('count', $records)); - $this->logger->info("DNS lookup completed", [ + $this->logger->info("DNS refresh completed", [ 'domain' => $domain, 'total_records' => $totalRecords, + ]); + + return $records; + } + + /** + * Standard DNS lookup: root domain + resolve targets + special TXT. + * No subdomain brute force, no crt.sh. Like running nslookup/dig. + * Used by Discover > Quick Scan. + */ + public function quickScan(string $domain): array + { + $this->logger->info("DNS quick scan started", ['domain' => $domain]); + + [$records, $seen] = $this->queryRootDomain($domain); + + // Add subdomains found as NS/MX/CNAME/SRV targets + $targetSubs = $this->extractTargetSubdomains($domain, $records); + foreach ($targetSubs 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); + } + + foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) { + $fqdn = "{$sub}.{$domain}"; + $this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen); + } + + $this->resolveMxTargets($domain, $records, $seen); + $this->resolveNsIps($records); + $this->sortRecords($records); + + $totalRecords = array_sum(array_map('count', $records)); + $this->logger->info("DNS quick scan completed", [ + 'domain' => $domain, + 'total_records' => $totalRecords, + ]); + + return $records; + } + + /** + * Full discovery: root + wordlist brute force + crt.sh extras + wildcard detection. + * Used by Discover > Deep Scan and the discover_dns.php script. + * + * @param string $domain The domain to scan + * @param array $extraSubdomains Additional candidates (e.g. from crt.sh or previous scans) + * @param callable|null $onProgress Optional callback for progress messages: fn(string $msg) + */ + public function lookup(string $domain, array $extraSubdomains = [], ?callable $onProgress = null): array + { + $log = $onProgress ?? function (string $msg) {}; + + $this->logger->info("DNS deep lookup started", ['domain' => $domain]); + + $log("Querying root domain..."); + [$records, $seen] = $this->queryRootDomain($domain); + $rootCount = array_sum(array_map('count', $records)); + $log("Root query done: {$rootCount} record(s)"); + + // Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV + $candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains); + $targetSubs = $this->extractTargetSubdomains($domain, $records); + $candidates = array_unique(array_merge($candidates, $targetSubs)); + + // Wildcard detection: probe a random nonsense subdomain + $wildcardDetected = false; + $probeHost = '_dm-wc-' . bin2hex(random_bytes(4)) . '.' . $domain; + $log("Wildcard detection: probing random subdomain..."); + if ($this->subdomainExists($probeHost)) { + $wildcardDetected = true; + $this->logger->info("Wildcard DNS detected, skipping brute force", ['domain' => $domain]); + $log("⚠ Wildcard DNS detected — brute force skipped, using only crt.sh/known hosts"); + // Only use crt.sh/extra candidates + DB hosts (real subdomains), not wordlist + $candidates = array_values(array_unique($extraSubdomains)); + } else { + $log("No wildcard detected"); + } + + // Probe subdomains — fast checkdnsrr existence test + $total = count($candidates); + $log("Probing {$total} subdomain candidate(s)..."); + $discovered = []; + $probed = 0; + foreach ($candidates as $sub) { + $fqdn = "{$sub}.{$domain}"; + if ($this->subdomainExists($fqdn)) { + $discovered[] = $sub; + } + $probed++; + if ($probed % 25 === 0 || $probed === $total) { + $log("Probed {$probed}/{$total} — found " . count($discovered) . " so far"); + } + } + $log("Subdomain probe complete: " . count($discovered) . " found out of {$total}"); + + // Deep scan discovered subdomains (A, AAAA, CNAME, TXT) + if (!empty($discovered)) { + $log("Querying " . count($discovered) . " discovered subdomain(s)..."); + } + 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); + if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) { + $this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen); + } + } + + $log("Querying special TXT subdomains..."); + foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) { + $fqdn = "{$sub}.{$domain}"; + $this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen); + } + + $log("Resolving MX/NS targets..."); + $this->resolveMxTargets($domain, $records, $seen); + $this->resolveNsIps($records); + $this->sortRecords($records); + + $totalRecords = array_sum(array_map('count', $records)); + $this->logger->info("DNS deep lookup completed", [ + 'domain' => $domain, + 'total_records' => $totalRecords, 'subdomains_discovered' => count($discovered), + 'wildcard_detected' => $wildcardDetected, ]); return $records; @@ -314,70 +363,572 @@ class DnsService return $ips; } + // ======================================================================== + // SHARED SCAN HELPERS + // ======================================================================== + + /** + * Query root domain for all record types + DNS_ALL fallback + gethostbynamel fallback. + * + * @return array{0: array, 1: array} [$records, $seen] + */ + private function queryRootDomain(string $domain): array + { + $records = [ + 'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [], + 'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [], + ]; + $seen = []; + + foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) { + $this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen); + } + + $this->queryAllFallback($domain, $domain, $records, $seen); + + 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); + } + } + } + + return [$records, $seen]; + } + + /** + * Extract subdomain labels found as NS/MX/CNAME/SRV targets under the given domain. + */ + private function extractTargetSubdomains(string $domain, array $records): array + { + $subs = []; + 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, $subs)) { + $subs[] = $sub; + } + } + } + } + return $subs; + } + + /** + * Resolve MX targets that are under the domain — add their A/AAAA records. + */ + private function resolveMxTargets(string $domain, array &$records, array &$seen): void + { + 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); + } + } + } + + /** + * Resolve NS server hostnames to their A/AAAA IPs (stored in raw data for display). + */ + private function resolveNsIps(array &$records): void + { + 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 records: root (@) first, then alphabetical by host. + */ + private function sortRecords(array &$records): void + { + 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']); + }); + } + } + + // ======================================================================== + // BIND ZONE FILE PARSER + // ======================================================================== + + /** + * Parse BIND zone file content into grouped records matching our internal format. + * + * Handles standard BIND syntax: + * @ IN A 1.2.3.4 + * www IN CNAME example.com. + * mail IN MX 10 mx.example.com. + * @ 3600 IN TXT "v=spf1 ..." + * + * @return array Grouped records ['A' => [...], 'MX' => [...], ...] + */ + public function parseBindZone(string $content, string $domain): array + { + $records = [ + 'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [], + 'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [], + ]; + $seen = []; + $supportedTypes = array_keys($records); + + $lines = preg_split('/\r?\n/', $content); + $lastHost = '@'; + $defaultTtl = 3600; + + foreach ($lines as $line) { + $line = trim($line); + + if ($line === '' || $line[0] === ';') { + continue; + } + + // $TTL directive + if (preg_match('/^\$TTL\s+(\d+)/i', $line, $m)) { + $defaultTtl = (int)$m[1]; + continue; + } + + // Skip other directives ($ORIGIN, $INCLUDE, etc.) + if ($line[0] === '$') { + continue; + } + + // Strip inline comments (not inside quotes) + $line = preg_replace('/\s;[^"]*$/', '', $line); + + // Standard BIND format: [name] [ttl] [class] type rdata + $tokens = preg_split('/\s+/', $line); + if (count($tokens) < 3) { + continue; + } + + $host = null; + $ttl = $defaultTtl; + $type = null; + $rdataStart = 0; + + $idx = 0; + + // First token: hostname, or continuation (starts with a type or digit) + if (!ctype_digit($tokens[0]) && !in_array(strtoupper($tokens[0]), $supportedTypes) + && strtoupper($tokens[0]) !== 'IN') { + $host = $tokens[0]; + $idx = 1; + } + + // Optional TTL (numeric) + if (isset($tokens[$idx]) && ctype_digit($tokens[$idx])) { + $ttl = (int)$tokens[$idx]; + $idx++; + } + + // Optional class (IN) + if (isset($tokens[$idx]) && strtoupper($tokens[$idx]) === 'IN') { + $idx++; + } + + // Record type + if (!isset($tokens[$idx])) { + continue; + } + $type = strtoupper($tokens[$idx]); + $idx++; + + if (!in_array($type, $supportedTypes)) { + continue; + } + + $rdataStart = $idx; + $rdata = array_slice($tokens, $rdataStart); + if (empty($rdata)) { + continue; + } + + // Resolve host + if ($host === null) { + $host = $lastHost; + } elseif ($host === '@') { + $lastHost = '@'; + } else { + $host = rtrim($host, '.'); + // Strip the domain suffix to get just the subdomain label + $lowerHost = strtolower($host); + $lowerDomain = strtolower($domain); + if ($lowerHost === $lowerDomain) { + $host = '@'; + } elseif (str_ends_with($lowerHost, '.' . $lowerDomain)) { + $host = substr($host, 0, -(strlen($domain) + 1)); + } + $lastHost = $host; + } + + // Build record + $value = implode(' ', $rdata); + $priority = null; + $parsed = null; + + switch ($type) { + case 'A': + $parsed = [ + 'host' => $host, 'value' => $rdata[0], 'ttl' => $ttl, + 'is_cloudflare' => $this->isCloudflareIp($rdata[0]), + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'A', 'ip' => $rdata[0], 'ttl' => $ttl], + ]; + break; + case 'AAAA': + $parsed = [ + 'host' => $host, 'value' => $rdata[0], 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'AAAA', 'ipv6' => $rdata[0], 'ttl' => $ttl], + ]; + break; + case 'CNAME': + $parsed = [ + 'host' => $host, 'value' => rtrim($rdata[0], '.'), 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'CNAME', 'target' => rtrim($rdata[0], '.'), 'ttl' => $ttl], + ]; + break; + case 'MX': + $priority = (int)($rdata[0] ?? 0); + $target = rtrim($rdata[1] ?? '', '.'); + $parsed = [ + 'host' => $host, 'value' => $target, 'ttl' => $ttl, + 'priority' => $priority, 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'MX', 'pri' => $priority, 'target' => $target, 'ttl' => $ttl], + ]; + break; + case 'NS': + $parsed = [ + 'host' => $host, 'value' => rtrim($rdata[0], '.'), 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'NS', 'target' => rtrim($rdata[0], '.'), 'ttl' => $ttl], + ]; + break; + case 'TXT': + $txtValue = implode(' ', $rdata); + $txtValue = trim($txtValue, '"'); + $parsed = [ + 'host' => $host, 'value' => $txtValue, 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'TXT', 'txt' => $txtValue, 'ttl' => $ttl], + ]; + break; + case 'SRV': + if (count($rdata) >= 4) { + $priority = (int)$rdata[0]; + $weight = (int)$rdata[1]; + $port = (int)$rdata[2]; + $target = rtrim($rdata[3], '.'); + $parsed = [ + 'host' => $host, 'value' => $target, 'ttl' => $ttl, + 'priority' => $priority, 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'SRV', 'pri' => $priority, 'weight' => $weight, 'port' => $port, 'target' => $target, 'ttl' => $ttl], + ]; + } + break; + case 'CAA': + if (count($rdata) >= 3) { + $flags = (int)$rdata[0]; + $tag = $rdata[1]; + $caaValue = trim(implode(' ', array_slice($rdata, 2)), '"'); + $parsed = [ + 'host' => $host, 'value' => "{$flags} {$tag} \"{$caaValue}\"", 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'CAA', 'flags' => $flags, 'tag' => $tag, 'value' => $caaValue, 'ttl' => $ttl], + ]; + } + break; + case 'SOA': + if (count($rdata) >= 7) { + $parsed = [ + 'host' => $host, 'value' => implode(' ', $rdata), 'ttl' => $ttl, + 'is_cloudflare' => false, + 'raw' => ['host' => ($host === '@' ? $domain : "{$host}.{$domain}"), 'type' => 'SOA', 'mname' => rtrim($rdata[0], '.'), 'rname' => rtrim($rdata[1], '.'), 'serial' => (int)$rdata[2], 'refresh' => (int)$rdata[3], 'retry' => (int)$rdata[4], 'expire' => (int)$rdata[5], 'minimum-ttl' => (int)$rdata[6], 'ttl' => $ttl], + ]; + } + break; + } + + if ($parsed) { + $this->addIfNew($type, $parsed, $records, $seen); + } + } + + $this->sortRecords($records); + return $records; + } + // ======================================================================== // 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. + * + * Spawns check_dns.php --crtsh as a subprocess with a hard timeout to + * protect against crt.sh hangs. The subprocess handles HTTP retries and + * streams debug output to stderr, relayed via the $onStderrLine callback. + * + * @param string $domain The domain to scan + * @param int $maxSubdomains Cap on returned subdomains (0 = no limit) + * @param int $timeoutSeconds Hard kill timeout for the subprocess + * @param callable|null $onStderrLine fn(string $line) for real-time stderr relay + * @return array{0: string[], 1: bool} [subdomains, serverResponded] */ - public function crtshSubdomains(string $domain): array - { - $url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json'; + public function fetchCrtshSubdomains( + string $domain, + int $maxSubdomains = 100, + int $timeoutSeconds = 1800, + ?callable $onStderrLine = null + ): array { + $phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php'; + $scriptPath = dirname(__DIR__, 2) . '/cron/check_dns.php'; + $cmd = [$phpBin, $scriptPath, '--crtsh', $domain]; + if ($maxSubdomains > 0) { + $cmd[] = (string) $maxSubdomains; + } + + $projectRoot = dirname(__DIR__, 2); + $proc = proc_open($cmd, [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], $pipes, $projectRoot); + + if (!is_resource($proc)) { + $this->logger->error('Failed to spawn crt.sh subprocess', ['domain' => $domain]); + 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; + + if ($elapsed >= $timeoutSeconds) { + $stdout .= self::drainStream($pipes[1]); + $stderrBuffer .= self::drainStream($pipes[2]); + $this->flushCrtshStderrLines($stderrBuffer, $onStderrLine); + proc_terminate($proc, 9); + proc_close($proc); + $this->logger->warning("crt.sh subprocess killed after {$elapsed}s", ['domain' => $domain]); + return [[], false]; + } + + $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; + $this->flushCrtshStderrLines($stderrBuffer, $onStderrLine); + } + } + } + usleep(100000); + } + + $stdout .= stream_get_contents($pipes[1]); + $stderrBuffer .= stream_get_contents($pipes[2]); + $this->flushCrtshStderrLines($stderrBuffer, $onStderrLine); + 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'] : []; + + $this->logger->info('crt.sh discovery completed', [ + 'domain' => $domain, + 'subdomains_found' => count($subs), + 'server_ok' => $ok, + ]); + + return [$subs, $ok]; + } + + /** + * Fetch a crt.sh URL with optional debug output to stderr. + * + * Called from the crt.sh subprocess where stderr is relayed to the parent + * in real-time. Pass $debug = true in subprocess context. + * + * @return array{status: int, body_length: int, data: array, time: float} + */ + public function fetchCrtshUrl(string $url, int $timeout = 900, bool $debug = false): array + { $ctx = stream_context_create([ 'http' => [ - 'timeout' => 30, + 'timeout' => $timeout, 'ignore_errors' => true, - 'header' => "User-Agent: DomainMonitor/1.0\r\n", - ], - 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, + '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', + ]), ], ]); - $json = @file_get_contents($url, false, $ctx); - if ($json === false) { - $this->logger->warning('crt.sh request failed', ['domain' => $domain]); - return []; + $start = microtime(true); + $http_response_header = null; + $body = @file_get_contents($url, false, $ctx); + $elapsed = microtime(true) - $start; + + $bodyLen = is_string($body) ? strlen($body) : 0; + + if ($debug) { + 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"); + } + + fwrite(STDERR, "Body: $bodyLen bytes\n"); + + if (is_string($body) && $bodyLen > 0) { + $preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body; + fwrite(STDERR, $preview . "\n"); + } + + fwrite(STDERR, "--- end response ---\n"); } - $entries = @json_decode($json, true); - if (!is_array($entries)) { - return []; + $status = 0; + if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) { + $status = (int) $m[0]; } - $subdomains = []; + $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 prefixes from raw crt.sh JSON response. + * + * Each entry has a name_value field that may contain multiple newline-separated + * names, including wildcards. Returns 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 + */ + public function extractCrtshSubdomains(array $crtshData, string $domain): array + { $domainLower = strtolower($domain); + $suffix = '.' . $domainLower; + $suffixLen = strlen($suffix); + $subs = []; - 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; + foreach ($crtshData as $entry) { + if (empty($entry['name_value'])) { + continue; + } - if ($n === $domainLower) continue; + foreach (explode("\n", $entry['name_value']) as $name) { + $name = strtolower(trim($name)); - if (str_ends_with($n, '.' . $domainLower)) { - $sub = str_replace('.' . $domainLower, '', $n); - if ($sub !== '' && !isset($subdomains[$sub])) { - $subdomains[$sub] = true; - } + if (strpos($name, '*.') === 0) { + $name = substr($name, 2); + } + + if ($name === $domainLower) { + continue; + } + + if (substr($name, -$suffixLen) !== $suffix) { + continue; + } + + $sub = substr($name, 0, strlen($name) - $suffixLen); + if (!empty($sub)) { + $subs[$sub] = true; } } } - $result = array_keys($subdomains); - $this->logger->info('crt.sh discovery completed', [ - 'domain' => $domain, - 'subdomains_found' => count($result), - ]); + return array_keys($subs); + } - return $result; + /** + * Flush complete stderr lines from buffer via callback. + */ + private function flushCrtshStderrLines(string &$buffer, ?callable $onLine): void + { + while (($pos = strpos($buffer, "\n")) !== false) { + $line = trim(substr($buffer, 0, $pos)); + $buffer = substr($buffer, $pos + 1); + if ($line !== '' && $onLine) { + $onLine($line); + } + } + } + + /** + * Drain remaining data from a non-blocking stream and close it. + */ + private static function drainStream($stream): string + { + if (!is_resource($stream)) { + return ''; + } + $data = stream_get_contents($stream); + fclose($stream); + return $data ?: ''; } // ======================================================================== diff --git a/app/Views/domains/edit.twig b/app/Views/domains/edit.twig index c43649a..8af2f38 100644 --- a/app/Views/domains/edit.twig +++ b/app/Views/domains/edit.twig @@ -205,7 +205,7 @@ Refresh WHOIS -
+ {% if auth.isAdmin %} + + {% endif %} -