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 -
+ {{ csrf_field() }}
+ {% if auth.isAdmin %} + + {% endif %} -
+ {{ csrf_field() }} + {% endif %} + {% endfor %} @@ -566,13 +583,12 @@ function bulkRefresh() { form.submit(); } -function bulkDelete() { +async function bulkDelete() { const ids = getSelectedIds(); if (ids.length === 0) return; - if (!confirm(`Delete ${ids.length} domain(s)? This action cannot be undone.`)) { - return; - } + var ok = await confirmAction({ message: 'Delete ' + ids.length + ' domain(s)? This action cannot be undone.' }); + if (!ok) return; const form = document.createElement('form'); form.method = 'POST'; @@ -641,16 +657,15 @@ function bulkAddTag(tagName) { form.submit(); } -function bulkRemoveAllTags() { +async function bulkRemoveAllTags() { const ids = getSelectedIds(); if (ids.length === 0) { alert('Please select at least one domain'); return; } - if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) { - return; - } + var ok = await confirmAction({ message: 'Remove all tags from ' + ids.length + ' domain(s)?', title: 'Remove Tags', icon: 'fa-tags text-orange-500' }); + if (!ok) return; const form = document.createElement('form'); form.method = 'POST'; @@ -680,51 +695,15 @@ function bulkTransfer() { alert('Please select at least one domain'); return; } - - const users = {{ users|default([])|json_encode|raw }}; - if (users.length === 0) { - alert('No users available for transfer'); - return; - } - - let userOptions = users.map(user => - `` - ).join(''); - - const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50'; - modal.innerHTML = ` -
-
-

Transfer ${ids.length} Domain(s)

-

Select the user to transfer the selected domains to:

- - - - ${ids.map(id => ``).join('')} - -
- - -
- -
- - -
- -
-
- `; - - document.body.appendChild(modal); + openTransferModal({ + title: 'Transfer ' + ids.length + ' Domain(s)', + description: 'Select the user to transfer the selected domains to.', + action: '/domains/bulk-transfer', + fields: { 'domain_ids[]': ids }, + submitText: 'Transfer Domains', + users: {{ users|default([])|json_encode|raw }}, + csrfToken: '{{ csrf_token() }}' + }); } document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) { @@ -977,5 +956,26 @@ document.addEventListener('click', function(e) { document.getElementById('domainExportMenu').classList.add('hidden'); } }); + +function transferDomain(domainId, domainName) { + const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>'); + openTransferModal({ + title: 'Transfer Domain', + description: 'Transfer ' + esc(domainName) + ' to another user.', + action: '/domains/transfer', + fields: { domain_id: domainId }, + users: {{ users|default([])|json_encode|raw }}, + csrfToken: '{{ csrf_token() }}' + }); +} + +document.addEventListener('click', function(e) { + const btn = e.target.closest('.domain-transfer-btn'); + if (btn) { + e.preventDefault(); + transferDomain(parseInt(btn.dataset.domainId, 10), btn.dataset.domainName || ''); + } +}); +{% include 'partials/transfer-modal.twig' %} {% endblock %} diff --git a/app/Views/domains/tabs/dns.twig b/app/Views/domains/tabs/dns.twig index a87854c..7560746 100644 --- a/app/Views/domains/tabs/dns.twig +++ b/app/Views/domains/tabs/dns.twig @@ -23,22 +23,32 @@

No DNS Records Yet

-

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

+

Run a quick scan, import a zone file, or add records manually.

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

Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }} @@ -51,13 +61,63 @@ {% endif %}

{% if domain and dnsMonitoringEnabled %} -
- {{ csrf_field()|raw }} - +
+ + {# Discover DNS dropdown #} +
+ + +
+ + {# Add Record #} + - + + {# Import Zone #} + + + {# Bulk delete (shown when records are selected) #} + +
{% endif %}
@@ -131,23 +191,31 @@ + + {% for record in dnsRecords['A'] %} {% set ipInfo = dnsIpDetails[record.value]|default(null) %} + + {% endfor %} @@ -199,23 +275,31 @@
Host IP Address PTR ASN TTL
{% if record.host == '@' %} @ (root) {% else %} {{ record.host }} {% endif %} + {% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ record.value }} @@ -177,6 +245,14 @@ {% endif %} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + {% for record in dnsRecords['AAAA'] %} {% set ipInfo = dnsIpDetails[record.value]|default(null) %} + + {% endfor %} @@ -267,17 +359,32 @@
Host IPv6 Address PTR ASN TTL
{% if record.host == '@' %} @ (root) {% else %} {{ record.host }} {% endif %} + {% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ record.value }} @@ -245,6 +329,14 @@ {% endif %} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + {% for record in dnsRecords['CNAME'] %} - + + + {% endfor %} @@ -300,19 +407,34 @@
Alias Target TTL
{{ record.host }}{{ record.host }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ record.value }} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + {% for record in dnsRecords['MX'] %} + + {% endfor %} @@ -331,10 +453,19 @@ {{ dnsRecords['TXT']|length }} -
- {% for record in dnsRecords['TXT'] %} -
-
+
+
Priority Mail Server TTL
- {{ record.priority }} + {{ record.priority }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ record.value }} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + + + + + + + + + + {% for record in dnsRecords['TXT'] %} {% set val = record.value|lower %} {% if val starts with 'v=spf1' %} {% set txtType = 'SPF' %} @@ -351,11 +482,29 @@ {% else %} {% set txtType = 'TXT' %} {% endif %} - {{ txtType }} -

{{ record.value }}

- - - {% endfor %} + + + + + + + + {% endfor %} + +
TypeValueTTL
+ {{ txtType }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} + {{ record.value }}{{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
{% endif %} @@ -374,11 +523,13 @@ + + @@ -386,10 +537,15 @@ {% set rawData = record.raw_data ? record.raw_data|from_json : null %} {% set nsIps = rawData ? rawData._ns_ips|default(null) : null %} + - + + {% endfor %} @@ -423,24 +587,39 @@
# Nameserver IPv4 IPv6 TTL
{{ loop.index }}
{{ record.value }}{{ record.value }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {% if nsIps and nsIps.ipv4|default([])|length > 0 %} {{ nsIps.ipv4|join(', ') }} @@ -401,6 +557,14 @@ {% else %}-{% endif %} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + {% for record in dnsRecords['SRV'] %} {% set rawData = record.raw_data ? record.raw_data|from_json : {} %} - + + + {% endfor %} @@ -463,22 +642,37 @@
Service Target Port Priority Weight TTL
{{ record.host }}{{ record.host }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ record.value }} {{ rawData.port|default('-') }} {{ record.priority|default('-') }} {{ rawData.weight|default('-') }} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
+ + {% for record in dnsRecords['CAA'] %} {% set rawData = record.raw_data ? record.raw_data|from_json : {} %} + + {% endfor %} @@ -491,6 +685,88 @@ {% endif %} {% endif %} +{# ===== Add Record Modal ===== #} +{% if domain %} + + +{# ===== Import Zone Modal (shared partial) ===== #} +{% include 'partials/import-modal.twig' with { + prefix: 'dnsZone', + title: 'Import DNS Zone File', + action: '/domains/' ~ domain.id ~ '/dns-import', + accept: '.txt,.zone,.db,.bind', + file_hint: 'BIND zone file (.txt, .zone)', + input_name: 'zone_file', + format_html: '

Standard BIND zone file format:

@ IN A 1.2.3.4

www IN CNAME example.com.

Duplicate records will be skipped. Imported records are tagged as "imported".

', + submit_label: 'Import Zone' +} %} + +{# ===== Bulk Delete Form (hidden, submitted via JS) ===== #} + + {{ csrf_field()|raw }} + +{% endif %} + diff --git a/app/Views/domains/tabs/ssl.twig b/app/Views/domains/tabs/ssl.twig index f5e6d71..aae8842 100644 --- a/app/Views/domains/tabs/ssl.twig +++ b/app/Views/domains/tabs/ssl.twig @@ -319,7 +319,7 @@ Check Now - + {{ csrf_field()|raw }} diff --git a/app/Views/groups/index.twig b/app/Views/groups/index.twig index ad2113f..660e580 100644 --- a/app/Views/groups/index.twig +++ b/app/Views/groups/index.twig @@ -138,7 +138,7 @@ {% endif %} - + - - - - - `; - - document.body.appendChild(modal); + const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>'); + openTransferModal({ + title: 'Transfer Group', + description: 'Transfer group ' + esc(groupName) + ' to another user.', + action: '/groups/transfer', + fields: { group_id: groupId }, + users: {{ users|default([])|json_encode|raw }}, + csrfToken: '{{ csrf_token() }}' + }); } function bulkTransfer() { @@ -336,52 +301,15 @@ function bulkTransfer() { alert('Please select groups to transfer'); return; } - - const users = {{ users|default([])|json_encode|raw }}; - - if (users.length === 0) { - alert('No users available for transfer'); - return; - } - - const userOptions = users.map(user => - `` - ).join(''); - - const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'; - modal.innerHTML = ` -
-

Transfer Groups

-

Transfer ${groupIds.length} selected group(s) to another user.

- -
- - ${groupIds.map(id => - `` - ).join('')} - -
- - -
- -
- - -
- -
- `; - - document.body.appendChild(modal); + openTransferModal({ + title: 'Transfer Groups', + description: 'Transfer ' + groupIds.length + ' selected group(s) to another user.', + action: '/groups/bulk-transfer', + fields: { 'group_ids[]': groupIds }, + submitText: 'Transfer All', + users: {{ users|default([])|json_encode|raw }}, + csrfToken: '{{ csrf_token() }}' + }); } document.addEventListener('click', function(e) { @@ -398,140 +326,11 @@ document.getElementById('groupImportModal')?.addEventListener('click', function( }); -{# Import Modal #} - - - +{% include 'partials/import-modal.twig' with { + prefix: 'group', + title: 'Import Notification Groups', + action: '/groups/import', + format_html: '

CSV: group_name, group_description, channel_type, channel_config, is_active

JSON: array of group objects with nested channels array

Channels with masked secrets will be imported as disabled. Update the credentials and enable them manually.

' +} %} +{% include 'partials/transfer-modal.twig' %} {% endblock %} diff --git a/app/Views/layout/base.twig b/app/Views/layout/base.twig index 71a5485..d03e85b 100644 --- a/app/Views/layout/base.twig +++ b/app/Views/layout/base.twig @@ -372,6 +372,8 @@ {% endif %} {% block scripts %}{% endblock %} + + {% include 'partials/confirm-modal.twig' %} diff --git a/app/Views/notifications/index.twig b/app/Views/notifications/index.twig index ed90e5f..e4c130b 100644 --- a/app/Views/notifications/index.twig +++ b/app/Views/notifications/index.twig @@ -310,7 +310,7 @@ {% endif %} - + {{ csrf_field() }} + +
+

+
+
+ + +
+ + + + diff --git a/app/Views/partials/import-modal.twig b/app/Views/partials/import-modal.twig new file mode 100644 index 0000000..141ef74 --- /dev/null +++ b/app/Views/partials/import-modal.twig @@ -0,0 +1,175 @@ +{# + # Shared import modal with drag & drop file upload. + # + # Parameters: + # prefix - Unique prefix for element IDs (e.g. 'tag', 'group', 'tld', 'dnsZone') + # title - Modal title (e.g. 'Import Tags') + # action - Form POST action URL + # accept - File input accept attribute (default: '.csv,.json') + # file_hint - Accepted file types hint (default: 'CSV, JSON') + # format_html - Raw HTML for the "Expected Format" info block + # submit_label - Submit button text (default: title) + # input_name - File input name attribute (default: 'import_file') + # extra_fields - Optional raw HTML for extra form fields (textarea, etc.) + #} + +{% set _accept = accept|default('.csv,.json') %} +{% set _file_hint = file_hint|default('CSV, JSON') %} +{% set _submit = submit_label|default(title) %} +{% set _input_name = input_name|default('import_file') %} + + + + diff --git a/app/Views/partials/transfer-modal.twig b/app/Views/partials/transfer-modal.twig new file mode 100644 index 0000000..67eea03 --- /dev/null +++ b/app/Views/partials/transfer-modal.twig @@ -0,0 +1,182 @@ +{# Shared transfer modal component. + Include once per page, then call: + + openTransferModal({ + title: 'Transfer Domain', + description: 'Transfer example.com to another user.', + action: '/domains/transfer', + fields: { domain_id: 123 } // or for arrays: { 'tag_ids[]': [1,2,3] } + submitText: 'Transfer', // optional, defaults to 'Transfer' + users: [...], // user objects with id, username, full_name/email + csrfToken: '...' + }); +#} + diff --git a/app/Views/profile/index.twig b/app/Views/profile/index.twig index b3084e9..81a0a39 100644 --- a/app/Views/profile/index.twig +++ b/app/Views/profile/index.twig @@ -158,7 +158,7 @@ {{ csrf_field() }} @@ -350,7 +350,7 @@
{% if twoFactorStatus.backup_codes_count < 3 %} -
+ {{ csrf_field() }}
{% if sessions|default([])|length > 1 %} - + {{ csrf_field() }} ' + ''; } else { - footerEl.innerHTML = '' + + footerEl.innerHTML = '' + (csrf ? '' : '') + '' + '' + @@ -1591,5 +1593,99 @@ document.addEventListener('DOMContentLoaded', function() { updateCaptchaUI(); } }); + +(function() { + const popularTimezones = {{ popularTimezones|json_encode|raw }}; + const allTimezones = {{ allTimezones|json_encode|raw }}; + + const hiddenInput = document.getElementById('app_timezone'); + const selectedEl = document.getElementById('tz-selected'); + const selectedText = document.getElementById('tz-selected-text'); + const dropdown = document.getElementById('tz-dropdown'); + const searchInput = document.getElementById('tz-search'); + const listEl = document.getElementById('tz-list'); + + const popularKeys = Object.keys(popularTimezones); + const otherTimezones = allTimezones.filter(tz => !popularKeys.includes(tz)); + + const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>'); + + function renderList(filter) { + const query = (filter || '').toLowerCase(); + + const matchPopular = popularKeys.filter(tz => { + const label = (popularTimezones[tz] || '').toLowerCase(); + return tz.toLowerCase().includes(query) || label.includes(query); + }); + + const matchOther = otherTimezones.filter(tz => tz.toLowerCase().includes(query)); + + if (matchPopular.length === 0 && matchOther.length === 0) { + listEl.innerHTML = '
No timezones found
'; + return; + } + + let html = ''; + + if (matchPopular.length > 0) { + html += '
Popular
'; + html += matchPopular.map(tz => + `
+ ${esc(popularTimezones[tz])} + ${hiddenInput.value === tz ? '' : ''} +
` + ).join(''); + } + + if (matchOther.length > 0) { + html += '
All Timezones
'; + const capped = matchOther.slice(0, 50); + html += capped.map(tz => + `
+ ${esc(tz)} + ${hiddenInput.value === tz ? '' : ''} +
` + ).join(''); + if (matchOther.length > 50) { + html += '
Type to narrow down ' + (matchOther.length - 50) + ' more...
'; + } + } + + listEl.innerHTML = html; + + listEl.querySelectorAll('.tz-item').forEach(item => { + item.addEventListener('click', () => { + const tz = item.dataset.tz; + hiddenInput.value = tz; + const label = popularTimezones[tz] || tz; + selectedText.innerHTML = '' + esc(label); + dropdown.classList.add('hidden'); + searchInput.value = ''; + }); + }); + } + + selectedEl.addEventListener('click', () => { + dropdown.classList.toggle('hidden'); + if (!dropdown.classList.contains('hidden')) { + renderList(''); + setTimeout(() => searchInput.focus(), 50); + } + }); + + searchInput.addEventListener('input', () => renderList(searchInput.value)); + + document.addEventListener('click', (e) => { + if (!document.getElementById('tz-picker').contains(e.target)) { + dropdown.classList.add('hidden'); + } + }); + + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + dropdown.classList.add('hidden'); + } + }); +})(); {% endblock %} diff --git a/app/Views/tags/index.twig b/app/Views/tags/index.twig index 552dff4..6d1fb49 100644 --- a/app/Views/tags/index.twig +++ b/app/Views/tags/index.twig @@ -29,7 +29,7 @@ {# Import Button #} - @@ -198,7 +198,7 @@ - {% if auth.isAdmin %} + {% if auth.isAdmin and tag.user_id is not null %} - - - {{ csrf_field() }} -
- {# Drag & Drop Zone #} -
- -
- -
- -

Drag & drop your file here

-

or

- - Browse Files - -

CSV, JSON · Max {{ max_upload_size() }}

-
- -
-
-
-

Expected Format

-

CSV columns: name, color, description

-

JSON: array of objects with same fields

-

Tags that already exist will be skipped. Only your private tags are imported.

-
-
-
- - -
- - - +{% include 'partials/import-modal.twig' with { + prefix: 'tag', + title: 'Import Tags', + action: '/tags/import', + format_html: '

CSV columns: name, color, description

JSON: array of objects with same fields

Tags that already exist will be skipped. Only your private tags are imported.

' +} %} +{% include 'partials/transfer-modal.twig' %} {% endblock %} diff --git a/app/Views/tags/view.twig b/app/Views/tags/view.twig index 0c47db0..c375c2c 100644 --- a/app/Views/tags/view.twig +++ b/app/Views/tags/view.twig @@ -210,16 +210,22 @@ - {% endfor %} diff --git a/cron/check_dns.php b/cron/check_dns.php index d7eb2be..d2bbd9e 100644 --- a/cron/check_dns.php +++ b/cron/check_dns.php @@ -4,20 +4,18 @@ /** * DNS Record Monitoring Cron Job * - * Checks DNS records for all active domains and sends notifications + * Re-checks existing DNS records for all active domains and sends notifications * when changes are detected (new records, removed records, changed records). + * No discovery (brute force / crt.sh) — use discover_dns.php for that. * - * Also handles crt.sh subdomain fetching internally via self-invocation - * with a hard timeout (no separate script needed). + * Also serves as the crt.sh subprocess entry point (--crtsh) for + * DnsService::fetchCrtshSubdomains() used by discover_dns.php. * * Usage: - * php cron/check_dns.php — run the full DNS check + * php cron/check_dns.php — re-check existing records * php cron/check_dns.php --crtsh [max] — (internal) crt.sh subprocess * * Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php - * - * NOTE: Requires a `crtsh_last_fetched` column on the domains table: - * ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL; */ require_once __DIR__ . '/../vendor/autoload.php'; @@ -33,6 +31,7 @@ use App\Models\User; use App\Services\DnsService; use App\Services\NotificationService; use App\Services\Logger; +use App\Helpers\CronHelper; use Core\Database; // ─── Bootstrap ─────────────────────────────────────────────────────────────── @@ -57,15 +56,6 @@ if (php_sapi_name() !== 'cli') { exit(1); } -/** crt.sh subprocess hard kill (seconds). In practice crt.sh 503s in <60s, but HTTP timeout is 900s as insurance. */ -const CRTSH_TIMEOUT_SECONDS = 1800; - -/** Max unique subdomains from crt.sh per domain (0 = no limit) */ -const CRTSH_MAX_SUBDOMAINS = 100; - -/** How often to re-fetch crt.sh per domain (hours). New certs appear gradually. */ -const CRTSH_REFRESH_HOURS = 24; - /** Microseconds to sleep between domains */ const INTER_DOMAIN_DELAY_US = 500000; @@ -91,6 +81,7 @@ try { } $logFile = __DIR__ . '/../logs/dns_cron.log'; +$cron = new CronHelper($logFile); $startTime = microtime(true); logMessage("=== Starting DNS check cron job ==="); @@ -124,8 +115,6 @@ $stats = [ 'in_app_notifications' => 0, 'errors' => 0, 'skipped_unresolved' => 0, - 'crtsh_skipped' => 0, - 'crtsh_fetched' => 0, 'domains_with_changes' => [], ]; @@ -138,7 +127,7 @@ foreach ($domains as $domain) { try { // Quick existence check — skip if domain doesn't resolve at all - if (!domainResolves($domainName)) { + if (!CronHelper::hostnameResolves($domainName)) { logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping"); logTimeSince($domainStartTime); $stats['skipped_unresolved']++; @@ -148,39 +137,10 @@ foreach ($domains as $domain) { $previousRecords = $dnsModel->getPreviousSnapshot($domain['id']); $isFirstScan = empty($previousRecords); - // Gather subdomain candidates: known hosts from DB + // Re-check only known hosts — no discovery (brute force / crt.sh) $existingHosts = $dnsModel->getDistinctHosts($domain['id']); - - // Decide whether to call crt.sh or use cached hosts - $ctSubs = []; - - if (shouldFetchCrtsh($domain, $existingHosts)) { - logMessage(" 🔍 crt.sh: fetching subdomains..."); - - [$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName); - - logMessage(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found"); - $stats['crtsh_fetched']++; - - // Update timestamp if server responded (200 OK). - // Empty [] is valid (no CT entries) — still counts as a successful fetch. - // Only skip update if all attempts 503'd / timed out. - if ($crtshOk) { - $domainModel->update($domain['id'], [ - 'crtsh_last_fetched' => date('Y-m-d H:i:s'), - ]); - } - } else { - logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in " - . crtshHoursUntilRefresh($domain) . "h)"); - $stats['crtsh_skipped']++; - } - - $extraSubs = array_unique(array_merge($existingHosts, $ctSubs)); - - // Fetch fresh DNS records - $newRecords = $dnsService->lookup($domainName, $extraSubs); - $totalRecords = array_sum(array_map('count', $newRecords)); + $newRecords = $dnsService->refreshExisting($domainName, $existingHosts); + $totalRecords = array_sum(array_map('count', $newRecords)); if ($totalRecords === 0) { logMessage(" ⚠ No DNS records found for $domainName"); @@ -262,55 +222,13 @@ exit(0); // ═════════════════════════════════════════════════════════════════════════════ -// Crt.sh smart caching -// ═════════════════════════════════════════════════════════════════════════════ - -/** - * Should we fetch crt.sh for this domain right now? - * - * Skip if we already have enough known hosts and fetched recently. - * Always fetch on first scan or if we have very few known hosts. - * - * NOTE: Requires a `crtsh_last_fetched` DATETIME column on the domains table. - * ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL; - */ -function shouldFetchCrtsh(array $domain, array $existingHosts): bool -{ - // Always fetch if we've never successfully fetched before - $lastFetched = $domain['crtsh_last_fetched'] ?? null; - if (empty($lastFetched)) { - return true; - } - - // Respect the refresh interval — even if domain has few hosts, - // crt.sh already answered (maybe with [] or few results). Don't hammer it. - $hoursSince = (time() - strtotime($lastFetched)) / 3600; - return $hoursSince >= CRTSH_REFRESH_HOURS; -} - -/** - * Hours remaining until next crt.sh refresh (for log messages). - */ -function crtshHoursUntilRefresh(array $domain): string -{ - $lastFetched = $domain['crtsh_last_fetched'] ?? null; - if (empty($lastFetched)) { - return '0'; - } - $hoursSince = (time() - strtotime($lastFetched)) / 3600; - $remaining = max(0, CRTSH_REFRESH_HOURS - $hoursSince); - return sprintf('%.1f', $remaining); -} - - -// ═════════════════════════════════════════════════════════════════════════════ -// Crt.sh subprocess (self-invocation with hard timeout) +// Crt.sh subprocess entry point (invoked by DnsService::fetchCrtshSubdomains) // ═════════════════════════════════════════════════════════════════════════════ /** * Internal crt.sh subprocess entry point. * Called when this script is invoked with: --crtsh [max_subdomains] - * Outputs a JSON array of subdomains to stdout. + * Outputs JSON to stdout. Uses DnsService for HTTP fetch and parsing. * * Wildcard query ?q=%.domain.com with 5 retry attempts. * All HTTP response details are written to stderr for real-time debugging. @@ -329,6 +247,7 @@ function runCrtshSubprocess(array $argv): void $retryDelay = 10; $httpTimeout = 900; + $dnsService = new DnsService(); $url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json'; try { @@ -338,13 +257,12 @@ function runCrtshSubprocess(array $argv): void for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n"); - $response = fetchCrtshWithDebug($url, $httpTimeout); + $response = $dnsService->fetchCrtshUrl($url, $httpTimeout, true); - // HTTP 200 — server answered, don't retry regardless of content if ($response['status'] === 200) { $gotHttp200 = true; if (!empty($response['data'])) { - $result = extractSubdomains($response['data'], $domain); + $result = $dnsService->extractCrtshSubdomains($response['data'], $domain); fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n"); } else { fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n"); @@ -352,7 +270,6 @@ function runCrtshSubprocess(array $argv): void break; } - // Non-200 (503, timeout, connection error) — retry if ($attempt < $maxAttempts) { fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n"); sleep($retryDelay); @@ -361,7 +278,6 @@ function runCrtshSubprocess(array $argv): void } } - // Apply cap if ($maxSubdomains > 0 && count($result) > $maxSubdomains) { fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n"); $result = array_slice(array_values($result), 0, $maxSubdomains); @@ -376,253 +292,10 @@ function runCrtshSubprocess(array $argv): void } } -/** - * Fetch a crt.sh URL with full debug output to stderr. - * Dumps HTTP response headers + body preview immediately so you see - * exactly what the server returned — like watching curl in real-time. - * - * @param string $url Full crt.sh URL - * @param int $timeout HTTP timeout in seconds - * @return array{status: int, body_length: int, data: array, time: float} - */ -function fetchCrtshWithDebug(string $url, int $timeout = 900): array -{ - $ctx = stream_context_create([ - 'http' => [ - 'timeout' => $timeout, - 'ignore_errors' => true, - 'header' => implode("\r\n", [ - 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept: application/json, text/plain, */*', - 'Accept-Language: en-US,en;q=0.9', - 'Connection: keep-alive', - ]), - ], - ]); - - $start = microtime(true); - $http_response_header = null; - $body = @file_get_contents($url, false, $ctx); - $elapsed = microtime(true) - $start; - - // ── Dump full response to stderr ────────────────────────────────── - fwrite(STDERR, "--- response ---\n"); - fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n"); - - if (isset($http_response_header) && is_array($http_response_header)) { - foreach ($http_response_header as $h) { - fwrite(STDERR, "$h\n"); - } - } else { - fwrite(STDERR, "(no response headers — connection failed or timeout)\n"); - } - - $bodyLen = is_string($body) ? strlen($body) : 0; - fwrite(STDERR, "Body: $bodyLen bytes\n"); - - if (is_string($body) && $bodyLen > 0) { - // Show first 2000 chars of body so you can see errors, JSON start, etc. - $preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body; - fwrite(STDERR, $preview . "\n"); - } - - fwrite(STDERR, "--- end response ---\n"); - - // ── Parse status and JSON ───────────────────────────────────────── - $status = 0; - if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) { - $status = (int) $m[0]; - } - - $data = []; - if ($status === 200 && is_string($body) && $bodyLen > 2) { - $decoded = json_decode($body, true); - if (is_array($decoded)) { - $data = $decoded; - } - } - - return [ - 'status' => $status, - 'body_length' => $bodyLen, - 'data' => $data, - 'time' => $elapsed, - ]; -} - -/** - * Extract unique subdomain names from raw crt.sh JSON response. - * - * Each entry has a `name_value` field that may contain multiple newline-separated - * names, including wildcards. We strip wildcards, filter to our target domain, - * and return only the subdomain prefixes (e.g. "www", "mail", "api"). - * - * @param array $crtshData Decoded JSON array from crt.sh - * @param string $domain The base domain (e.g. "example.com") - * @return string[] Unique subdomain prefixes - */ -function extractSubdomains(array $crtshData, string $domain): array -{ - $domainLower = strtolower($domain); - $suffix = '.' . $domainLower; - $suffixLen = strlen($suffix); - $subs = []; - - foreach ($crtshData as $entry) { - if (empty($entry['name_value'])) { - continue; - } - - foreach (explode("\n", $entry['name_value']) as $name) { - $name = strtolower(trim($name)); - - // Strip wildcard prefix - if (strpos($name, '*.') === 0) { - $name = substr($name, 2); - } - - // Skip the apex domain itself - if ($name === $domainLower) { - continue; - } - - // Must be a subdomain of our domain - if (substr($name, -$suffixLen) !== $suffix) { - continue; - } - - // Extract the subdomain part (everything before .domain.tld) - $sub = substr($name, 0, strlen($name) - $suffixLen); - if (!empty($sub)) { - $subs[$sub] = true; - } - } - } - - return array_keys($subs); -} - - -// ═════════════════════════════════════════════════════════════════════════════ -// Subprocess management (main process side) -// ═════════════════════════════════════════════════════════════════════════════ - -/** - * Spawn a subprocess of this script in --crtsh mode with a hard timeout. - * Relays stderr from the subprocess to logMessage in REAL-TIME so you see - * every HTTP response, retry, and status as it happens. - * - * @return array{0: string[], 1: bool} [subdomains, ok (true if server responded 200)] - */ -function fetchCrtshWithTimeout(string $domainName): array -{ - $phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php'; - $cmd = [$phpBin, __FILE__, '--crtsh', $domainName]; - - if (CRTSH_MAX_SUBDOMAINS > 0) { - $cmd[] = (string) CRTSH_MAX_SUBDOMAINS; - } - - $proc = proc_open($cmd, [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ], $pipes, __DIR__ . '/..'); - - if (!is_resource($proc)) { - return [[], false]; - } - - fclose($pipes[0]); - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - $start = time(); - $stdout = ''; - $stderrBuffer = ''; - - while (true) { - $status = proc_get_status($proc); - - if (!$status['running']) { - break; - } - - $elapsed = time() - $start; - - // Hard timeout — kill the subprocess - if ($elapsed >= CRTSH_TIMEOUT_SECONDS) { - $stdout .= drainStream($pipes[1]); - $stderrBuffer .= drainStream($pipes[2]); - flushStderrLines($stderrBuffer); - proc_terminate($proc, 9); - proc_close($proc); - logMessage(" ✗ crt.sh killed after {$elapsed}s (hard timeout)"); - return [[], false]; - } - - // Read available data from pipes - $readable = [$pipes[1], $pipes[2]]; - $w = $e = null; - if (@stream_select($readable, $w, $e, 0, 200000) > 0) { - foreach ($readable as $stream) { - $chunk = stream_get_contents($stream); - if ($stream === $pipes[1]) { - $stdout .= $chunk; - } else { - $stderrBuffer .= $chunk; - // Flush complete lines to terminal immediately - flushStderrLines($stderrBuffer); - } - } - } - usleep(100000); - } - - // Drain remaining output - $stdout .= stream_get_contents($pipes[1]); - $stderrBuffer .= stream_get_contents($pipes[2]); - flushStderrLines($stderrBuffer); - fclose($pipes[1]); - fclose($pipes[2]); - proc_close($proc); - - $decoded = json_decode($stdout, true); - $ok = is_array($decoded) && !empty($decoded['ok']); - $subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : []; - return [$subs, $ok]; -} - -/** - * Flush complete lines from stderr buffer to logMessage in real-time. - * Keeps any incomplete trailing line in the buffer for next call. - */ -function flushStderrLines(string &$buffer): void -{ - while (($pos = strpos($buffer, "\n")) !== false) { - $line = trim(substr($buffer, 0, $pos)); - $buffer = substr($buffer, $pos + 1); - if ($line !== '') { - logMessage(" ↳ $line"); - } - } -} - - // ═════════════════════════════════════════════════════════════════════════════ // DNS helpers // ═════════════════════════════════════════════════════════════════════════════ -/** - * Check whether a domain resolves at all (SOA, A, or AAAA). - */ -function domainResolves(string $domain): bool -{ - return @checkdnsrr($domain, 'SOA') - || @checkdnsrr($domain, 'A') - || @checkdnsrr($domain, 'AAAA'); -} - /** * Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo). */ @@ -785,73 +458,29 @@ function sendInAppNotifications( // ═════════════════════════════════════════════════════════════════════════════ -// Logging / formatting helpers +// Logging helpers (thin wrappers around CronHelper) // ═════════════════════════════════════════════════════════════════════════════ function logMessage(string $message): void { - global $logFile; - $timestamp = date('Y-m-d H:i:s'); - $line = "[$timestamp] $message\n"; - file_put_contents($logFile, $line, FILE_APPEND); - echo $line; + global $cron; + $cron->log($message); } function logTimeSince(float $since): void { - logMessage(" ⏱ " . formatDuration(microtime(true) - $since)); -} - -function formatDuration(float $seconds): string -{ - if ($seconds < 60) { - return sprintf("%.1fs", $seconds); - } - $m = (int) floor($seconds / 60); - $s = $seconds - $m * 60; - return $m . 'm ' . sprintf("%.1fs", $s); -} - -function formatElapsedTime(float $seconds): string -{ - if ($seconds < 60) { - return sprintf("%.2f seconds", $seconds); - } - if ($seconds < 3600) { - $m = (int) floor($seconds / 60); - $s = $seconds - $m * 60; - return sprintf("%d minute%s %.2f seconds", $m, $m !== 1 ? 's' : '', $s); - } - $h = (int) floor($seconds / 3600); - $m = (int) floor(($seconds - $h * 3600) / 60); - $s = $seconds - $h * 3600 - $m * 60; - return sprintf("%d hour%s %d minute%s %.2f seconds", - $h, $h !== 1 ? 's' : '', $m, $m !== 1 ? 's' : '', $s); -} - -/** - * Drain remaining data from a non-blocking stream and close it. - */ -function drainStream($stream): string -{ - if (!is_resource($stream)) { - return ''; - } - $data = stream_get_contents($stream); - fclose($stream); - return $data ?: ''; + global $cron; + $cron->logTimeSince($since); } function printSummary(array $stats, float $startTime): void { - $elapsed = formatElapsedTime(microtime(true) - $startTime); + $elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime); logMessage("\n=== DNS cron job completed ==="); logMessage("Domains checked: {$stats['checked']}"); logMessage("Skipped (by status): {$stats['skipped_by_status']}"); logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}"); - logMessage("Crt.sh fetched: {$stats['crtsh_fetched']}"); - logMessage("Crt.sh skipped (cached): {$stats['crtsh_skipped']}"); logMessage("Changes detected: {$stats['changes_detected']}"); logMessage("Records added: {$stats['records_added']}"); logMessage("Records removed: {$stats['records_removed']}"); diff --git a/cron/check_domains.php b/cron/check_domains.php index c7affe0..e7f25ca 100644 --- a/cron/check_domains.php +++ b/cron/check_domains.php @@ -23,6 +23,7 @@ use App\Models\User; use App\Services\WhoisService; use App\Services\NotificationService; use App\Services\UpdateService; +use App\Helpers\CronHelper; use Core\Database; // Load environment variables @@ -56,27 +57,11 @@ try { // Log file $logFile = __DIR__ . '/../logs/cron.log'; +$cron = new CronHelper($logFile); -function logMessage(string $message) { - global $logFile; - $timestamp = date('Y-m-d H:i:s'); - file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND); - echo "[$timestamp] $message\n"; -} - -function formatElapsedTime(float $seconds): string { - if ($seconds < 60) { - return sprintf("%.2f seconds", $seconds); - } elseif ($seconds < 3600) { - $minutes = floor($seconds / 60); - $remainingSeconds = $seconds - ($minutes * 60); - return sprintf("%d minute%s %.2f seconds", $minutes, $minutes != 1 ? 's' : '', $remainingSeconds); - } else { - $hours = floor($seconds / 3600); - $remainingMinutes = floor(($seconds - ($hours * 3600)) / 60); - $remainingSeconds = $seconds - ($hours * 3600) - ($remainingMinutes * 60); - return sprintf("%d hour%s %d minute%s %.2f seconds", $hours, $hours != 1 ? 's' : '', $remainingMinutes, $remainingMinutes != 1 ? 's' : '', $remainingSeconds); - } +function logMessage(string $message): void { + global $cron; + $cron->log($message); } // Record start time @@ -806,7 +791,7 @@ $settingModel->updateLastCheckRun(); // Calculate elapsed time $endTime = microtime(true); $elapsedTime = $endTime - $startTime; -$formattedTime = formatElapsedTime($elapsedTime); +$formattedTime = CronHelper::formatElapsedTime($elapsedTime); // Summary logMessage("\n=== Cron job completed ==="); diff --git a/cron/check_ssl.php b/cron/check_ssl.php index b67ca03..46d5f03 100644 --- a/cron/check_ssl.php +++ b/cron/check_ssl.php @@ -26,6 +26,7 @@ use App\Models\User; use App\Services\Logger; use App\Services\NotificationService; use App\Services\SslService; +use App\Helpers\CronHelper; use Core\Database; if (php_sapi_name() !== 'cli') { @@ -56,6 +57,7 @@ try { } $logFile = __DIR__ . '/../logs/ssl_cron.log'; +$cron = new CronHelper($logFile); $startTime = microtime(true); logMessage("=== Starting SSL check cron job ==="); @@ -132,7 +134,7 @@ foreach ($domains as $domain) { $port = (int)($target['port'] ?? 443); $endpointLabel = $sslService->formatTargetLabel($hostname, $port); - if (!hostnameResolves($hostname)) { + if (!CronHelper::hostnameResolves($hostname)) { logMessage(" {$endpointLabel}: skipped (hostname does not resolve)"); $stats['skipped_unresolved']++; continue; @@ -231,7 +233,7 @@ logMessage("Issue endpoints: {$stats['issues_detected']}"); logMessage("External notifications: {$stats['notifications_sent']}"); logMessage("In-app notifications: {$stats['in_app_notifications']}"); logMessage("Errors: {$stats['errors']}"); -logMessage("Execution time: " . formatElapsedTime(microtime(true) - $startTime)); +logMessage("Execution time: " . CronHelper::formatElapsedTime(microtime(true) - $startTime)); logMessage("============================\n"); exit(0); @@ -350,57 +352,12 @@ function sendInAppSslNotifications( function logMessage(string $message): void { - global $logFile; - $timestamp = date('Y-m-d H:i:s'); - $line = "[{$timestamp}] {$message}\n"; - file_put_contents($logFile, $line, FILE_APPEND); - echo $line; + global $cron; + $cron->log($message); } function logTimeSince(float $since): void { - logMessage(" -> " . formatDuration(microtime(true) - $since)); -} - -function hostnameResolves(string $hostname): bool -{ - return @checkdnsrr($hostname, 'SOA') - || @checkdnsrr($hostname, 'A') - || @checkdnsrr($hostname, 'AAAA'); -} - -function formatDuration(float $seconds): string -{ - if ($seconds < 60) { - return sprintf('%.1fs', $seconds); - } - - $minutes = (int) floor($seconds / 60); - $remaining = $seconds - ($minutes * 60); - return $minutes . 'm ' . sprintf('%.1fs', $remaining); -} - -function formatElapsedTime(float $seconds): string -{ - if ($seconds < 60) { - return sprintf('%.2f seconds', $seconds); - } - - if ($seconds < 3600) { - $minutes = (int) floor($seconds / 60); - $remaining = $seconds - ($minutes * 60); - return sprintf('%d minute%s %.2f seconds', $minutes, $minutes !== 1 ? 's' : '', $remaining); - } - - $hours = (int) floor($seconds / 3600); - $minutes = (int) floor(($seconds - ($hours * 3600)) / 60); - $remaining = $seconds - ($hours * 3600) - ($minutes * 60); - return sprintf( - '%d hour%s %d minute%s %.2f seconds', - $hours, - $hours !== 1 ? 's' : '', - $minutes, - $minutes !== 1 ? 's' : '', - $remaining - ); + global $cron; + $cron->logTimeSince($since, ' -> '); } diff --git a/cron/discover_dns.php b/cron/discover_dns.php new file mode 100644 index 0000000..f566c75 --- /dev/null +++ b/cron/discover_dns.php @@ -0,0 +1,225 @@ +#!/usr/bin/env php +load(); +new Database(); + +$domainModel = new Domain(); +$dnsModel = new DnsRecord(); +$dnsService = new DnsService(); +$logger = new Logger('dns-discover'); + +$settingModel = new \App\Models\Setting(); +try { + $appSettings = $settingModel->getAppSettings(); + date_default_timezone_set($appSettings['app_timezone']); +} catch (\Exception $e) { + date_default_timezone_set('UTC'); +} + +$logFile = __DIR__ . '/../logs/dns_discover.log'; +$cron = new CronHelper($logFile); + +// ─── Parse CLI arguments ───────────────────────────────────────────────────── + +$targetDomain = null; +$quickMode = false; + +for ($i = 1; $i < count($argv); $i++) { + if ($argv[$i] === '--domain' && isset($argv[$i + 1])) { + $targetDomain = $argv[++$i]; + } elseif ($argv[$i] === '--quick') { + $quickMode = true; + } +} + +// ─── Resolve domains to scan ──────────────────────────────────────────────── + +if ($targetDomain) { + $domainRow = null; + foreach ($domainModel->all() as $d) { + if (strcasecmp($d['domain_name'], $targetDomain) === 0) { + $domainRow = $d; + break; + } + } + if (!$domainRow) { + $cron->log("Domain not found in database: $targetDomain"); + exit(1); + } + $domains = [$domainRow]; +} else { + $checkableStatuses = ['active', 'expiring_soon']; + $allDnsEnabled = array_values(array_filter( + $domainModel->where('is_active', 1), + static fn($d): bool => ($d['dns_monitoring_enabled'] ?? 1) == 1 + )); + $domains = array_values(array_filter($allDnsEnabled, static function ($d) use ($checkableStatuses): bool { + $status = strtolower($d['status'] ?? ''); + return in_array($status, $checkableStatuses, true); + })); +} + +$modeLabel = $quickMode ? 'Quick Scan' : 'Deep Scan'; +$startTime = microtime(true); +$cron->log("=== DNS Discovery ({$modeLabel}) — " . count($domains) . " domain(s) ==="); + +$stats = [ + 'scanned' => 0, + 'skipped' => 0, + 'added' => 0, + 'updated' => 0, + 'removed' => 0, + 'errors' => 0, +]; + +// ─── crt.sh settings (deep scan only) ────────────────────────────────────── + +const CRTSH_TIMEOUT_SECONDS = 1800; +const CRTSH_MAX_SUBDOMAINS = 100; + +// ─── Scan loop ─────────────────────────────────────────────────────────────── + +foreach ($domains as $domain) { + $domainName = $domain['domain_name']; + $domStart = microtime(true); + $cron->log("Discovering: $domainName ($modeLabel)"); + + try { + if (!CronHelper::hostnameResolves($domainName)) { + $cron->log(" ⏭ Domain does not resolve, skipping"); + $cron->logTimeSince($domStart); + $stats['skipped']++; + continue; + } + + if ($quickMode) { + $newRecords = $dnsService->quickScan($domainName); + } else { + // Deep scan: gather crt.sh subdomains + existing hosts, then full lookup + $existingHosts = $dnsModel->getDistinctHosts($domain['id']); + $ctSubs = []; + + $cron->log(" 🔍 crt.sh: fetching subdomains..."); + [$ctSubs, $crtshOk] = $dnsService->fetchCrtshSubdomains( + $domainName, + CRTSH_MAX_SUBDOMAINS, + CRTSH_TIMEOUT_SECONDS, + fn(string $line) => $cron->log(" ↳ $line") + ); + $cron->log(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found"); + + if ($crtshOk) { + $domainModel->update($domain['id'], [ + 'crtsh_last_fetched' => date('Y-m-d H:i:s'), + ]); + } + + $extraSubs = array_unique(array_merge($existingHosts, $ctSubs)); + $newRecords = $dnsService->lookup($domainName, $extraSubs, fn(string $msg) => $cron->log(" 🔎 $msg")); + } + + $totalRecords = array_sum(array_map('count', $newRecords)); + if ($totalRecords === 0) { + $cron->log(" ⚠ No DNS records found"); + $stats['errors']++; + $cron->logTimeSince($domStart); + continue; + } + + // Enrich IP details + $ips = []; + foreach (['A', 'AAAA'] as $type) { + foreach ($newRecords[$type] ?? [] as $r) { + if (!empty($r['value'])) { + $ips[] = $r['value']; + } + } + } + if (!empty($ips)) { + $ipDetails = $dnsService->lookupIpDetails($ips); + foreach (['A', 'AAAA'] as $type) { + foreach ($newRecords[$type] as &$rec) { + if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) { + $rec['raw']['_ip_info'] = $ipDetails[$rec['value']]; + } + } + unset($rec); + } + } + + $saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords); + $domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]); + + $stats['scanned']++; + $stats['added'] += $saveStats['added']; + $stats['updated'] += $saveStats['updated']; + $stats['removed'] += $saveStats['removed']; + + $cron->log(" ✓ $totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})"); + + $logger->info("DNS discovery completed", [ + 'domain' => $domainName, + 'mode' => $quickMode ? 'quick' : 'deep', + 'records' => $totalRecords, + 'added' => $saveStats['added'], + ]); + + $cron->logTimeSince($domStart); + + } catch (\Exception $e) { + $cron->log(" ✗ Error: " . $e->getMessage()); + $cron->logTimeSince($domStart); + $logger->error("DNS discovery failed", [ + 'domain' => $domainName, + 'error' => $e->getMessage(), + ]); + $stats['errors']++; + } +} + +// ─── Summary ───────────────────────────────────────────────────────────────── + +$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime); +$cron->log("\n=== DNS Discovery completed ==="); +$cron->log("Domains scanned: {$stats['scanned']}"); +$cron->log("Skipped: {$stats['skipped']}"); +$cron->log("Records added: {$stats['added']}"); +$cron->log("Records updated: {$stats['updated']}"); +$cron->log("Records removed: {$stats['removed']}"); +$cron->log("Errors: {$stats['errors']}"); +$cron->log("Execution time: $elapsed"); +$cron->log("==========================\n"); + +exit(0); diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index 65c0b99..a1d147a 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -360,6 +360,7 @@ CREATE TABLE IF NOT EXISTS dns_records ( ttl INT NULL, priority INT NULL COMMENT 'MX priority', is_cloudflare BOOLEAN DEFAULT FALSE, + source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered', raw_data JSON NULL COMMENT 'Full record data from dns_get_record()', first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/database/migrations/029_add_dns_record_source.sql b/database/migrations/029_add_dns_record_source.sql new file mode 100644 index 0000000..38ea5a8 --- /dev/null +++ b/database/migrations/029_add_dns_record_source.sql @@ -0,0 +1,7 @@ +-- Add source tracking to DNS records (discovered vs manual vs imported) +ALTER TABLE dns_records + ADD COLUMN source ENUM('discovered','manual','imported') NOT NULL DEFAULT 'discovered' + AFTER is_cloudflare; + +INSERT INTO migrations (migration) VALUES ('029_add_dns_record_source.sql') +ON DUPLICATE KEY UPDATE migration=migration; diff --git a/routes/web.php b/routes/web.php index 1f2ee36..9f191e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -86,6 +86,11 @@ $router->post('/domains/{id}/update', [DomainController::class, 'update']); $router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']); $router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']); $router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']); +$router->post('/domains/{id}/discover-dns', [DomainController::class, 'discoverDns']); +$router->post('/domains/{id}/dns-records', [DomainController::class, 'addDnsRecord']); +$router->post('/domains/{id}/dns-records/bulk-delete', [DomainController::class, 'bulkDeleteDnsRecords']); +$router->post('/domains/{id}/dns-records/{recordId}/delete', [DomainController::class, 'deleteDnsRecord']); +$router->post('/domains/{id}/dns-import', [DomainController::class, 'importDnsZone']); $router->post('/domains/{id}/ssl/add', [DomainController::class, 'addSslHost']); $router->post('/domains/{id}/ssl/refresh-all', [DomainController::class, 'refreshAllSsl']); $router->post('/domains/{id}/ssl/bulk-refresh', [DomainController::class, 'bulkRefreshSsl']);
Tag Value (CA) Flags TTL
- {{ rawData.tag|default('-') }} + {{ rawData.tag|default('-') }}{% if record.source|default('discovered') == 'manual' %} + manual + {% elseif record.source|default('discovered') == 'imported' %} + imported + {% endif %} {{ rawData.value|default(record.value) }} {{ rawData.flags|default('0') }} {{ record.ttl }}s +
+ {{ csrf_field()|raw }} + +
+
@@ -273,6 +279,12 @@ Refresh WHOIS + {% if auth.isAdmin %} + + {% endif %} {% endfor %} @@ -348,4 +360,28 @@ {% endif %} + +{% if auth.isAdmin %} + +{% include 'partials/transfer-modal.twig' %} +{% endif %} {% endblock %} diff --git a/app/Views/tld-registry/index.twig b/app/Views/tld-registry/index.twig index 064daea..f303e88 100644 --- a/app/Views/tld-registry/index.twig +++ b/app/Views/tld-registry/index.twig @@ -348,10 +348,10 @@ {% if session.role is defined and session.role == 'admin' %} - + - + {% endif %} @@ -421,7 +421,7 @@ View {% if session.role is defined and session.role == 'admin' %} - + Refresh {% endif %} @@ -584,63 +584,12 @@ -{# Import TLD Modal #} - +{% include 'partials/import-modal.twig' with { + prefix: 'tld', + title: 'Import TLDs', + action: '/tld-registry/import', + format_html: '

CSV columns: tld, whois_server, rdap_servers, registry_url, is_active

JSON: array of objects with same fields

Existing TLDs will be updated. New TLDs will be created as active.

' +} %} {% endblock %} diff --git a/app/Views/tld-registry/view.twig b/app/Views/tld-registry/view.twig index 52ee184..53ea5ca 100644 --- a/app/Views/tld-registry/view.twig +++ b/app/Views/tld-registry/view.twig @@ -21,11 +21,11 @@
{% if session.role is defined and session.role == 'admin' %} - + Refresh - + Toggle @@ -215,13 +215,13 @@
{% if session.role is defined and session.role == 'admin' %} - +
Refresh from IANA
- +
diff --git a/app/Views/users/index.twig b/app/Views/users/index.twig index 527d6c8..dc99ff6 100644 --- a/app/Views/users/index.twig +++ b/app/Views/users/index.twig @@ -396,7 +396,7 @@ function getSelectedUserIds() { return Array.from(checkboxes).map(cb => cb.value); } -function bulkToggleStatus(action) { +async function bulkToggleStatus(action) { const userIds = getSelectedUserIds(); if (userIds.length === 0) { @@ -405,9 +405,8 @@ function bulkToggleStatus(action) { } const actionText = action === 'active' ? 'activate' : 'deactivate'; - if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) { - return; - } + var ok = await confirmAction({ message: 'Are you sure you want to ' + actionText + ' ' + userIds.length + ' user(s)?', title: actionText.charAt(0).toUpperCase() + actionText.slice(1) + ' Users', icon: action === 'active' ? 'fa-user-check text-green-500' : 'fa-user-slash text-orange-500' }); + if (!ok) return; const form = document.createElement('form'); form.method = 'POST'; @@ -450,10 +449,9 @@ function toggleUserStatus(userId) { form.submit(); } -function deleteUser(userId) { - if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { - return; - } +async function deleteUser(userId) { + var ok = await confirmAction({ message: 'Are you sure you want to delete this user? This action cannot be undone.' }); + if (!ok) return; const form = document.createElement('form'); form.method = 'POST'; @@ -469,7 +467,7 @@ function deleteUser(userId) { form.submit(); } -function bulkDeleteUsers() { +async function bulkDeleteUsers() { const userIds = getSelectedUserIds(); if (userIds.length === 0) { @@ -477,9 +475,8 @@ function bulkDeleteUsers() { return; } - if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) { - return; - } + var ok = await confirmAction({ message: 'Are you sure you want to delete ' + userIds.length + ' user(s)? This action cannot be undone.' }); + if (!ok) return; const form = document.createElement('form'); form.method = 'POST'; diff --git a/app/Views/users/show.twig b/app/Views/users/show.twig index 7a8e962..6d178a6 100644 --- a/app/Views/users/show.twig +++ b/app/Views/users/show.twig @@ -48,12 +48,12 @@
{{ csrf_field() }} {% if isActive %} - {% else %} - @@ -61,7 +61,7 @@
{{ csrf_field() }} - @@ -529,8 +529,15 @@ {{ domain.statusText }}
- {{ domain.group_name|default('—') }} + + {% if domain.group_name %} + + + {{ domain.group_name }} + + {% else %} + No Group + {% endif %}