Add DNS monitoring and refresh functionality
Introduce DNS monitoring: add DnsService (comprehensive DNS lookup, crt.sh discovery, Cloudflare detection, IP enrichment) and a new DnsRecord model to persist snapshots, manage diffs, and provide queries/stats. Update DomainController to support a dns_monitoring_enabled flag, refactor WHOIS/DNS refresh logic into performWhoisRefresh/performDnsRefresh, and add endpoints for refreshWhois, refreshDns and refreshAll; send notifications when DNS monitoring is toggled. Add UI templates/tabs for DNS, billing, notifications, overview, SSL and WHOIS and wire DNS data into the domain view; expose cached IP details. Add cron/check_dns.php and migration 027_add_dns_monitoring.sql (and include it in installer migration lists). Other tweaks: safer EmailHelper subject handling, TldRegistry search improvements, domain sorting using an effective status (expiring_soon), Discord channel null-safe fields, settings UI additions (domain_view_template and cron staleness warnings), and route/migration updates. This enables scheduled and manual DNS scans with persistent records and notifications.
This commit is contained in:
@@ -608,6 +608,7 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
|
||||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||||
|
$dnsMonitoringEnabled = isset($_POST['dns_monitoring_enabled']) ? 1 : 0;
|
||||||
$tagsInput = trim($_POST['tags'] ?? '');
|
$tagsInput = trim($_POST['tags'] ?? '');
|
||||||
$manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null;
|
$manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null;
|
||||||
|
|
||||||
@@ -642,6 +643,7 @@ class DomainController extends Controller
|
|||||||
$this->domainModel->update($id, [
|
$this->domainModel->update($id, [
|
||||||
'notification_group_id' => $groupId,
|
'notification_group_id' => $groupId,
|
||||||
'is_active' => $isActive,
|
'is_active' => $isActive,
|
||||||
|
'dns_monitoring_enabled' => $dnsMonitoringEnabled,
|
||||||
'expiration_date' => $manualExpirationDate
|
'expiration_date' => $manualExpirationDate
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -664,6 +666,24 @@ class DomainController extends Controller
|
|||||||
$notificationService->sendToGroup($groupId, $subject, $message);
|
$notificationService->sendToGroup($groupId, $subject, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send notification if DNS monitoring changed and has notification group
|
||||||
|
$dnsMonitoringChanged = (($domain['dns_monitoring_enabled'] ?? 1) != $dnsMonitoringEnabled);
|
||||||
|
if ($dnsMonitoringChanged && $groupId) {
|
||||||
|
$notificationService = new \App\Services\NotificationService();
|
||||||
|
|
||||||
|
if ($dnsMonitoringEnabled) {
|
||||||
|
$message = "🟢 DNS monitoring has been ENABLED for {$domain['domain_name']}\n\n" .
|
||||||
|
"DNS records will be checked for changes and you'll receive alerts when they change.";
|
||||||
|
$subject = "✅ DNS Monitoring Enabled: {$domain['domain_name']}";
|
||||||
|
} else {
|
||||||
|
$message = "🔴 DNS monitoring has been DISABLED for {$domain['domain_name']}\n\n" .
|
||||||
|
"DNS records will no longer be checked. You will not receive DNS change alerts.";
|
||||||
|
$subject = "⏸️ DNS Monitoring Disabled: {$domain['domain_name']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$notificationService->sendToGroup($groupId, $subject, $message);
|
||||||
|
}
|
||||||
|
|
||||||
// Also send notification if group changed and monitoring is active
|
// Also send notification if group changed and monitoring is active
|
||||||
if (!$statusChanged && $isActive && $oldGroupId != $groupId) {
|
if (!$statusChanged && $isActive && $oldGroupId != $groupId) {
|
||||||
$notificationService = new \App\Services\NotificationService();
|
$notificationService = new \App\Services\NotificationService();
|
||||||
@@ -697,50 +717,26 @@ class DomainController extends Controller
|
|||||||
$this->redirect('/domains/' . $id);
|
$this->redirect('/domains/' . $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refresh($params = [])
|
/**
|
||||||
|
* Perform WHOIS lookup and persist results.
|
||||||
|
* @return string|null Status message on success, null on failure.
|
||||||
|
*/
|
||||||
|
private function performWhoisRefresh(int $id, array $domain): ?string
|
||||||
{
|
{
|
||||||
$id = $params['id'] ?? 0;
|
|
||||||
$domain = $this->checkDomainAccess($id);
|
|
||||||
|
|
||||||
if (!$domain) {
|
|
||||||
$_SESSION['error'] = 'Domain not found';
|
|
||||||
$this->redirect('/domains');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log domain refresh start
|
|
||||||
$logger = new \App\Services\Logger();
|
$logger = new \App\Services\Logger();
|
||||||
$logger->info('Domain refresh started', [
|
|
||||||
'domain_id' => $id,
|
|
||||||
'domain_name' => $domain['domain_name'],
|
|
||||||
'user_id' => \Core\Auth::id(),
|
|
||||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get fresh WHOIS information
|
|
||||||
$whoisData = $this->whoisService->getDomainInfo($domain['domain_name']);
|
$whoisData = $this->whoisService->getDomainInfo($domain['domain_name']);
|
||||||
|
|
||||||
if (!$whoisData) {
|
if (!$whoisData) {
|
||||||
$logger->error('Domain refresh failed - WHOIS data not retrieved', [
|
$logger->error('WHOIS refresh failed', [
|
||||||
'domain_id' => $id,
|
'domain_id' => $id,
|
||||||
'domain_name' => $domain['domain_name'],
|
'domain_name' => $domain['domain_name'],
|
||||||
'user_id' => \Core\Auth::id()
|
'user_id' => \Core\Auth::id()
|
||||||
]);
|
]);
|
||||||
|
return null;
|
||||||
$_SESSION['error'] = 'Could not retrieve WHOIS information';
|
|
||||||
// Check if we came from view page
|
|
||||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
|
||||||
if (strpos($referer, '/domains/' . $id) !== false) {
|
|
||||||
$this->redirect('/domains/' . $id);
|
|
||||||
} else {
|
|
||||||
$this->redirect('/domains');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use WHOIS expiration date if available, otherwise preserve manual expiration date
|
|
||||||
$expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date'];
|
$expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date'];
|
||||||
|
|
||||||
$status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData);
|
$status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData);
|
||||||
|
|
||||||
$this->domainModel->update($id, [
|
$this->domainModel->update($id, [
|
||||||
@@ -754,8 +750,7 @@ class DomainController extends Controller
|
|||||||
'whois_data' => json_encode($whoisData)
|
'whois_data' => json_encode($whoisData)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Log successful domain refresh
|
$logger->info('WHOIS refresh completed', [
|
||||||
$logger->info('Domain refresh completed successfully', [
|
|
||||||
'domain_id' => $id,
|
'domain_id' => $id,
|
||||||
'domain_name' => $domain['domain_name'],
|
'domain_name' => $domain['domain_name'],
|
||||||
'new_status' => $status,
|
'new_status' => $status,
|
||||||
@@ -764,19 +759,129 @@ class DomainController extends Controller
|
|||||||
'user_id' => \Core\Auth::id()
|
'user_id' => \Core\Auth::id()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = 'Domain information refreshed';
|
return 'WHOIS updated';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we came from view page or list page
|
/**
|
||||||
|
* Perform DNS lookup and persist results.
|
||||||
|
* @return string Status message (always returns, even on zero records).
|
||||||
|
*/
|
||||||
|
private function performDnsRefresh(int $id, array $domain): string
|
||||||
|
{
|
||||||
|
$logger = new \App\Services\Logger('dns');
|
||||||
|
|
||||||
|
$dnsService = new \App\Services\DnsService();
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
|
||||||
|
// Feed previously known hosts so manual refresh doesn't lose crt.sh-discovered subdomains
|
||||||
|
$existingHosts = $dnsModel->getDistinctHosts($id);
|
||||||
|
$records = $dnsService->lookup($domain['domain_name'], $existingHosts);
|
||||||
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
|
||||||
|
if ($totalRecords === 0) {
|
||||||
|
$logger->warning('DNS refresh returned no records', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
]);
|
||||||
|
return 'DNS: no records found';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich A/AAAA records with IP details (PTR, ASN, geo) and store in raw_data
|
||||||
|
$ips = [];
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (!empty($records[$type])) {
|
||||||
|
foreach ($records[$type] as $r) {
|
||||||
|
if (!empty($r['value'])) {
|
||||||
|
$ips[] = $r['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($ips)) {
|
||||||
|
$ipDetails = $dnsService->lookupIpDetails($ips);
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (!empty($records[$type])) {
|
||||||
|
foreach ($records[$type] as &$rec) {
|
||||||
|
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
||||||
|
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $dnsModel->saveSnapshot($id, $records);
|
||||||
|
$this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
|
$logger->info('DNS refresh completed', [
|
||||||
|
'domain_name' => $domain['domain_name'],
|
||||||
|
'total' => $totalRecords,
|
||||||
|
'added' => $stats['added'],
|
||||||
|
'updated' => $stats['updated'],
|
||||||
|
'removed' => $stats['removed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return "DNS updated ({$totalRecords} records)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect back to the originating page (domain view or list).
|
||||||
|
*/
|
||||||
|
private function redirectBackToDomain(int $id, string $hash = ''): void
|
||||||
|
{
|
||||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||||
if (strpos($referer, '/domains/' . $id) !== false) {
|
if (strpos($referer, '/domains/' . $id) !== false) {
|
||||||
// Came from view page, go back to view page
|
$this->redirect('/domains/' . $id . $hash);
|
||||||
$this->redirect('/domains/' . $id);
|
|
||||||
} else {
|
} else {
|
||||||
// Came from list page, stay on list page
|
|
||||||
$this->redirect('/domains');
|
$this->redirect('/domains');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function refreshWhois($params = [])
|
||||||
|
{
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->performWhoisRefresh($id, $domain);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
$_SESSION['error'] = 'Could not retrieve WHOIS information';
|
||||||
|
} else {
|
||||||
|
$_SESSION['success'] = 'WHOIS information refreshed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectBackToDomain($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshAll($params = [])
|
||||||
|
{
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
$messages[] = $this->performWhoisRefresh($id, $domain) ?? 'WHOIS failed';
|
||||||
|
if (!empty($domain['dns_monitoring_enabled'])) {
|
||||||
|
$messages[] = $this->performDnsRefresh($id, $domain);
|
||||||
|
} else {
|
||||||
|
$messages[] = 'DNS skipped (monitoring disabled)';
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Domain refreshed: ' . implode(', ', $messages);
|
||||||
|
$this->redirectBackToDomain($id);
|
||||||
|
}
|
||||||
|
|
||||||
public function delete($params = [])
|
public function delete($params = [])
|
||||||
{
|
{
|
||||||
$id = $params['id'] ?? 0;
|
$id = $params['id'] ?? 0;
|
||||||
@@ -842,11 +947,39 @@ class DomainController extends Controller
|
|||||||
$availableTags = $tagModel->getAllWithUsage();
|
$availableTags = $tagModel->getAllWithUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->view('domains/view', [
|
// Get DNS records for the DNS tab
|
||||||
|
$dnsModel = new \App\Models\DnsRecord();
|
||||||
|
$dnsRecords = $dnsModel->getByDomainGrouped($id);
|
||||||
|
$dnsRecordCount = $dnsModel->countByDomain($id);
|
||||||
|
$dnsHasCloudflare = $dnsModel->hasCloudflare($id);
|
||||||
|
|
||||||
|
// Extract cached IP details (PTR, ASN, geo) from stored raw_data
|
||||||
|
$dnsIpDetails = [];
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (!empty($dnsRecords[$type])) {
|
||||||
|
foreach ($dnsRecords[$type] as $r) {
|
||||||
|
if (!empty($r['raw_data']) && !empty($r['value'])) {
|
||||||
|
$raw = json_decode($r['raw_data'], true);
|
||||||
|
if (!empty($raw['_ip_info'])) {
|
||||||
|
$dnsIpDetails[$r['value']] = $raw['_ip_info'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
|
||||||
|
$templateName = $viewTemplate === 'detailed' ? 'domains/view-detailed' : 'domains/view';
|
||||||
|
|
||||||
|
$this->view($templateName, [
|
||||||
'domain' => $formattedDomain,
|
'domain' => $formattedDomain,
|
||||||
'whoisData' => $whoisData,
|
'whoisData' => $whoisData,
|
||||||
'logs' => $logs,
|
'logs' => $logs,
|
||||||
'availableTags' => $availableTags,
|
'availableTags' => $availableTags,
|
||||||
|
'dnsRecords' => $dnsRecords,
|
||||||
|
'dnsRecordCount' => $dnsRecordCount,
|
||||||
|
'dnsHasCloudflare' => $dnsHasCloudflare,
|
||||||
|
'dnsIpDetails' => $dnsIpDetails,
|
||||||
'title' => $domain['domain_name']
|
'title' => $domain['domain_name']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -1279,9 +1412,14 @@ class DomainController extends Controller
|
|||||||
|
|
||||||
// Validate notes length
|
// Validate notes length
|
||||||
$lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes');
|
$lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes');
|
||||||
|
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
|
||||||
|
$redirect = '/domains/' . $id . ($viewTemplate === 'detailed' ? '#overview' : '');
|
||||||
|
|
||||||
if ($lengthError) {
|
if ($lengthError) {
|
||||||
$_SESSION['error'] = $lengthError;
|
$_SESSION['error'] = $lengthError;
|
||||||
$this->redirect('/domains/' . $id);
|
$this->redirect($redirect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,7 +1428,7 @@ class DomainController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$_SESSION['success'] = 'Notes updated successfully';
|
$_SESSION['success'] = 'Notes updated successfully';
|
||||||
$this->redirect('/domains/' . $id);
|
$this->redirect($redirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function bulkAddTags()
|
public function bulkAddTags()
|
||||||
@@ -1615,6 +1753,32 @@ class DomainController extends Controller
|
|||||||
$this->redirect('/domains');
|
$this->redirect('/domains');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DNS MONITORING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
public function refreshDns($params = [])
|
||||||
|
{
|
||||||
|
$id = (int)($params['id'] ?? 0);
|
||||||
|
$domain = $this->checkDomainAccess($id);
|
||||||
|
|
||||||
|
if (!$domain) {
|
||||||
|
$_SESSION['error'] = 'Domain not found';
|
||||||
|
$this->redirect('/domains');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->performDnsRefresh($id, $domain);
|
||||||
|
|
||||||
|
if (strpos($result, 'no records') !== false) {
|
||||||
|
$_SESSION['warning'] = 'No DNS records found for this domain';
|
||||||
|
} else {
|
||||||
|
$_SESSION['success'] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirectBackToDomain($id, '#dns');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tags for specific domains (API endpoint)
|
* Get tags for specific domains (API endpoint)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class InstallerController extends Controller
|
|||||||
'024_add_status_notifications_v1.1.2.sql',
|
'024_add_status_notifications_v1.1.2.sql',
|
||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
|
'027_add_dns_monitoring.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +201,7 @@ class InstallerController extends Controller
|
|||||||
'024_add_status_notifications_v1.1.2.sql',
|
'024_add_status_notifications_v1.1.2.sql',
|
||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
|
'027_add_dns_monitoring.sql',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +425,7 @@ class InstallerController extends Controller
|
|||||||
'024_add_status_notifications_v1.1.2.sql',
|
'024_add_status_notifications_v1.1.2.sql',
|
||||||
'025_add_update_system_v1.1.3.sql',
|
'025_add_update_system_v1.1.3.sql',
|
||||||
'026_update_app_version_v1.1.4.sql',
|
'026_update_app_version_v1.1.4.sql',
|
||||||
|
'027_add_dns_monitoring.sql',
|
||||||
];
|
];
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
|
|||||||
@@ -136,6 +136,21 @@ class SettingsController extends Controller
|
|||||||
// Rollback availability
|
// Rollback availability
|
||||||
$rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']);
|
$rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']);
|
||||||
|
|
||||||
|
// Cron staleness: show warning if last run is overdue
|
||||||
|
$intervalHours = (int)($settings['check_interval_hours'] ?? 24);
|
||||||
|
$domainStaleThreshold = $intervalHours * 1.5; // e.g. 36h for 24h interval
|
||||||
|
$dnsStaleThreshold = 24; // DNS cron runs every 6h, 24h = overdue
|
||||||
|
$domainCronStale = false;
|
||||||
|
$dnsCronStale = false;
|
||||||
|
if (!empty($settings['last_check_run'])) {
|
||||||
|
$hoursSince = (time() - strtotime($settings['last_check_run'])) / 3600;
|
||||||
|
$domainCronStale = $hoursSince > $domainStaleThreshold;
|
||||||
|
}
|
||||||
|
if (!empty($settings['last_dns_check_run'])) {
|
||||||
|
$hoursSince = (time() - strtotime($settings['last_dns_check_run'])) / 3600;
|
||||||
|
$dnsCronStale = $hoursSince > $dnsStaleThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
$this->view('settings/index', [
|
$this->view('settings/index', [
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'appSettings' => $appSettings,
|
'appSettings' => $appSettings,
|
||||||
@@ -154,6 +169,8 @@ class SettingsController extends Controller
|
|||||||
'cachedUpdateAvailable' => $cachedUpdateAvailable,
|
'cachedUpdateAvailable' => $cachedUpdateAvailable,
|
||||||
'cachedUpdateData' => $cachedUpdateData,
|
'cachedUpdateData' => $cachedUpdateData,
|
||||||
'rollbackAvailable' => $rollbackAvailable,
|
'rollbackAvailable' => $rollbackAvailable,
|
||||||
|
'domainCronStale' => $domainCronStale,
|
||||||
|
'dnsCronStale' => $dnsCronStale,
|
||||||
'title' => 'Settings'
|
'title' => 'Settings'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -320,6 +337,13 @@ class SettingsController extends Controller
|
|||||||
$this->settingModel->setValue('registration_enabled', $registrationEnabled);
|
$this->settingModel->setValue('registration_enabled', $registrationEnabled);
|
||||||
$this->settingModel->setValue('require_email_verification', $requireEmailVerification);
|
$this->settingModel->setValue('require_email_verification', $requireEmailVerification);
|
||||||
|
|
||||||
|
// Update domain view template
|
||||||
|
$viewTemplate = trim($_POST['domain_view_template'] ?? 'detailed');
|
||||||
|
if (!in_array($viewTemplate, ['legacy', 'detailed'])) {
|
||||||
|
$viewTemplate = 'detailed';
|
||||||
|
}
|
||||||
|
$this->settingModel->setValue('domain_view_template', $viewTemplate);
|
||||||
|
|
||||||
$_SESSION['success'] = 'Application settings updated successfully';
|
$_SESSION['success'] = 'Application settings updated successfully';
|
||||||
$this->redirect('/settings#app');
|
$this->redirect('/settings#app');
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ class TwoFactorController extends Controller
|
|||||||
|
|
||||||
$this->view('2fa/verify', [
|
$this->view('2fa/verify', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
|
'canSendEmailCode' => !empty($user['email_verified']),
|
||||||
'title' => 'Two-Factor Authentication'
|
'title' => 'Two-Factor Authentication'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,7 +511,10 @@ class EmailHelper
|
|||||||
public static function getEmailSubject(array $data): string
|
public static function getEmailSubject(array $data): string
|
||||||
{
|
{
|
||||||
if (isset($data['domain'])) {
|
if (isset($data['domain'])) {
|
||||||
$daysLeft = $data['days_left'];
|
$daysLeft = $data['days_left'] ?? null;
|
||||||
|
if ($daysLeft === null) {
|
||||||
|
return "⚠️ Domain Expiration Alert: {$data['domain']}";
|
||||||
|
}
|
||||||
if ($daysLeft <= 0) {
|
if ($daysLeft <= 0) {
|
||||||
return "🚨 URGENT: Domain {$data['domain']} has EXPIRED";
|
return "🚨 URGENT: Domain {$data['domain']} has EXPIRED";
|
||||||
}
|
}
|
||||||
|
|||||||
216
app/Models/DnsRecord.php
Normal file
216
app/Models/DnsRecord.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Core\Model;
|
||||||
|
|
||||||
|
class DnsRecord extends Model
|
||||||
|
{
|
||||||
|
protected static string $table = 'dns_records';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all DNS records for a domain, grouped by type
|
||||||
|
*/
|
||||||
|
public function getByDomainGrouped(int $domainId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC, priority ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$type = $row['record_type'];
|
||||||
|
if (!isset($grouped[$type])) {
|
||||||
|
$grouped[$type] = [];
|
||||||
|
}
|
||||||
|
$grouped[$type][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all DNS records for a domain (flat list)
|
||||||
|
*/
|
||||||
|
public function getByDomain(int $domainId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count DNS records for a domain
|
||||||
|
*/
|
||||||
|
public function countByDomain(int $domainId): int
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare("SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND record_type != 'SOA'");
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
return (int)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get distinct non-root host labels for a domain.
|
||||||
|
* Used to preserve previously discovered subdomains across refreshes.
|
||||||
|
*/
|
||||||
|
public function getDistinctHosts(int $domainId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT DISTINCT host FROM dns_records WHERE domain_id = ? AND host != '@'"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
return array_column($stmt->fetchAll(), 'host');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain has any Cloudflare-proxied records
|
||||||
|
*/
|
||||||
|
public function hasCloudflare(int $domainId): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND is_cloudflare = 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
return (int)$stmt->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a snapshot of DNS records for a domain.
|
||||||
|
* Updates existing records, inserts new ones, removes stale ones.
|
||||||
|
* @return array{added: int, updated: int, removed: int}
|
||||||
|
*/
|
||||||
|
public function saveSnapshot(int $domainId, array $groupedRecords): array
|
||||||
|
{
|
||||||
|
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$seenIds = [];
|
||||||
|
|
||||||
|
foreach ($groupedRecords as $type => $records) {
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$host = $record['host'] ?? '@';
|
||||||
|
$value = $record['value'] ?? '';
|
||||||
|
$ttl = $record['ttl'] ?? null;
|
||||||
|
$priority = $record['priority'] ?? null;
|
||||||
|
$isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0;
|
||||||
|
$rawData = isset($record['raw']) ? json_encode($record['raw']) : null;
|
||||||
|
|
||||||
|
$existing = $this->findExisting($domainId, $type, $host, $value, $priority);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$this->db->prepare(
|
||||||
|
"UPDATE dns_records SET ttl = ?, is_cloudflare = ?, raw_data = ?, last_seen_at = ?, updated_at = ? WHERE id = ?"
|
||||||
|
)->execute([$ttl, $isCloudflare, $rawData, $now, $now, $existing['id']]);
|
||||||
|
$seenIds[] = $existing['id'];
|
||||||
|
$stats['updated']++;
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
||||||
|
$seenIds[] = (int)$this->db->lastInsertId();
|
||||||
|
$stats['added']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove records that no longer exist
|
||||||
|
if (!empty($seenIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
||||||
|
$deleteStmt = $this->db->prepare(
|
||||||
|
"DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})"
|
||||||
|
);
|
||||||
|
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
||||||
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
|
} else {
|
||||||
|
// No records found at all — remove everything
|
||||||
|
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
||||||
|
$deleteStmt->execute([$domainId]);
|
||||||
|
$stats['removed'] = $deleteStmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing record by its natural key
|
||||||
|
*/
|
||||||
|
private function findExisting(int $domainId, string $type, string $host, string $value, ?int $priority): ?array
|
||||||
|
{
|
||||||
|
if ($priority !== null) {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value, $priority]);
|
||||||
|
} else {
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority IS NULL LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId, $type, $host, $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all DNS records for a domain
|
||||||
|
*/
|
||||||
|
public function deleteByDomain(int $domainId): bool
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
||||||
|
return $stmt->execute([$domainId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get record counts grouped by type for a domain
|
||||||
|
*/
|
||||||
|
public function getCountsByType(int $domainId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->db->prepare(
|
||||||
|
"SELECT record_type, COUNT(*) as count FROM dns_records WHERE domain_id = ? GROUP BY record_type ORDER BY record_type"
|
||||||
|
);
|
||||||
|
$stmt->execute([$domainId]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$counts[$row['record_type']] = (int)$row['count'];
|
||||||
|
}
|
||||||
|
return $counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get previous snapshot as grouped records (for diff comparison).
|
||||||
|
* Reconstructs the same format that DnsService::lookup() returns.
|
||||||
|
*/
|
||||||
|
public function getPreviousSnapshot(int $domainId): array
|
||||||
|
{
|
||||||
|
$records = $this->getByDomainGrouped($domainId);
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($records as $type => $rows) {
|
||||||
|
$grouped[$type] = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$entry = [
|
||||||
|
'host' => $row['host'],
|
||||||
|
'value' => $row['value'],
|
||||||
|
'ttl' => $row['ttl'] ? (int)$row['ttl'] : null,
|
||||||
|
];
|
||||||
|
if ($row['priority'] !== null) {
|
||||||
|
$entry['priority'] = (int)$row['priority'];
|
||||||
|
}
|
||||||
|
if ($row['is_cloudflare']) {
|
||||||
|
$entry['is_cloudflare'] = true;
|
||||||
|
}
|
||||||
|
$grouped[$type][] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,24 @@ class Domain extends Model
|
|||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective status for sorting (active + daysLeft within threshold = expiring_soon)
|
||||||
|
*/
|
||||||
|
private static function getEffectiveStatusForSort(array $domain, int $expiringThreshold): string
|
||||||
|
{
|
||||||
|
$status = $domain['status'] ?? '';
|
||||||
|
if ($status === 'inactive') {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
if ($status === 'active' && !empty($domain['expiration_date'])) {
|
||||||
|
$daysLeft = (int) floor((strtotime($domain['expiration_date']) - time()) / 86400);
|
||||||
|
if ($daysLeft <= $expiringThreshold && $daysLeft >= 0) {
|
||||||
|
return 'expiring_soon';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $status ?: 'error';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filtered, sorted, and paginated domains
|
* Get filtered, sorted, and paginated domains
|
||||||
*/
|
*/
|
||||||
@@ -332,10 +350,33 @@ class Domain extends Model
|
|||||||
$totalDomains = count($domains);
|
$totalDomains = count($domains);
|
||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
|
usort($domains, function($a, $b) use ($sortBy, $sortOrder, $expiringThreshold) {
|
||||||
$aVal = $a[$sortBy] ?? '';
|
$aVal = $a[$sortBy] ?? '';
|
||||||
$bVal = $b[$sortBy] ?? '';
|
$bVal = $b[$sortBy] ?? '';
|
||||||
|
|
||||||
|
// When sorting by status: use effective status (active + daysLeft<=threshold = expiring_soon) and logical priority order
|
||||||
|
if ($sortBy === 'status') {
|
||||||
|
$aVal = self::getEffectiveStatusForSort($a, $expiringThreshold);
|
||||||
|
$bVal = self::getEffectiveStatusForSort($b, $expiringThreshold);
|
||||||
|
$priority = [
|
||||||
|
'expired' => 1,
|
||||||
|
'redemption_period' => 2,
|
||||||
|
'pending_delete' => 3,
|
||||||
|
'expiring_soon' => 4,
|
||||||
|
'active' => 5,
|
||||||
|
'available' => 6,
|
||||||
|
'error' => 7,
|
||||||
|
'inactive' => 8,
|
||||||
|
];
|
||||||
|
$aOrder = $priority[$aVal] ?? 99;
|
||||||
|
$bOrder = $priority[$bVal] ?? 99;
|
||||||
|
$comparison = $aOrder <=> $bOrder;
|
||||||
|
if ($comparison === 0) {
|
||||||
|
$comparison = strcasecmp($a['domain_name'] ?? '', $b['domain_name'] ?? '');
|
||||||
|
}
|
||||||
|
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||||
|
}
|
||||||
|
|
||||||
$comparison = strcasecmp($aVal, $bVal);
|
$comparison = strcasecmp($aVal, $bVal);
|
||||||
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,13 +114,14 @@ class TldRegistry extends Model
|
|||||||
*/
|
*/
|
||||||
public function search(string $search): array
|
public function search(string $search): array
|
||||||
{
|
{
|
||||||
$search = '%' . $search . '%';
|
$tldNorm = strtolower(trim(trim($search), '.'));
|
||||||
|
$suffix = '%.' . $tldNorm;
|
||||||
$sql = "SELECT * FROM tld_registry
|
$sql = "SELECT * FROM tld_registry
|
||||||
WHERE (LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))
|
WHERE (LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)
|
||||||
ORDER BY tld ASC";
|
ORDER BY tld ASC";
|
||||||
|
|
||||||
$stmt = $this->db->prepare($sql);
|
$stmt = $this->db->prepare($sql);
|
||||||
$stmt->execute([$search, $search, $search]);
|
$stmt->execute([$tldNorm, $suffix]);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +145,12 @@ class TldRegistry extends Model
|
|||||||
$whereConditions = [];
|
$whereConditions = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
// Search filter
|
// Search filter: exact match OR TLDs ending with .{search} (e.g. "za" -> .za, .co.za, .net.za)
|
||||||
if (!empty($search)) {
|
if (!empty($search)) {
|
||||||
$searchParam = '%' . $search . '%';
|
$tldNorm = strtolower(trim(trim($search), '.'));
|
||||||
$whereConditions[] = "(LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))";
|
$suffix = '%.' . $tldNorm;
|
||||||
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
|
$whereConditions[] = "(LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)";
|
||||||
|
$params = array_merge($params, [$tldNorm, $suffix]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
|
|||||||
@@ -73,12 +73,12 @@ class DiscordChannel implements NotificationChannelInterface
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Days Left',
|
'name' => 'Days Left',
|
||||||
'value' => $data['days_left'],
|
'value' => (string) ($data['days_left'] ?? 'N/A'),
|
||||||
'inline' => true
|
'inline' => true
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Expiration Date',
|
'name' => 'Expiration Date',
|
||||||
'value' => $data['expiration_date'],
|
'value' => $data['expiration_date'] ?? 'N/A',
|
||||||
'inline' => true
|
'inline' => true
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|||||||
766
app/Services/DnsService.php
Normal file
766
app/Services/DnsService.php
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
class DnsService
|
||||||
|
{
|
||||||
|
private Logger $logger;
|
||||||
|
|
||||||
|
// https://www.cloudflare.com/ips-v4/ and /ips-v6/
|
||||||
|
private const CLOUDFLARE_IPV4_RANGES = [
|
||||||
|
'173.245.48.0/20',
|
||||||
|
'103.21.244.0/22',
|
||||||
|
'103.22.200.0/22',
|
||||||
|
'103.31.4.0/22',
|
||||||
|
'141.101.64.0/18',
|
||||||
|
'108.162.192.0/18',
|
||||||
|
'190.93.240.0/20',
|
||||||
|
'188.114.96.0/20',
|
||||||
|
'197.234.240.0/22',
|
||||||
|
'198.41.128.0/17',
|
||||||
|
'162.158.0.0/15',
|
||||||
|
'104.16.0.0/13',
|
||||||
|
'104.24.0.0/14',
|
||||||
|
'172.64.0.0/13',
|
||||||
|
'131.0.72.0/22',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const CLOUDFLARE_IPV6_RANGES = [
|
||||||
|
'2400:cb00::/32',
|
||||||
|
'2606:4700::/32',
|
||||||
|
'2803:f800::/32',
|
||||||
|
'2405:b500::/32',
|
||||||
|
'2405:8100::/32',
|
||||||
|
'2a06:98c0::/29',
|
||||||
|
'2c0f:f248::/32',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const SUBDOMAIN_WORDLIST = [
|
||||||
|
'www', 'mail', 'ftp', 'smtp', 'pop', 'pop3', 'imap', 'webmail', 'email',
|
||||||
|
'ns1', 'ns2', 'ns3', 'ns4', 'dns', 'dns1', 'dns2',
|
||||||
|
'mx', 'mx1', 'mx2', 'relay', 'gateway', 'mailgw',
|
||||||
|
'vpn', 'vpn1', 'vpn2', 'remote', 'access', 'proxy', 'fw', 'firewall',
|
||||||
|
'api', 'api2', 'app', 'app1', 'app2', 'dev', 'dev2',
|
||||||
|
'stage', 'staging', 'test', 'beta', 'demo', 'sandbox',
|
||||||
|
'admin', 'panel', 'portal', 'dashboard', 'cms', 'cpanel', 'whm', 'plesk',
|
||||||
|
'db', 'db1', 'db2', 'mysql', 'postgres', 'redis',
|
||||||
|
'cdn', 'cdn1', 'cdn2', 'static', 'assets', 'media', 'img', 'images', 'files',
|
||||||
|
'shop', 'store', 'pay', 'billing',
|
||||||
|
'blog', 'forum', 'wiki', 'docs',
|
||||||
|
'help', 'support', 'kb',
|
||||||
|
'git', 'gitlab', 'ci', 'jenkins',
|
||||||
|
'monitor', 'status', 'grafana',
|
||||||
|
'sso', 'auth', 'login', 'id', 'oauth',
|
||||||
|
'm', 'mobile',
|
||||||
|
'intranet', 'internal', 'corp',
|
||||||
|
'backup', 'old', 'legacy',
|
||||||
|
'cloud', 'autodiscover', 'autoconfig', 'lyncdiscover', 'sip',
|
||||||
|
'server', 'server1', 'server2', 'host', 'node1', 'node2',
|
||||||
|
'web', 'web1', 'web2', 'www1', 'www2',
|
||||||
|
'mail1', 'mail2', 'mail3', 'smtp1', 'smtp2', 'mta',
|
||||||
|
'lb', 'haproxy', 'nginx', 'cache',
|
||||||
|
'owa', 'exchange', 'outlook',
|
||||||
|
'ns', 'mx0',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const SPECIAL_TXT_SUBDOMAINS = [
|
||||||
|
'_dmarc',
|
||||||
|
'_mta-sts',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ROOT_RECORD_TYPES = [
|
||||||
|
DNS_A => 'A',
|
||||||
|
DNS_AAAA => 'AAAA',
|
||||||
|
DNS_MX => 'MX',
|
||||||
|
DNS_TXT => 'TXT',
|
||||||
|
DNS_NS => 'NS',
|
||||||
|
DNS_CNAME => 'CNAME',
|
||||||
|
DNS_SOA => 'SOA',
|
||||||
|
DNS_SRV => 'SRV',
|
||||||
|
DNS_CAA => 'CAA',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->logger = new Logger('dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MAIN LOOKUP
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive DNS lookup for a domain.
|
||||||
|
* Scans root + common subdomains + targets extracted from NS/MX/CNAME.
|
||||||
|
* Resolves NS/MX targets to A/AAAA IPs.
|
||||||
|
*
|
||||||
|
* @param string $domain The domain to scan
|
||||||
|
* @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans)
|
||||||
|
*/
|
||||||
|
public function lookup(string $domain, array $extraSubdomains = []): array
|
||||||
|
{
|
||||||
|
$this->logger->info("DNS lookup started", ['domain' => $domain]);
|
||||||
|
|
||||||
|
$records = [
|
||||||
|
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
|
||||||
|
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
|
||||||
|
];
|
||||||
|
$seen = []; // "TYPE:host:value" dedup keys
|
||||||
|
|
||||||
|
// Phase 1: Root domain — query each type individually
|
||||||
|
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
|
||||||
|
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1b: DNS_ALL fallback to catch anything we missed
|
||||||
|
$this->queryAllFallback($domain, $domain, $records, $seen);
|
||||||
|
|
||||||
|
// Phase 1c: gethostbynamel fallback for A records
|
||||||
|
if (empty($records['A'])) {
|
||||||
|
$ips = @gethostbynamel($domain);
|
||||||
|
if (is_array($ips)) {
|
||||||
|
foreach ($ips as $ip) {
|
||||||
|
$this->addIfNew('A', [
|
||||||
|
'host' => '@', 'value' => $ip, 'ttl' => 0,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIp($ip),
|
||||||
|
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
|
||||||
|
], $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV
|
||||||
|
$candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains);
|
||||||
|
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
|
||||||
|
foreach ($records[$type] as $rec) {
|
||||||
|
$target = rtrim($rec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$sub = str_replace('.' . $domain, '', strtolower($target));
|
||||||
|
if ($sub && !in_array($sub, $candidates)) {
|
||||||
|
$candidates[] = $sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$candidates = array_unique($candidates);
|
||||||
|
|
||||||
|
// Phase 3: Probe subdomains — fast checkdnsrr existence test first
|
||||||
|
$discovered = [];
|
||||||
|
foreach ($candidates as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
if ($this->subdomainExists($fqdn)) {
|
||||||
|
$discovered[] = $sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Deep scan discovered subdomains (A, AAAA, CNAME, TXT)
|
||||||
|
foreach ($discovered as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
|
||||||
|
// TXT only for known useful subdomains
|
||||||
|
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
|
||||||
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4b: Special TXT subdomains (always query even if not "discovered")
|
||||||
|
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
|
||||||
|
$fqdn = "{$sub}.{$domain}";
|
||||||
|
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: Resolve MX targets that are under this domain — add their A/AAAA records
|
||||||
|
foreach ($records['MX'] as $mxRec) {
|
||||||
|
$target = rtrim($mxRec['value'] ?? '', '.');
|
||||||
|
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
|
||||||
|
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
|
||||||
|
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6: Resolve NS server IPs — store in raw data for display
|
||||||
|
foreach ($records['NS'] as &$nsRec) {
|
||||||
|
$nsHost = rtrim($nsRec['value'] ?? '', '.');
|
||||||
|
if ($nsHost) {
|
||||||
|
$nsIps = $this->resolveHostIps($nsHost);
|
||||||
|
$nsRec['raw']['_ns_ips'] = $nsIps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($nsRec);
|
||||||
|
|
||||||
|
// Sort A/AAAA: root first, then alphabetical
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
usort($records[$type], function ($a, $b) {
|
||||||
|
if ($a['host'] === '@') return -1;
|
||||||
|
if ($b['host'] === '@') return 1;
|
||||||
|
return strcmp($a['host'], $b['host']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalRecords = array_sum(array_map('count', $records));
|
||||||
|
$this->logger->info("DNS lookup completed", [
|
||||||
|
'domain' => $domain,
|
||||||
|
'total_records' => $totalRecords,
|
||||||
|
'subdomains_discovered' => count($discovered),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// LOOKUP HELPERS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query a FQDN for a specific record type and collect deduplicated results.
|
||||||
|
*/
|
||||||
|
private function queryAndCollect(
|
||||||
|
string $fqdn, int $dnsConst, string $typeName,
|
||||||
|
string $baseDomain, array &$records, array &$seen
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$raw = @dns_get_record($fqdn, $dnsConst);
|
||||||
|
if ($raw === false || empty($raw)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($raw as $entry) {
|
||||||
|
$parsed = $this->parseRecord($typeName, $entry, $baseDomain);
|
||||||
|
if ($parsed) {
|
||||||
|
$this->addIfNew($typeName, $parsed, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-existent subdomain or network issue — not worth logging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS_ALL fallback to catch records missed by individual queries.
|
||||||
|
*/
|
||||||
|
private function queryAllFallback(string $fqdn, string $baseDomain, array &$records, array &$seen): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$all = @dns_get_record($fqdn, DNS_ALL);
|
||||||
|
if (!is_array($all) || empty($all)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($all as $entry) {
|
||||||
|
$type = strtoupper($entry['type'] ?? '');
|
||||||
|
if ($type && isset($records[$type])) {
|
||||||
|
$parsed = $this->parseRecord($type, $entry, $baseDomain);
|
||||||
|
if ($parsed) {
|
||||||
|
$this->addIfNew($type, $parsed, $records, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a record only if it hasn't been seen before (dedup).
|
||||||
|
*/
|
||||||
|
private function addIfNew(string $type, array $parsed, array &$records, array &$seen): void
|
||||||
|
{
|
||||||
|
$priority = $parsed['priority'] ?? '';
|
||||||
|
$dedupKey = "{$type}:{$parsed['host']}:{$parsed['value']}:{$priority}";
|
||||||
|
if (isset($seen[$dedupKey])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$seen[$dedupKey] = true;
|
||||||
|
$records[$type][] = $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast existence check for a subdomain.
|
||||||
|
*/
|
||||||
|
private function subdomainExists(string $fqdn): bool
|
||||||
|
{
|
||||||
|
if (@checkdnsrr($fqdn, 'A')) return true;
|
||||||
|
if (@checkdnsrr($fqdn, 'AAAA')) return true;
|
||||||
|
if (@checkdnsrr($fqdn, 'CNAME')) return true;
|
||||||
|
$ip = @gethostbyname($fqdn);
|
||||||
|
return ($ip !== $fqdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a hostname to its A and AAAA IPs.
|
||||||
|
*/
|
||||||
|
private function resolveHostIps(string $hostname): array
|
||||||
|
{
|
||||||
|
$ips = ['ipv4' => [], 'ipv6' => []];
|
||||||
|
|
||||||
|
$a = @dns_get_record($hostname, DNS_A);
|
||||||
|
if (is_array($a)) {
|
||||||
|
foreach ($a as $r) {
|
||||||
|
if (!empty($r['ip'])) {
|
||||||
|
$ips['ipv4'][] = $r['ip'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$aaaa = @dns_get_record($hostname, DNS_AAAA);
|
||||||
|
if (is_array($aaaa)) {
|
||||||
|
foreach ($aaaa as $r) {
|
||||||
|
if (!empty($r['ipv6'])) {
|
||||||
|
$ips['ipv6'][] = $r['ipv6'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ips;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CERTIFICATE TRANSPARENCY (crt.sh)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover subdomains via crt.sh Certificate Transparency logs.
|
||||||
|
* Returns an array of subdomain labels (e.g. ['www', 'mail', 'api']).
|
||||||
|
* Slow/unreliable — use only in cron, not on manual refresh.
|
||||||
|
*/
|
||||||
|
public function crtshSubdomains(string $domain): array
|
||||||
|
{
|
||||||
|
$url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json';
|
||||||
|
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 30,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'header' => "User-Agent: DomainMonitor/1.0\r\n",
|
||||||
|
],
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => false,
|
||||||
|
'verify_peer_name' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$json = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($json === false) {
|
||||||
|
$this->logger->warning('crt.sh request failed', ['domain' => $domain]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = @json_decode($json, true);
|
||||||
|
if (!is_array($entries)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subdomains = [];
|
||||||
|
$domainLower = strtolower($domain);
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$name = $entry['name_value'] ?? '';
|
||||||
|
foreach (explode("\n", $name) as $n) {
|
||||||
|
$n = strtolower(trim($n));
|
||||||
|
$n = ltrim($n, '*.');
|
||||||
|
if (empty($n)) continue;
|
||||||
|
|
||||||
|
if ($n === $domainLower) continue;
|
||||||
|
|
||||||
|
if (str_ends_with($n, '.' . $domainLower)) {
|
||||||
|
$sub = str_replace('.' . $domainLower, '', $n);
|
||||||
|
if ($sub !== '' && !isset($subdomains[$sub])) {
|
||||||
|
$subdomains[$sub] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = array_keys($subdomains);
|
||||||
|
$this->logger->info('crt.sh discovery completed', [
|
||||||
|
'domain' => $domain,
|
||||||
|
'subdomains_found' => count($result),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RECORD PARSING
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw dns_get_record entry into a normalized record.
|
||||||
|
*/
|
||||||
|
private function parseRecord(string $type, array $entry, string $domain): ?array
|
||||||
|
{
|
||||||
|
$host = $entry['host'] ?? $domain;
|
||||||
|
$hostLower = strtolower($host);
|
||||||
|
$domainLower = strtolower($domain);
|
||||||
|
|
||||||
|
// Skip records that resolved to external domains (e.g. CNAME target chains)
|
||||||
|
if ($hostLower !== $domainLower && !str_ends_with($hostLower, '.' . $domainLower)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostLabel = ($hostLower === $domainLower)
|
||||||
|
? '@'
|
||||||
|
: str_ireplace('.' . $domain, '', $host);
|
||||||
|
$ttl = $entry['ttl'] ?? null;
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'A':
|
||||||
|
$ip = $entry['ip'] ?? '';
|
||||||
|
if (empty($ip)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $ip,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIp($ip),
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'AAAA':
|
||||||
|
$ip = $entry['ipv6'] ?? '';
|
||||||
|
if (empty($ip)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $ip,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'is_cloudflare' => $this->isCloudflareIpv6($ip),
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'MX':
|
||||||
|
$target = $entry['target'] ?? '';
|
||||||
|
if (empty($target)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $target,
|
||||||
|
'priority' => $entry['pri'] ?? 0,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'TXT':
|
||||||
|
$txt = $entry['txt'] ?? '';
|
||||||
|
if (empty($txt)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $txt,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'txt_type' => $this->classifyTxtRecord($txt),
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'NS':
|
||||||
|
$target = $entry['target'] ?? '';
|
||||||
|
if (empty($target)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $target,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'CNAME':
|
||||||
|
$target = $entry['target'] ?? '';
|
||||||
|
if (empty($target)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $target,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'SOA':
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $entry['mname'] ?? '',
|
||||||
|
'rname' => $entry['rname'] ?? '',
|
||||||
|
'serial' => $entry['serial'] ?? 0,
|
||||||
|
'refresh' => $entry['refresh'] ?? 0,
|
||||||
|
'retry' => $entry['retry'] ?? 0,
|
||||||
|
'expire' => $entry['expire'] ?? 0,
|
||||||
|
'minimum' => $entry['minimum-ttl'] ?? 0,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'SRV':
|
||||||
|
$target = $entry['target'] ?? '';
|
||||||
|
if (empty($target)) return null;
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $target,
|
||||||
|
'priority' => $entry['pri'] ?? 0,
|
||||||
|
'weight' => $entry['weight'] ?? 0,
|
||||||
|
'port' => $entry['port'] ?? 0,
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'CAA':
|
||||||
|
$value = ($entry['flags'] ?? 0) . ' ' . ($entry['tag'] ?? '') . ' "' . ($entry['value'] ?? '') . '"';
|
||||||
|
return [
|
||||||
|
'host' => $hostLabel,
|
||||||
|
'value' => $value,
|
||||||
|
'flags' => $entry['flags'] ?? 0,
|
||||||
|
'tag' => $entry['tag'] ?? '',
|
||||||
|
'ca' => $entry['value'] ?? '',
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'raw' => $entry,
|
||||||
|
];
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a TXT record's purpose.
|
||||||
|
*/
|
||||||
|
private function classifyTxtRecord(string $value): string
|
||||||
|
{
|
||||||
|
$lower = strtolower($value);
|
||||||
|
if (str_starts_with($lower, 'v=spf1')) return 'SPF';
|
||||||
|
if (str_starts_with($lower, 'v=dkim1')) return 'DKIM';
|
||||||
|
if (str_starts_with($lower, 'v=dmarc1')) return 'DMARC';
|
||||||
|
if (str_contains($lower, 'google-site-verification')) return 'Google Verification';
|
||||||
|
if (str_contains($lower, 'ms=')) return 'Microsoft Verification';
|
||||||
|
if (str_contains($lower, 'facebook-domain-verification')) return 'Facebook Verification';
|
||||||
|
if (str_contains($lower, 'apple-domain-verification')) return 'Apple Verification';
|
||||||
|
if (str_contains($lower, 'amazonses:')) return 'Amazon SES';
|
||||||
|
if (str_contains($lower, 'docusign')) return 'DocuSign';
|
||||||
|
if (str_contains($lower, 'atlassian-domain-verification')) return 'Atlassian Verification';
|
||||||
|
if (str_contains($lower, '_mta-sts')) return 'MTA-STS';
|
||||||
|
return 'TXT';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CLOUDFLARE DETECTION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
public function isCloudflareIp(string $ip): bool
|
||||||
|
{
|
||||||
|
if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
if ($ipLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::CLOUDFLARE_IPV4_RANGES as $cidr) {
|
||||||
|
[$subnet, $mask] = explode('/', $cidr);
|
||||||
|
$subnetLong = ip2long($subnet);
|
||||||
|
$maskLong = ~((1 << (32 - (int)$mask)) - 1);
|
||||||
|
|
||||||
|
if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCloudflareIpv6(string $ip): bool
|
||||||
|
{
|
||||||
|
if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipBin = inet_pton($ip);
|
||||||
|
if ($ipBin === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::CLOUDFLARE_IPV6_RANGES as $cidr) {
|
||||||
|
[$subnet, $prefixLen] = explode('/', $cidr);
|
||||||
|
$subnetBin = inet_pton($subnet);
|
||||||
|
if ($subnetBin === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefixLen = (int)$prefixLen;
|
||||||
|
$fullBytes = intdiv($prefixLen, 8);
|
||||||
|
$remainingBits = $prefixLen % 8;
|
||||||
|
|
||||||
|
$match = true;
|
||||||
|
for ($i = 0; $i < $fullBytes; $i++) {
|
||||||
|
if ($ipBin[$i] !== $subnetBin[$i]) {
|
||||||
|
$match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($match && $remainingBits > 0 && $fullBytes < 16) {
|
||||||
|
$bitmask = 0xFF << (8 - $remainingBits) & 0xFF;
|
||||||
|
if ((ord($ipBin[$fullBytes]) & $bitmask) !== (ord($subnetBin[$fullBytes]) & $bitmask)) {
|
||||||
|
$match = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($match) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// IP DETAILS (PTR, ASN, GEO)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-lookup IP details (ASN, PTR, org, country) for a list of IPs.
|
||||||
|
* PTR via gethostbyaddr(); ASN/geo via ip-api.com batch.
|
||||||
|
*/
|
||||||
|
public function lookupIpDetails(array $ips): array
|
||||||
|
{
|
||||||
|
$unique = array_values(array_unique(array_filter($ips)));
|
||||||
|
if (empty($unique)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($unique as $ip) {
|
||||||
|
$ptr = @gethostbyaddr($ip);
|
||||||
|
$result[$ip] = [
|
||||||
|
'reverse' => ($ptr !== false && $ptr !== $ip) ? $ptr : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestBody = [];
|
||||||
|
foreach ($unique as $ip) {
|
||||||
|
$requestBody[] = [
|
||||||
|
'query' => $ip,
|
||||||
|
'fields' => 'status,query,as,asname,isp,org,country,countryCode,regionName,city,hosting',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\nUser-Agent: DomainMonitor/1.0",
|
||||||
|
'content' => json_encode($requestBody),
|
||||||
|
'timeout' => 5,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents('http://ip-api.com/batch', false, $context);
|
||||||
|
if ($response !== false) {
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (is_array($data)) {
|
||||||
|
foreach ($data as $item) {
|
||||||
|
if (($item['status'] ?? '') === 'success' && isset($item['query'])) {
|
||||||
|
$result[$item['query']] = array_merge($result[$item['query']] ?? [], $item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->logger->warning('ip-api.com batch request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// DIFF / NOTIFICATIONS
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two sets of DNS records and return changes.
|
||||||
|
*/
|
||||||
|
public function diffRecords(array $oldRecords, array $newRecords): array
|
||||||
|
{
|
||||||
|
$changes = ['added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
|
||||||
|
$oldFlat = $this->flattenRecords($oldRecords);
|
||||||
|
$newFlat = $this->flattenRecords($newRecords);
|
||||||
|
|
||||||
|
foreach ($newFlat as $key => $record) {
|
||||||
|
if (!isset($oldFlat[$key])) {
|
||||||
|
$changes['added'][] = $record;
|
||||||
|
} elseif ($oldFlat[$key]['value'] !== $record['value']) {
|
||||||
|
$changes['changed'][] = [
|
||||||
|
'record' => $record,
|
||||||
|
'old_value' => $oldFlat[$key]['value'],
|
||||||
|
'new_value' => $record['value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($oldFlat as $key => $record) {
|
||||||
|
if (!isset($newFlat[$key])) {
|
||||||
|
$changes['removed'][] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function flattenRecords(array $grouped): array
|
||||||
|
{
|
||||||
|
$flat = [];
|
||||||
|
foreach ($grouped as $type => $records) {
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$host = $record['host'] ?? '@';
|
||||||
|
$value = $record['value'] ?? '';
|
||||||
|
$priority = $record['priority'] ?? '';
|
||||||
|
$key = "{$type}:{$host}:{$value}:{$priority}";
|
||||||
|
$flat[$key] = array_merge($record, ['record_type' => $type]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatChangesSummary(array $changes, string $domain): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (!empty($changes['added'])) {
|
||||||
|
$parts[] = count($changes['added']) . " new record(s) added";
|
||||||
|
}
|
||||||
|
if (!empty($changes['removed'])) {
|
||||||
|
$parts[] = count($changes['removed']) . " record(s) removed";
|
||||||
|
}
|
||||||
|
if (!empty($changes['changed'])) {
|
||||||
|
$parts[] = count($changes['changed']) . " record(s) changed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty($parts) ? '' : "DNS changes detected for {$domain}: " . implode(', ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatChangesDetail(array $changes, string $domain): string
|
||||||
|
{
|
||||||
|
$lines = ["🔄 DNS Changes Detected: {$domain}\n"];
|
||||||
|
|
||||||
|
if (!empty($changes['added'])) {
|
||||||
|
$lines[] = "➕ New Records:";
|
||||||
|
foreach ($changes['added'] as $r) {
|
||||||
|
$type = $r['record_type'] ?? 'UNKNOWN';
|
||||||
|
$lines[] = " {$type} {$r['host']} → {$r['value']}";
|
||||||
|
}
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changes['removed'])) {
|
||||||
|
$lines[] = "➖ Removed Records:";
|
||||||
|
foreach ($changes['removed'] as $r) {
|
||||||
|
$type = $r['record_type'] ?? 'UNKNOWN';
|
||||||
|
$lines[] = " {$type} {$r['host']} → {$r['value']}";
|
||||||
|
}
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changes['changed'])) {
|
||||||
|
$lines[] = "✏️ Changed Records:";
|
||||||
|
foreach ($changes['changed'] as $c) {
|
||||||
|
$type = $c['record']['record_type'] ?? 'UNKNOWN';
|
||||||
|
$lines[] = " {$type} {$c['record']['host']}: {$c['old_value']} → {$c['new_value']}";
|
||||||
|
}
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -665,6 +665,54 @@ class NotificationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// DNS MONITORING NOTIFICATIONS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a DNS change notification (in-app / bell icon)
|
||||||
|
*/
|
||||||
|
public function notifyDnsChange(int $userId, string $domainName, int $domainId, string $summary): void
|
||||||
|
{
|
||||||
|
$notificationModel = new \App\Models\Notification();
|
||||||
|
$notificationModel->createNotification(
|
||||||
|
$userId,
|
||||||
|
'dns_change',
|
||||||
|
'DNS Records Changed',
|
||||||
|
"{$domainName} - {$summary}",
|
||||||
|
$domainId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send DNS change alert to external channels
|
||||||
|
*/
|
||||||
|
public function sendDnsChangeAlert(array $domain, array $notificationChannels, string $detailMessage): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($notificationChannels as $channel) {
|
||||||
|
$config = json_decode($channel['channel_config'], true);
|
||||||
|
$success = $this->send(
|
||||||
|
$channel['channel_type'],
|
||||||
|
$config,
|
||||||
|
$detailMessage,
|
||||||
|
[
|
||||||
|
'subject' => "DNS Changes: {$domain['domain_name']}",
|
||||||
|
'domain' => $domain['domain_name'],
|
||||||
|
'domain_id' => $domain['id'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'channel' => $channel['channel_type'],
|
||||||
|
'success' => $success,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete old read notifications (cleanup)
|
* Delete old read notifications (cleanup)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -848,7 +848,7 @@ class WhoisService
|
|||||||
|
|
||||||
// Check if domain is not found/available
|
// Check if domain is not found/available
|
||||||
$whoisDataLower = strtolower($whoisData);
|
$whoisDataLower = strtolower($whoisData);
|
||||||
// More specific patterns to avoid false positives
|
// Exact line match (original patterns)
|
||||||
if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
|
if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
|
||||||
preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
|
preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
|
||||||
preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) {
|
preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) {
|
||||||
@@ -856,6 +856,12 @@ class WhoisService
|
|||||||
$data['registrar'] = 'Not Registered';
|
$data['registrar'] = 'Not Registered';
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
// Broader patterns for formats that don't match exact line (e.g. .rs "%ERROR:103: Domain is not registered", .io "Domain not found.", .co "The queried object does not exist: DOMAIN NOT FOUND")
|
||||||
|
if (preg_match('/domain\s+is\s+not\s+registered|domain\s+not\s+found\.?(\s|$)|queried\s+object\s+does\s+not\s+exist|%error:\d+:\s*domain/i', $whoisDataLower)) {
|
||||||
|
$data['status'][] = 'AVAILABLE';
|
||||||
|
$data['registrar'] = 'Not Registered';
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
// Special handling for .eu domains that are available
|
// Special handling for .eu domains that are available
|
||||||
// EURid returns "Status: AVAILABLE" in a specific format
|
// EURid returns "Status: AVAILABLE" in a specific format
|
||||||
@@ -1196,7 +1202,10 @@ class WhoisService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No expiration date and no clear status indicators
|
// No expiration date and no clear status indicators
|
||||||
// This should only happen for newly added domains or error cases
|
// Fallback: registrar "Not Registered" means domain is available (e.g. parseWhoisData set it but status was lost in merge)
|
||||||
|
if (!empty($whoisData['registrar']) && $whoisData['registrar'] === 'Not Registered') {
|
||||||
|
return 'available';
|
||||||
|
}
|
||||||
// Return error to avoid incorrectly marking registered domains as available
|
// Return error to avoid incorrectly marking registered domains as available
|
||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,15 +140,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Monitoring -->
|
<!-- Active Monitoring -->
|
||||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700">
|
<div id="dns-monitoring" class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700 space-y-4">
|
||||||
<label class="flex items-center cursor-pointer">
|
<label class="flex items-start cursor-pointer">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="is_active"
|
name="is_active"
|
||||||
{{ domain.is_active ? 'checked' : '' }}
|
{{ domain.is_active ? 'checked' : '' }}
|
||||||
class="w-4 h-4 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable Active Monitoring</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable Active Monitoring</span>
|
||||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">When enabled, this domain will be checked regularly and notifications will be sent</p>
|
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, this domain will be checked regularly and notifications will be sent</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="dns_monitoring_enabled"
|
||||||
|
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
|
||||||
|
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||||
|
<div class="ml-3">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable DNS Monitoring</span>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, DNS records will be checked for changes and you'll receive alerts</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +187,7 @@
|
|||||||
<i class="fas fa-eye text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
|
<i class="fas fa-eye text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">View Details</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">View Details</span>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="m-0">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="m-0">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-green-300 dark:hover:border-green-700 hover:bg-green-50 dark:hover:bg-green-500/10 transition-colors group">
|
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-green-300 dark:hover:border-green-700 hover:bg-green-50 dark:hover:bg-green-500/10 transition-colors group">
|
||||||
|
|||||||
@@ -369,7 +369,7 @@
|
|||||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
|||||||
279
app/Views/domains/tabs/billing.twig
Normal file
279
app/Views/domains/tabs/billing.twig
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<!-- BILLING TAB CONTENT -->
|
||||||
|
|
||||||
|
<!-- Preview Banner -->
|
||||||
|
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
|
||||||
|
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">Financial tracking is coming soon. This is a design preview with sample data and might change in the future.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Purchase Source Info -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-shopping-cart text-blue-600 dark:text-blue-400 mr-2" style="font-size: 14px;"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-blue-900 dark:text-blue-300">Purchased From</p>
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400 mt-0.5">Sedo Marketplace - $1,200.00 on Jan 15, 2020</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="https://sedo.com/account" target="_blank" class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
|
Go to Seller
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial Summary Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Purchase Price</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">$1,200.00</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Jan 15, 2020</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 bg-blue-50 dark:bg-blue-500/10 rounded flex items-center justify-center">
|
||||||
|
<i class="fas fa-shopping-cart text-blue-600 dark:text-blue-400" style="font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total Renewals</p>
|
||||||
|
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">$450.00</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">3 payments</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 bg-orange-50 dark:bg-orange-500/10 rounded flex items-center justify-center">
|
||||||
|
<i class="fas fa-redo text-orange-600 dark:text-orange-400" style="font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total Invested</p>
|
||||||
|
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 mt-0.5">$1,700.00</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">All expenses</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 bg-indigo-50 dark:bg-indigo-500/10 rounded flex items-center justify-center">
|
||||||
|
<i class="fas fa-wallet text-indigo-600 dark:text-indigo-400" style="font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Profit/Loss</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-400 dark:text-slate-500 mt-0.5">-</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Not sold</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 bg-gray-50 dark:bg-slate-700 rounded flex items-center justify-center">
|
||||||
|
<i class="fas fa-chart-line text-gray-600 dark:text-slate-400" style="font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Renewal Alert -->
|
||||||
|
<div class="bg-orange-50 dark:bg-orange-500/10 border border-orange-200 dark:border-orange-800 rounded-lg p-3 mb-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-alt text-orange-600 dark:text-orange-400 mr-2" style="font-size: 14px;"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-orange-900 dark:text-orange-300">Next Renewal Due</p>
|
||||||
|
<p class="text-xs text-orange-700 dark:text-orange-400 mt-0.5">Jan 15, 2026 <span class="font-semibold">(65 days)</span> - Estimated: $150.00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="inline-flex items-center px-3 py-1.5 bg-orange-600 text-white text-xs rounded hover:bg-orange-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1" style="font-size: 9px;"></i>
|
||||||
|
Add Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium"><i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Transaction</button>
|
||||||
|
<button class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium"><i class="fas fa-hand-holding-usd mr-1.5" style="font-size: 10px;"></i>Record Sale</button>
|
||||||
|
<button class="inline-flex items-center px-3 py-2 bg-indigo-600 text-white text-xs rounded-lg hover:bg-indigo-700 transition-colors font-medium"><i class="fas fa-file-invoice-dollar mr-1.5" style="font-size: 10px;"></i>Export Report</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction History (static sample) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Transaction History
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">5</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Date</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Type</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Amount</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Company/Seller</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Invoice</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Payment Method</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-900 dark:text-white whitespace-nowrap">Jan 15, 2020</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap"><span class="inline-flex items-center px-2 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded"><i class="fas fa-shopping-cart mr-1" style="font-size: 9px;"></i>Purchase</span></td>
|
||||||
|
<td class="px-4 py-3 text-xs font-semibold text-gray-900 dark:text-white whitespace-nowrap">$1,200.00</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-900 dark:text-white whitespace-nowrap">Sedo Marketplace</td>
|
||||||
|
<td class="px-4 py-3 text-xs font-mono text-blue-600 dark:text-blue-400 whitespace-nowrap">SEDO-2020-001234</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-600 dark:text-slate-400 whitespace-nowrap">Credit Card</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap"><span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded"><i class="fas fa-check-circle mr-1" style="font-size: 8px;"></i>Paid</span></td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-600 dark:text-slate-400">Initial domain purchase from Sedo marketplace</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial Charts -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
||||||
|
<!-- Expense Breakdown (Donut Chart) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-chart-pie text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Expense Breakdown
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<canvas id="expenseChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expense Timeline (Line Chart) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-chart-line text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Expense Timeline
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<canvas id="timelineChart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart.js CDN -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Expense Breakdown Donut Chart
|
||||||
|
const expenseCtx = document.getElementById('expenseChart')?.getContext('2d');
|
||||||
|
if (expenseCtx) {
|
||||||
|
new Chart(expenseCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Initial Purchase', 'Renewals', 'Transfers', 'Other'],
|
||||||
|
datasets: [{
|
||||||
|
data: [1200, 450, 50, 0],
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(59, 130, 246, 0.8)',
|
||||||
|
'rgba(251, 146, 60, 0.8)',
|
||||||
|
'rgba(168, 85, 247, 0.8)',
|
||||||
|
'rgba(156, 163, 175, 0.8)'
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
'rgb(59, 130, 246)',
|
||||||
|
'rgb(251, 146, 60)',
|
||||||
|
'rgb(168, 85, 247)',
|
||||||
|
'rgb(156, 163, 175)'
|
||||||
|
],
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
padding: 15,
|
||||||
|
font: { size: 11 },
|
||||||
|
generateLabels: function(chart) {
|
||||||
|
const data = chart.data;
|
||||||
|
if (data.labels.length && data.datasets.length) {
|
||||||
|
return data.labels.map((label, i) => {
|
||||||
|
const value = data.datasets[0].data[i];
|
||||||
|
return { text: `${label}: $${value.toFixed(2)}`, fillStyle: data.datasets[0].backgroundColor[i], hidden: false, index: i };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const label = context.label || '';
|
||||||
|
const value = context.parsed || 0;
|
||||||
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
|
const percentage = ((value / total) * 100).toFixed(1);
|
||||||
|
return `${label}: $${value.toFixed(2)} (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense Timeline Chart
|
||||||
|
const timelineCtx = document.getElementById('timelineChart')?.getContext('2d');
|
||||||
|
if (timelineCtx) {
|
||||||
|
new Chart(timelineCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: ['2020', '2021', '2022', '2023', '2024'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cumulative Investment',
|
||||||
|
data: [1200, 1350, 1500, 1650, 1700],
|
||||||
|
borderColor: 'rgb(99, 102, 241)',
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: 'rgb(99, 102, 241)',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointBorderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `Total Invested: $${context.parsed.y.toFixed(2)}`; } } } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { callback: function(value) { return '$' + value; }, font: { size: 10 } },
|
||||||
|
grid: { color: 'rgba(0, 0, 0, 0.05)' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: { font: { size: 10 } },
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
507
app/Views/domains/tabs/dns.twig
Normal file
507
app/Views/domains/tabs/dns.twig
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
{# DNS TAB CONTENT #}
|
||||||
|
|
||||||
|
{% set totalDnsRecords = dnsRecordCount|default(0) %}
|
||||||
|
{% set dnsMonitoringEnabled = domain.dns_monitoring_enabled|default(1) %}
|
||||||
|
|
||||||
|
{% if not dnsMonitoringEnabled %}
|
||||||
|
<!-- DNS Monitoring Disabled - show only message, no records -->
|
||||||
|
<div class="mb-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">DNS monitoring is disabled</h3>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the DNS cron. Enable it in Edit to track DNS changes.</p>
|
||||||
|
<a href="/domains/{{ domain.id }}/edit#dns-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
|
||||||
|
<i class="fas fa-edit mr-1"></i>Enable DNS monitoring in Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if totalDnsRecords == 0 %}
|
||||||
|
<!-- No DNS Data Yet -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
|
||||||
|
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Click "Refresh DNS" to fetch the current DNS records for this domain.</p>
|
||||||
|
{% if domain %}
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
<span class="btn-label">Refresh DNS</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||||
|
<i class="far fa-clock mr-1"></i>
|
||||||
|
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
|
||||||
|
</p>
|
||||||
|
{% if dnsHasCloudflare|default(false) %}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded border border-orange-200 dark:border-orange-800">
|
||||||
|
<i class="fas fa-cloud mr-1" style="font-size: 10px;"></i>
|
||||||
|
Cloudflare Detected
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if domain and dnsMonitoringEnabled %}
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
<span class="btn-label">Refresh DNS</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DNS Records by Type -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
|
||||||
|
{# ===== SOA Record (Start of Authority) — shown first ===== #}
|
||||||
|
{% if dnsRecords['SOA'] is defined and dnsRecords['SOA']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-gray-100 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-info-circle text-gray-500 dark:text-slate-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
SOA Record (Start of Authority)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{% for record in dnsRecords['SOA'] %}
|
||||||
|
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Primary NS</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ record.value }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Admin</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.rname|default('N/A') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Serial</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.serial|default('N/A') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TTL</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ record.ttl }}s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 pt-3 border-t border-gray-100 dark:border-slate-700">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Refresh</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.refresh|default('N/A') }}s</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Retry</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.retry|default('N/A') }}s</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Expire</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.expire|default('N/A') }}s</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Min TTL</label>
|
||||||
|
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData['minimum-ttl']|default('N/A') }}s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== A Records ===== #}
|
||||||
|
{% if dnsRecords['A'] is defined and dnsRecords['A']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-circle text-blue-600 dark:text-blue-400 mr-2" style="font-size: 8px;"></i>
|
||||||
|
A Records (IPv4)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-blue-600 text-white text-xs font-semibold rounded">{{ dnsRecords['A']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['A'] %}
|
||||||
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
|
{% if record.host == '@' %}
|
||||||
|
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
|
||||||
|
{% else %}
|
||||||
|
{{ record.host }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs">
|
||||||
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
|
{% if record.is_cloudflare %}
|
||||||
|
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1.5" style="font-size: 10px;" title="Cloudflare"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-w-[200px] truncate" title="{{ ipInfo.reverse|default('') }}">
|
||||||
|
{{ ipInfo.reverse|default('-') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
{% if ipInfo and ipInfo.as %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if ipInfo.countryCode %}
|
||||||
|
<span class="fi fi-{{ ipInfo.countryCode|lower }}" style="font-size: 16px;"></span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white">{{ ipInfo.as|split(' ')|first }}</div>
|
||||||
|
<div class="text-gray-600 dark:text-slate-400">{{ ipInfo.org|default(ipInfo.isp|default('')) }}</div>
|
||||||
|
{% if ipInfo.city or ipInfo.regionName %}
|
||||||
|
<div class="text-gray-500 dark:text-slate-500">{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500 text-xs">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== AAAA Records ===== #}
|
||||||
|
{% if dnsRecords['AAAA'] is defined and dnsRecords['AAAA']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-indigo-50 dark:bg-indigo-500/10 border-b border-indigo-200 dark:border-indigo-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-circle text-indigo-600 dark:text-indigo-400 mr-2" style="font-size: 8px;"></i>
|
||||||
|
AAAA Records (IPv6)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-indigo-600 text-white text-xs font-semibold rounded">{{ dnsRecords['AAAA']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['AAAA'] %}
|
||||||
|
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
|
||||||
|
{% if record.host == '@' %}
|
||||||
|
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
|
||||||
|
{% else %}
|
||||||
|
{{ record.host }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs">
|
||||||
|
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
|
||||||
|
{% if record.is_cloudflare %}
|
||||||
|
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1.5" style="font-size: 10px;" title="Cloudflare"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-w-[200px] truncate" title="{{ ipInfo.reverse|default('') }}">
|
||||||
|
{{ ipInfo.reverse|default('-') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
{% if ipInfo and ipInfo.as %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if ipInfo.countryCode %}
|
||||||
|
<span class="fi fi-{{ ipInfo.countryCode|lower }}" style="font-size: 16px;"></span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs">
|
||||||
|
<div class="font-semibold text-gray-900 dark:text-white">{{ ipInfo.as|split(' ')|first }}</div>
|
||||||
|
<div class="text-gray-600 dark:text-slate-400">{{ ipInfo.org|default(ipInfo.isp|default('')) }}</div>
|
||||||
|
{% if ipInfo.city or ipInfo.regionName %}
|
||||||
|
<div class="text-gray-500 dark:text-slate-500">{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500 text-xs">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== CNAME Records ===== #}
|
||||||
|
{% if dnsRecords['CNAME'] is defined and dnsRecords['CNAME']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-amber-200 dark:border-amber-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-link text-amber-600 dark:text-amber-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
CNAME Records (Aliases)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-amber-600 text-white text-xs font-semibold rounded">{{ dnsRecords['CNAME']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['CNAME'] %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== MX Records ===== #}
|
||||||
|
{% if dnsRecords['MX'] is defined and dnsRecords['MX']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-envelope text-green-600 dark:text-green-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
MX Records (Mail Exchange)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-green-600 text-white text-xs font-semibold rounded">{{ dnsRecords['MX']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['MX'] %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== TXT Records ===== #}
|
||||||
|
{% if dnsRecords['TXT'] is defined and dnsRecords['TXT']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-purple-50 dark:bg-purple-500/10 border-b border-purple-200 dark:border-purple-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt text-purple-600 dark:text-purple-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
TXT Records
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
{% for record in dnsRecords['TXT'] %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
|
||||||
|
<div class="flex items-start">
|
||||||
|
{% set val = record.value|lower %}
|
||||||
|
{% if val starts with 'v=spf1' %}
|
||||||
|
{% set txtType = 'SPF' %}
|
||||||
|
{% elseif val starts with 'v=dkim1' %}
|
||||||
|
{% set txtType = 'DKIM' %}
|
||||||
|
{% elseif val starts with 'v=dmarc1' %}
|
||||||
|
{% set txtType = 'DMARC' %}
|
||||||
|
{% elseif 'google-site-verification' in val %}
|
||||||
|
{% set txtType = 'Google' %}
|
||||||
|
{% elseif 'ms=' in val %}
|
||||||
|
{% set txtType = 'Microsoft' %}
|
||||||
|
{% elseif 'facebook-domain-verification' in val %}
|
||||||
|
{% set txtType = 'Facebook' %}
|
||||||
|
{% else %}
|
||||||
|
{% set txtType = 'TXT' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded mr-2 flex-shrink-0">{{ txtType }}</span>
|
||||||
|
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== NS Records ===== #}
|
||||||
|
{% if dnsRecords['NS'] is defined and dnsRecords['NS']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-teal-50 dark:bg-teal-500/10 border-b border-teal-200 dark:border-teal-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-server text-teal-600 dark:text-teal-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
NS Records (Name Servers)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-teal-600 text-white text-xs font-semibold rounded">{{ dnsRecords['NS']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['NS'] %}
|
||||||
|
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
|
||||||
|
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||||
|
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
|
||||||
|
{{ nsIps.ipv4|join(', ') }}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||||
|
{% if nsIps and nsIps.ipv6|default([])|length > 0 %}
|
||||||
|
{{ nsIps.ipv6|join(', ') }}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== SRV Records ===== #}
|
||||||
|
{% if dnsRecords['SRV'] is defined and dnsRecords['SRV']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-violet-50 dark:bg-violet-500/10 border-b border-violet-200 dark:border-violet-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-project-diagram text-violet-600 dark:text-violet-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
SRV Records (Services)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-violet-600 text-white text-xs font-semibold rounded">{{ dnsRecords['SRV']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['SRV'] %}
|
||||||
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ===== CAA Records ===== #}
|
||||||
|
{% if dnsRecords['CAA'] is defined and dnsRecords['CAA']|length > 0 %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-orange-50 dark:bg-orange-500/10 border-b border-orange-200 dark:border-orange-800">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-certificate text-orange-600 dark:text-orange-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
CAA Records (Certificate Authority)
|
||||||
|
<span class="ml-2 px-1.5 py-0.5 bg-orange-600 text-white text-xs font-semibold rounded">{{ dnsRecords['CAA']|length }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Tag</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
{% for record in dnsRecords['CAA'] %}
|
||||||
|
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleDnsRefresh(form) {
|
||||||
|
var btn = form.querySelector('.dns-refresh-btn');
|
||||||
|
if (btn.disabled) return false;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
|
||||||
|
btn.classList.add('bg-gray-400', 'cursor-not-allowed');
|
||||||
|
var icon = btn.querySelector('i');
|
||||||
|
var label = btn.querySelector('.btn-label');
|
||||||
|
if (icon) icon.classList.add('fa-spin');
|
||||||
|
if (label) label.textContent = 'Scanning DNS...';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
131
app/Views/domains/tabs/notification.twig
Normal file
131
app/Views/domains/tabs/notification.twig
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Notification History
|
||||||
|
<span id="notification-count" class="ml-1.5 text-gray-600 dark:text-slate-400">({{ logs|length }})</span>
|
||||||
|
</h3>
|
||||||
|
{% if logs is not empty %}
|
||||||
|
<div class="flex flex-wrap items-center gap-2" id="notification-filters">
|
||||||
|
<select id="filter-channel" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="">All channels</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="slack">Slack</option>
|
||||||
|
<option value="mattermost">Mattermost</option>
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="pushover">Pushover</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-status" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="sent">Sent</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-type" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="expiration">Expiration</option>
|
||||||
|
<option value="status">Status change</option>
|
||||||
|
<option value="dns">DNS change</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="filter-search" placeholder="Search message..." class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 w-32 focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
|
<button type="button" id="filter-reset" class="text-xs text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 px-2 py-1" title="Reset filters">Clear</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
{% if logs is empty %}
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400">No notifications sent yet</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="notification-table-wrap" class="max-h-96 overflow-y-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700 text-xs">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-900 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Channel</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Date</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700" id="notification-log-tbody">
|
||||||
|
{% for log in logs %}
|
||||||
|
{% set nt = log.notification_type|default('') %}
|
||||||
|
{% set logType = (nt == 'expired' or (nt|slice(0, 13)) == 'expiring_in_') ? 'expiration' : (((nt|slice(0, 7)) == 'domain_') ? 'status' : ((nt == 'dns_change') ? 'dns' : 'other')) %}
|
||||||
|
<tr class="notification-log-row hover:bg-gray-50 dark:hover:bg-slate-700" data-channel="{{ log.channel_type }}" data-status="{{ log.status }}" data-type="{{ logType }}" data-message="{{ log.message|e('html_attr') }}">
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400">
|
||||||
|
{{ log.channel_type|capitalize }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap">
|
||||||
|
{% set logStatusClass = log.status == 'sent' ? 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400' : 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400' %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium {{ logStatusClass }}">
|
||||||
|
{{ log.status|capitalize }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap text-gray-600 dark:text-slate-400">{{ log.sent_at|date('M j, H:i') }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-700 dark:text-slate-300 max-w-xs truncate" title="{{ log.message }}">
|
||||||
|
{{ log.message }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="notification-empty-filter" class="hidden p-8 text-center">
|
||||||
|
<i class="fas fa-filter text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400">No notifications match the current filters</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if logs is not empty %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var rows = document.querySelectorAll('.notification-log-row');
|
||||||
|
var countEl = document.getElementById('notification-count');
|
||||||
|
var emptyFilterEl = document.getElementById('notification-empty-filter');
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
var channel = document.getElementById('filter-channel').value;
|
||||||
|
var status = document.getElementById('filter-status').value;
|
||||||
|
var type = document.getElementById('filter-type').value;
|
||||||
|
var search = (document.getElementById('filter-search').value || '').toLowerCase();
|
||||||
|
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
var match = true;
|
||||||
|
if (channel && row.dataset.channel !== channel) match = false;
|
||||||
|
if (status && row.dataset.status !== status) match = false;
|
||||||
|
if (type && row.dataset.type !== type) match = false;
|
||||||
|
if (search && row.dataset.message.toLowerCase().indexOf(search) === -1) match = false;
|
||||||
|
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
if (match) visible++;
|
||||||
|
});
|
||||||
|
|
||||||
|
countEl.textContent = '(' + visible + (visible !== rows.length ? ' of ' + rows.length + ')' : ')');
|
||||||
|
emptyFilterEl.classList.toggle('hidden', visible > 0);
|
||||||
|
document.getElementById('notification-table-wrap').classList.toggle('hidden', visible === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
document.getElementById('filter-channel').value = '';
|
||||||
|
document.getElementById('filter-status').value = '';
|
||||||
|
document.getElementById('filter-type').value = '';
|
||||||
|
document.getElementById('filter-search').value = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.notification-filter').forEach(function(el) {
|
||||||
|
el.addEventListener('change', applyFilters);
|
||||||
|
el.addEventListener('input', function() { if (el.id === 'filter-search') applyFilters(); });
|
||||||
|
});
|
||||||
|
document.getElementById('filter-reset').addEventListener('click', resetFilters);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
284
app/Views/domains/tabs/overview.twig
Normal file
284
app/Views/domains/tabs/overview.twig
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<!-- OVERVIEW TAB CONTENT -->
|
||||||
|
|
||||||
|
<!-- Main 2-Column Layout -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<!-- LEFT COLUMN -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Domain Info Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-info-circle text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Domain Information
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2 text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Registrar:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ domain.registrar ?? 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
{% if domain.registrar_url is not empty %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Registrar URL:</span>
|
||||||
|
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||||
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
|
Visit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Expires:</span>
|
||||||
|
{% set expiryColor = domain.expiryColor|default('gray') %}
|
||||||
|
<span class="text-{{ expiryColor }}-600 dark:text-{{ expiryColor }}-400 font-semibold">
|
||||||
|
{% if domain.expiration_date is defined and domain.expiration_date %}
|
||||||
|
{{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %}
|
||||||
|
{% if domain.isManualExpiration %}
|
||||||
|
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400">
|
||||||
|
<i class="fas fa-edit mr-0.5" style="font-size: 8px;"></i>Manual
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Created:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white">{% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Last Updated:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white">{% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Last Checked:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white">{% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial Summary (Mockup) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-dollar-sign text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Financial Summary
|
||||||
|
</h3>
|
||||||
|
<button onclick="switchTab('billing')" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||||
|
Details
|
||||||
|
<i class="fas fa-arrow-right ml-1" style="font-size: 8px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2 text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Purchase Price:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">$12.99</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Renewal Cost:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">$14.99 / yr</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Total Spent:</span>
|
||||||
|
<span class="text-gray-900 dark:text-white font-semibold">$42.97</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-100 dark:border-slate-700 pt-2 mt-2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Next Renewal:</span>
|
||||||
|
{% if domain.expiration_date is defined and domain.expiration_date %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-{{ (domain.expiryColor ?? 'gray') }}-100 dark:bg-{{ (domain.expiryColor ?? 'gray') }}-500/10 text-{{ (domain.expiryColor ?? 'gray') }}-800 dark:text-{{ (domain.expiryColor ?? 'gray') }}-400 text-xs font-semibold rounded">
|
||||||
|
{{ domain.expiration_date|date('M d, Y') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 px-2 py-1.5 bg-amber-50 dark:bg-amber-500/10 rounded border border-amber-200 dark:border-amber-800">
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-400 flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-1.5" style="font-size: 9px;"></i>
|
||||||
|
Sample data — billing features coming soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes (Inline Editable) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-sticky-note text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Notes
|
||||||
|
</h3>
|
||||||
|
<button id="notes-edit-btn" onclick="toggleNotesEdit(true)" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||||
|
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div id="notes-view-mode">
|
||||||
|
{% if domain.notes is not empty %}
|
||||||
|
<div class="text-xs text-gray-900 dark:text-white whitespace-pre-wrap font-mono bg-gray-50 dark:bg-slate-900 rounded p-2 border border-gray-200 dark:border-slate-700 max-h-40 overflow-y-auto">{{ domain.notes }}</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 italic">No notes yet. <button onclick="toggleNotesEdit(true)" class="text-blue-600 dark:text-blue-400 hover:underline">Add notes</button></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- Edit Mode -->
|
||||||
|
<div id="notes-edit-mode" class="hidden">
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/update-notes" id="overview-notes-form">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
id="overview-notes-textarea"
|
||||||
|
rows="6"
|
||||||
|
class="w-full px-3 py-2 text-xs border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||||
|
placeholder="Add notes about this domain...">{{ domain.notes|default('') }}</textarea>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex-1 inline-flex items-center justify-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-save mr-1.5"></i>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="toggleNotesEdit(false)"
|
||||||
|
class="flex-1 inline-flex items-center justify-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-times mr-1.5"></i>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Monitoring Status -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-heartbeat text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Monitoring Status
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-file-alt text-blue-500 dark:text-blue-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-slate-300">WHOIS</span>
|
||||||
|
</div>
|
||||||
|
{% if domain.is_active %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-lock text-indigo-500 dark:text-indigo-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-slate-300">SSL</span>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-minus-circle mr-1" style="font-size: 9px;"></i>Coming Soon
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-network-wired text-blue-500 dark:text-blue-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-slate-300">DNS</span>
|
||||||
|
</div>
|
||||||
|
{% if domain.dns_monitoring_enabled|default(1) %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-bell text-orange-500 dark:text-orange-400 mr-2" style="font-size: 10px;"></i>
|
||||||
|
<span class="text-xs text-gray-700 dark:text-slate-300">Notification Group</span>
|
||||||
|
</div>
|
||||||
|
{% if domain.group_name is not empty %}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-bell mr-1" style="font-size: 9px;"></i>{{ domain.group_name }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center px-2 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded hover:bg-orange-200 dark:hover:bg-orange-500/20 transition-colors">
|
||||||
|
<i class="fas fa-plus-circle mr-1" style="font-size: 9px;"></i>Assign Group
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Channels -->
|
||||||
|
{% if domain.group_name is not empty %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Active Channels
|
||||||
|
<span class="ml-auto text-xs font-medium text-gray-500 dark:text-slate-400 normal-case tracking-normal">{{ domain.group_name }}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{% if domain.channels is not empty %}
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{% for channel in domain.channels %}
|
||||||
|
<div class="flex items-center p-2 rounded {{ channel.is_active ? 'bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-800' : 'bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-700' }}">
|
||||||
|
<i class="fas fa-{{ channel.is_active ? 'check-circle text-green-600 dark:text-green-400' : 'times-circle text-gray-400 dark:text-slate-500' }} mr-2 text-xs"></i>
|
||||||
|
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">{{ channel.channel_type|capitalize }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">{{ domain.activeChannelCount|default(0) }} of {{ domain.channels|length }} channels active</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 italic">No channels configured for this group</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Domain Status Codes -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Domain Status Codes
|
||||||
|
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ domain.parsedStatuses|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
|
||||||
|
{% for status in domain.parsedStatuses %}
|
||||||
|
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 rounded text-xs font-medium" title="{{ status }}">{{ status|replace({'_':' '})|title }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 rounded text-xs font-medium">No status codes</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
379
app/Views/domains/tabs/ssl.twig
Normal file
379
app/Views/domains/tabs/ssl.twig
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<!-- SSL TAB CONTENT -->
|
||||||
|
|
||||||
|
<!-- Preview Banner -->
|
||||||
|
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
|
||||||
|
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">SSL certificate monitoring is coming soon. This is a design preview with sample data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters & Actions Bar -->
|
||||||
|
<div class="mb-3 flex flex-wrap gap-3 justify-between items-center">
|
||||||
|
<div class="flex-1 max-w-md">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="ssl-search" placeholder="Search certificates..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||||
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
|
||||||
|
<option value="all">All Certificates</option>
|
||||||
|
<option value="valid">Valid Only</option>
|
||||||
|
<option value="expiring">Expiring Soon</option>
|
||||||
|
<option value="expired">Expired</option>
|
||||||
|
<option value="invalid">Invalid</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="checkAllCertificates()" class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Check All
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
|
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Add Subdomain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions Toolbar (Hidden by default) -->
|
||||||
|
<div id="ssl-bulk-actions" class="hidden mb-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
|
||||||
|
<button type="button" onclick="bulkCheckSSL()" class="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5" style="font-size: 9px;"></i>
|
||||||
|
Check Selected
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="bulkDeleteSSL()" class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
|
||||||
|
Delete Selected
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||||
|
<i class="fas fa-times mr-1.5" style="font-size: 9px;"></i>
|
||||||
|
Clear Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSL Statistics -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">3</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-slate-500" style="font-size: 18px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
|
||||||
|
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-0.5">2</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-check-circle text-green-500 dark:text-green-400" style="font-size: 18px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring Soon</p>
|
||||||
|
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">1</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-exclamation-triangle text-orange-500 dark:text-orange-400" style="font-size: 18px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
|
||||||
|
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-0.5">1</p>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-times-circle text-red-500 dark:text-red-400" style="font-size: 18px;"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Info -->
|
||||||
|
<div class="mb-3 flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-slate-400">
|
||||||
|
Showing <span class="font-semibold text-gray-900 dark:text-white">1</span> to <span class="font-semibold text-gray-900 dark:text-white">3</span> of <span class="font-semibold text-gray-900 dark:text-white">3</span> certificate(s)
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs text-gray-600 dark:text-slate-400">Show:</label>
|
||||||
|
<select class="px-2 py-1 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded text-xs">
|
||||||
|
<option>10</option>
|
||||||
|
<option selected>25</option>
|
||||||
|
<option>50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSL Certificates List -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Cert 1 (root) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="1" data-status="valid">
|
||||||
|
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">example.com <span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span></h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||||
|
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||||
|
Valid & Trusted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Oct 05, 2025</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jan 08, 2026</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">65 days</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<p class="text-xs text-gray-900 dark:text-white font-medium">Let's Encrypt Authority X3</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||||
|
<div class="mt-1.5 space-y-1">
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">example.com</span></div>
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">www.example.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cert 2 (mail subdomain) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="2" data-status="valid">
|
||||||
|
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="2" onchange="updateSSLBulkActions()">
|
||||||
|
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">mail.example.com</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
|
||||||
|
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
|
||||||
|
Valid & Trusted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Aug 01, 2025</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jul 28, 2026</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">270 days</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<p class="text-xs text-gray-900 dark:text-white font-medium">DigiCert Inc.</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||||
|
<div class="mt-1.5 space-y-1">
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">mail.example.com</span></div>
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">smtp.example.com</span></div>
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">imap.example.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||||
|
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cert 3 (api - expired) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-red-200 dark:border-red-800 overflow-hidden ssl-cert-item" data-cert-id="3" data-status="expired">
|
||||||
|
<div class="px-4 py-2 bg-red-50 dark:bg-red-500/10 border-b border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="3" onchange="updateSSLBulkActions()">
|
||||||
|
<i class="fas fa-lock text-red-600 dark:text-red-400 mr-2" style="font-size: 14px;"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">api.example.com</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded border border-red-200 dark:border-red-800">
|
||||||
|
<i class="fas fa-times-circle mr-1" style="font-size: 9px;"></i>
|
||||||
|
EXPIRED
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Sep 26, 2024</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-red-700 dark:text-red-400">Sep 30, 2025</span></div>
|
||||||
|
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-red-600 dark:text-red-400">30 days (expired)</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<p class="text-xs text-gray-900 dark:text-white font-medium">Self-Signed</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">⚠️ Not Trusted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
|
||||||
|
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error Details</p>
|
||||||
|
<p class="text-xs text-red-700 dark:text-red-400">Certificate has expired</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
|
||||||
|
<div class="mt-1.5 space-y-1">
|
||||||
|
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">api.example.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
|
||||||
|
<div class="mt-1.5 space-y-1.5">
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 11:00</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
|
||||||
|
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-slate-400">Page <span class="font-semibold text-gray-900 dark:text-white">1</span> of <span class="font-semibold text-gray-900 dark:text-white">1</span></div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-left"></i></button>
|
||||||
|
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-left"></i> Previous</button>
|
||||||
|
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">1</span>
|
||||||
|
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed">Next <i class="fas fa-angle-right"></i></button>
|
||||||
|
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-right"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateSSLBulkActions() {
|
||||||
|
const checkboxes = document.querySelectorAll('.ssl-checkbox:checked');
|
||||||
|
const bulkActions = document.getElementById('ssl-bulk-actions');
|
||||||
|
const selectedCount = document.getElementById('ssl-selected-count');
|
||||||
|
if (checkboxes.length > 0) {
|
||||||
|
bulkActions.classList.remove('hidden');
|
||||||
|
selectedCount.textContent = `${checkboxes.length} certificate(s) selected`;
|
||||||
|
} else {
|
||||||
|
bulkActions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearSSLSelection() {
|
||||||
|
document.querySelectorAll('.ssl-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
updateSSLBulkActions();
|
||||||
|
}
|
||||||
|
function getSelectedSSLIds() {
|
||||||
|
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map(cb => cb.value);
|
||||||
|
}
|
||||||
|
function bulkCheckSSL() {
|
||||||
|
const ids = getSelectedSSLIds();
|
||||||
|
console.log('Checking SSL certificates:', ids);
|
||||||
|
}
|
||||||
|
function bulkDeleteSSL() {
|
||||||
|
const ids = getSelectedSSLIds();
|
||||||
|
if (confirm(`Delete ${ids.length} certificate(s)? This action cannot be undone.`)) {
|
||||||
|
console.log('Deleting SSL certificates:', ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function checkAllCertificates() {
|
||||||
|
console.log('Checking all certificates...');
|
||||||
|
}
|
||||||
|
document.getElementById('ssl-search')?.addEventListener('input', function(e) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||||
|
const text = item.textContent.toLowerCase();
|
||||||
|
item.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('ssl-filter')?.addEventListener('change', function(e) {
|
||||||
|
const filter = e.target.value;
|
||||||
|
document.querySelectorAll('.ssl-cert-item').forEach(item => {
|
||||||
|
if (filter === 'all') {
|
||||||
|
item.style.display = '';
|
||||||
|
} else {
|
||||||
|
const status = item.dataset.status;
|
||||||
|
item.style.display = status === filter ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
241
app/Views/domains/tabs/whois.twig
Normal file
241
app/Views/domains/tabs/whois.twig
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!-- WHOIS TAB CONTENT -->
|
||||||
|
|
||||||
|
{% if not domain.is_active %}
|
||||||
|
<!-- Active Monitoring Disabled Banner -->
|
||||||
|
<div class="mb-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">Active monitoring is disabled</h3>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the cron. You will not receive status or expiration alerts.</p>
|
||||||
|
<a href="/domains/{{ domain.id }}/edit#dns-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
|
||||||
|
<i class="fas fa-edit mr-1"></i>Enable active monitoring in Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 3-Column Layout: Registration, Registrant, Dates -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
|
<!-- Registration Information -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Registration
|
||||||
|
</h3>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline" onsubmit="prepareReturnTo(event)">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="return_to" value="">
|
||||||
|
<button type="submit" class="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
|
||||||
|
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
|
||||||
|
Refresh WHOIS
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar</label>
|
||||||
|
<p class="text-gray-900 dark:text-white font-semibold">{{ domain.registrar ?? 'Unknown' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
||||||
|
{% if domain.registrar_url is defined and domain.registrar_url %}
|
||||||
|
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||||
|
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||||
|
Visit Registrar
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">IANA ID</label>
|
||||||
|
<p class="text-gray-900 dark:text-white">{{ whoisData.iana_id ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
||||||
|
{% if domain.abuse_email %}
|
||||||
|
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">WHOIS Server</label>
|
||||||
|
<p class="text-gray-900 dark:text-white font-mono break-all">{{ whoisData.whois_server ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registrant Information -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-user text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Registrant
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Name</label>
|
||||||
|
<p class="text-gray-900 dark:text-white font-medium">{{ whoisData.owner ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Organization</label>
|
||||||
|
<p class="text-gray-900 dark:text-white">{{ whoisData.organization ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Email</label>
|
||||||
|
{% if whoisData.email is defined and whoisData.email %}
|
||||||
|
<a href="mailto:{{ whoisData.email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 break-all">{{ whoisData.email }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-slate-500">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Country</label>
|
||||||
|
<p class="text-gray-900 dark:text-white">{{ whoisData.country ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Privacy Protection</label>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
|
||||||
|
<i class="fas fa-shield-alt mr-1" style="font-size: 9px;"></i>
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Important Dates -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-calendar-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Important Dates
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-800">
|
||||||
|
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
|
||||||
|
<i class="fas fa-plus text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Created</p>
|
||||||
|
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
|
||||||
|
<i class="fas fa-edit text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Updated</p>
|
||||||
|
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center p-2 bg-orange-50 dark:bg-orange-500/10 rounded border border-orange-200 dark:border-orange-800">
|
||||||
|
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
|
||||||
|
<i class="fas fa-calendar-times text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Expires</p>
|
||||||
|
<p class="text-xs font-semibold text-orange-700 dark:text-orange-400">
|
||||||
|
{% if domain.expiration_date is defined and domain.expiration_date %}
|
||||||
|
{{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center p-2 bg-indigo-50 dark:bg-indigo-500/10 rounded border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
|
||||||
|
<i class="fas fa-sync text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Checked</p>
|
||||||
|
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nameservers & Domain Status (2-col) -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
||||||
|
<!-- Nameservers -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Name Servers
|
||||||
|
{% if whoisData.nameservers is defined and whoisData.nameservers is not empty %}
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ whoisData.nameservers|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{% if whoisData.nameservers is defined and whoisData.nameservers is not empty %}
|
||||||
|
{% for ns in whoisData.nameservers %}
|
||||||
|
<div class="flex items-center p-2 bg-gray-50 dark:bg-slate-700 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
<div class="w-5 h-5 bg-primary rounded flex items-center justify-center text-white font-bold text-xs mr-2 flex-shrink-0">{{ loop.index }}</div>
|
||||||
|
<span class="font-mono text-xs text-gray-900 dark:text-slate-200 break-all">{{ ns }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<i class="fas fa-server text-gray-300 dark:text-slate-600 text-lg mb-1"></i>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400">No nameservers</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain Status Codes -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||||
|
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Domain Status Codes
|
||||||
|
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ domain.parsedStatuses|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
|
||||||
|
{% for status in domain.parsedStatuses %}
|
||||||
|
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 rounded text-xs font-medium" title="{{ status }}">{{ status|replace({'_':' '})|title }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 rounded text-xs font-medium">No status codes</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw WHOIS (Collapsible) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mt-3">
|
||||||
|
<button onclick="toggleRawWhois()" class="w-full px-4 py-2 bg-gray-50 dark:bg-slate-900 text-left hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 flex items-center justify-between">
|
||||||
|
<span class="flex items-center uppercase tracking-wider">
|
||||||
|
<i class="fas fa-code text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||||
|
Raw WHOIS Data
|
||||||
|
</span>
|
||||||
|
<i class="fas fa-chevron-down text-gray-400 dark:text-slate-500 transition-transform text-xs" id="raw-whois-icon"></i>
|
||||||
|
</h3>
|
||||||
|
</button>
|
||||||
|
<div id="raw-whois-content" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
|
||||||
|
<pre class="text-xs text-green-400 font-mono">{{ whoisData|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
269
app/Views/domains/view-detailed.twig
Normal file
269
app/Views/domains/view-detailed.twig
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
{% extends "layout/base.twig" %}
|
||||||
|
|
||||||
|
{% set title = 'Domain Details' %}
|
||||||
|
{% set pageTitle = domain.domain_name|default('Domain Details') %}
|
||||||
|
{% set pageDescription = 'Domain information and monitoring status' %}
|
||||||
|
{% set pageIcon = 'fas fa-globe' %}
|
||||||
|
|
||||||
|
{% set daysLeft = domain.daysLeft %}
|
||||||
|
{% set domainStatus = domain.displayStatus %}
|
||||||
|
{% set expiryColor = domain.expiryColor %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Top Action Bar -->
|
||||||
|
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% if domain %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold {{ domain.statusClass }}">
|
||||||
|
<i class="fas {{ domain.statusIcon }} mr-1.5"></i>
|
||||||
|
{{ domain.statusText }}
|
||||||
|
</span>
|
||||||
|
{% if domain.displayStatus != 'available' %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-{{ domain.expiryColor }}-100 dark:bg-{{ domain.expiryColor }}-500/10 text-{{ domain.expiryColor }}-800 dark:text-{{ domain.expiryColor }}-400 border border-{{ domain.expiryColor }}-200 dark:border-{{ domain.expiryColor }}-800">
|
||||||
|
<i class="fas fa-calendar-alt mr-1.5"></i>
|
||||||
|
{{ domain.daysLeft is not null ? domain.daysLeft ~ ' days left' : 'No expiry date' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 dark:bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<i class="fas fa-{{ domain.is_active ? 'check-circle' : 'pause-circle' }} mr-1.5"></i>
|
||||||
|
{{ domain.is_active ? 'Monitoring Active' : 'Monitoring Paused' }}
|
||||||
|
</span>
|
||||||
|
{# Tags Display (match view.twig) #}
|
||||||
|
{% if domain.tags is not empty %}
|
||||||
|
{% set tags = domain.tags|split(',') %}
|
||||||
|
{% set tagColors = domain.tag_colors is not empty ? domain.tag_colors|split('|') : [] %}
|
||||||
|
{% set tagColorMap = {} %}
|
||||||
|
{% for availableTag in availableTags %}
|
||||||
|
{% set tagColorMap = tagColorMap|merge({(availableTag.name): availableTag.color}) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for tag in tags %}
|
||||||
|
{% set tag = tag|trim %}
|
||||||
|
{% set colorClass = tagColorMap[tag] ?? (tagColors[loop.index0] ?? 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border-gray-200 dark:border-slate-700') %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border {{ colorClass }}">
|
||||||
|
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
{{ tag|capitalize }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-800">
|
||||||
|
<i class="fas fa-check-circle mr-1.5"></i>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 border border-orange-200 dark:border-orange-800">
|
||||||
|
<i class="fas fa-calendar-alt mr-1.5"></i>
|
||||||
|
65 days left
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 dark:bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
<i class="fas fa-check-circle mr-1.5"></i>
|
||||||
|
Monitoring Active
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
{% if domain %}
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-all" class="inline" onsubmit="prepareReturnTo(event); return handleRefreshBtn(this);">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<input type="hidden" name="return_to" id="return_to_input">
|
||||||
|
<button type="submit" class="refresh-all-btn inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
|
<span class="btn-label">Refresh All</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-trash mr-1.5"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" disabled>
|
||||||
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
<a href="#" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-edit mr-1.5"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-trash mr-1.5"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/domains" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mb-3">
|
||||||
|
<div class="border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<nav class="-mb-px flex">
|
||||||
|
<button onclick="switchTab('overview')" id="tab-overview" class="tab-button active flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-primary text-primary bg-blue-50 dark:bg-slate-700">
|
||||||
|
<i class="fas fa-chart-line mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('whois')" id="tab-whois" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||||
|
<i class="fas fa-file-alt mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
WHOIS
|
||||||
|
{% if not domain.is_active %}
|
||||||
|
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="Active monitoring disabled"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('ssl')" id="tab-ssl" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||||
|
<i class="fas fa-lock mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
SSL
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">2</span>
|
||||||
|
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">1</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('dns')" id="tab-dns" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||||
|
<i class="fas fa-network-wired mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
DNS
|
||||||
|
{% if not (domain.dns_monitoring_enabled|default(1)) %}
|
||||||
|
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="DNS monitoring disabled"></i>
|
||||||
|
{% else %}
|
||||||
|
{% if dnsRecordCount|default(0) > 0 %}
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ dnsRecordCount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if dnsHasCloudflare|default(false) %}
|
||||||
|
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1" style="font-size: 10px;" title="Behind Cloudflare"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('billing')" id="tab-billing" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||||
|
<i class="fas fa-dollar-sign mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Billing
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('notifications')" id="tab-notifications" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
|
||||||
|
<i class="fas fa-bell mr-1.5" style="font-size: 10px;"></i>
|
||||||
|
Notifications
|
||||||
|
{% if logs is defined and logs|length > 0 %}
|
||||||
|
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ logs|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="tab-content-wrapper">
|
||||||
|
<div id="content-overview" class="tab-content">
|
||||||
|
{% include 'domains/tabs/overview.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-whois" class="tab-content hidden">
|
||||||
|
{% include 'domains/tabs/whois.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-ssl" class="tab-content hidden">
|
||||||
|
{% include 'domains/tabs/ssl.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-dns" class="tab-content hidden">
|
||||||
|
{% include 'domains/tabs/dns.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-billing" class="tab-content hidden">
|
||||||
|
{% include 'domains/tabs/billing.twig' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content-notifications" class="tab-content hidden">
|
||||||
|
{% include 'domains/tabs/notification.twig' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const hash = (window.location.hash || '#overview').replace('#','');
|
||||||
|
switchTab(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTabHash(tabName) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.hash = '#' + tabName;
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('active', 'border-primary', 'text-primary', 'bg-blue-50', 'dark:bg-slate-700');
|
||||||
|
button.classList.add('border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||||
|
});
|
||||||
|
document.getElementById('content-' + tabName).classList.remove('hidden');
|
||||||
|
const activeTab = document.getElementById('tab-' + tabName);
|
||||||
|
activeTab.classList.add('active', 'border-primary', 'text-primary', 'bg-blue-50', 'dark:bg-slate-700');
|
||||||
|
activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||||
|
updateTabHash(tabName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRawWhois() {
|
||||||
|
const content = document.getElementById('raw-whois-content');
|
||||||
|
const icon = document.getElementById('raw-whois-icon');
|
||||||
|
content.classList.toggle('hidden');
|
||||||
|
icon.classList.toggle('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDnsHistory() {
|
||||||
|
const content = document.getElementById('dns-history-content');
|
||||||
|
const icon = document.getElementById('dns-history-icon');
|
||||||
|
content.classList.toggle('hidden');
|
||||||
|
icon.classList.toggle('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotesEdit(editMode) {
|
||||||
|
const viewMode = document.getElementById('notes-view-mode');
|
||||||
|
const editModeEl = document.getElementById('notes-edit-mode');
|
||||||
|
const editBtn = document.getElementById('notes-edit-btn');
|
||||||
|
if (editMode) {
|
||||||
|
viewMode.classList.add('hidden');
|
||||||
|
editModeEl.classList.remove('hidden');
|
||||||
|
editBtn.classList.add('hidden');
|
||||||
|
document.getElementById('overview-notes-textarea').focus();
|
||||||
|
} else {
|
||||||
|
viewMode.classList.remove('hidden');
|
||||||
|
editModeEl.classList.add('hidden');
|
||||||
|
editBtn.classList.remove('hidden');
|
||||||
|
document.getElementById('overview-notes-textarea').value = {{ domain.notes|default('')|json_encode|raw }};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareReturnTo(e) {
|
||||||
|
const form = e.target;
|
||||||
|
const input = form.querySelector('input[name="return_to"]') || document.getElementById('return_to_input');
|
||||||
|
if (!input) return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (!url.hash) {
|
||||||
|
const active = document.querySelector('.tab-button.active');
|
||||||
|
if (active && active.id) {
|
||||||
|
url.hash = '#' + active.id.replace('tab-','');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.value = url.pathname + (url.hash ? url.hash : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefreshBtn(form) {
|
||||||
|
var btn = form.querySelector('.refresh-all-btn');
|
||||||
|
if (!btn || btn.disabled) return false;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
|
||||||
|
btn.classList.add('bg-gray-400', 'cursor-not-allowed');
|
||||||
|
var icon = btn.querySelector('i');
|
||||||
|
var label = btn.querySelector('.btn-label');
|
||||||
|
if (icon) icon.classList.add('fa-spin');
|
||||||
|
if (label) label.textContent = 'Refreshing...';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||||
Refresh
|
Refresh WHOIS
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||||
|
|||||||
@@ -238,6 +238,7 @@
|
|||||||
<!-- Add Domain Button -->
|
<!-- Add Domain Button -->
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-slate-700">
|
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-slate-700">
|
||||||
<form method="POST" action="/domains/store" class="flex items-center justify-between">
|
<form method="POST" action="/domains/store" class="flex items-center justify-between">
|
||||||
|
{{ csrf_field()|raw }}
|
||||||
<input type="hidden" name="domain_name" value="{{ whoisData.domain }}">
|
<input type="hidden" name="domain_name" value="{{ whoisData.domain }}">
|
||||||
<p class="text-sm text-gray-600 dark:text-slate-400">Want to monitor this domain?</p>
|
<p class="text-sm text-gray-600 dark:text-slate-400">Want to monitor this domain?</p>
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{% set currentNotificationDays = settings.notification_days_before|default('30,15,7,3,1') %}
|
{% set currentNotificationDays = settings.notification_days_before|default('30,15,7,3,1') %}
|
||||||
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
|
||||||
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
{% set lastCheckRun = settings.last_check_run|default(null) %}
|
||||||
|
{% set lastDnsCheckRun = settings.last_dns_check_run|default(null) %}
|
||||||
{% set currentVer = appSettings.app_version|default('0') %}
|
{% set currentVer = appSettings.app_version|default('0') %}
|
||||||
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
{% set updateChannel = updateSettings.update_channel|default('stable') %}
|
||||||
|
|
||||||
@@ -148,6 +149,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain View Template -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-slate-700 pt-4 mt-6">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-4">Domain View</h4>
|
||||||
|
<div>
|
||||||
|
<label for="domain_view_template" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||||
|
Domain Detail Page Template
|
||||||
|
</label>
|
||||||
|
<select id="domain_view_template" name="domain_view_template"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="legacy" {{ settings.domain_view_template == 'legacy' ? 'selected' : '' }}>
|
||||||
|
Legacy — Classic single-page layout
|
||||||
|
</option>
|
||||||
|
<option value="detailed" {{ settings.domain_view_template == 'detailed' or settings.domain_view_template is not defined ? 'selected' : '' }}>
|
||||||
|
Detailed — Tabbed layout with extended information
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||||
|
Choose which template to use when viewing domain details at <code class="bg-gray-100 dark:bg-slate-600 px-1 rounded">/domains/{id}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200 dark:border-slate-700">
|
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200 dark:border-slate-700">
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||||
<i class="fas fa-save mr-2"></i>
|
<i class="fas fa-save mr-2"></i>
|
||||||
@@ -464,25 +487,7 @@
|
|||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Last cronjob run times are shown in the System tab</p>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
|
||||||
Last Check Run
|
|
||||||
</label>
|
|
||||||
<div class="px-3 py-2 bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg">
|
|
||||||
{% if lastCheckRun %}
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
|
||||||
<span class="text-gray-700 dark:text-slate-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex items-center text-sm">
|
|
||||||
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
|
|
||||||
<span class="text-gray-500 dark:text-slate-400">Never run</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -772,27 +777,99 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<!-- Cron Command -->
|
<!-- Cron Commands -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
||||||
<i class="fas fa-terminal text-blue-500 dark:text-blue-400 mr-2"></i>
|
<i class="fas fa-terminal text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
Cron Job Command
|
Cron Job Commands
|
||||||
</h4>
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
|
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">Domain / WHOIS check</p>
|
||||||
<code>php cron/check_domains.php</code>
|
<code>php cron/check_domains.php</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
|
||||||
|
<code>php cron/check_dns.php</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Crontab Entry -->
|
<!-- Crontab Entries -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
||||||
<i class="fas fa-calendar-alt text-green-500 dark:text-green-400 mr-2"></i>
|
<i class="fas fa-calendar-alt text-green-500 dark:text-green-400 mr-2"></i>
|
||||||
Recommended Crontab Entry
|
Recommended Crontab Entries
|
||||||
</h4>
|
</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">Domain check (every {{ currentCheckInterval }}h)</p>
|
||||||
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
||||||
<code>0 */{{ currentCheckInterval }} * * * php {{ cronPath }}</code>
|
<code>0 */{{ currentCheckInterval }} * * * php {{ cronPath }}</code>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the path to match your server installation</p>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">DNS check (every 6 hours)</p>
|
||||||
|
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
|
||||||
|
<code>0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the paths to match your server installation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Cronjob Run -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
|
||||||
|
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
|
||||||
|
Last Cronjob Run
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_domains.php</p>
|
||||||
|
</div>
|
||||||
|
{% if lastCheckRun %}
|
||||||
|
<div class="flex items-center text-sm" title="{{ domainCronStale|default(false) ? 'Cron has not run within expected interval' : '' }}">
|
||||||
|
{% if domainCronStale|default(false) %}
|
||||||
|
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400 mr-2"></i>
|
||||||
|
<span class="text-amber-700 dark:text-amber-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||||
|
<span class="text-gray-700 dark:text-slate-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Never run</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">DNS</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_dns.php</p>
|
||||||
|
</div>
|
||||||
|
{% if lastDnsCheckRun %}
|
||||||
|
<div class="flex items-center text-sm" title="{{ dnsCronStale|default(false) ? 'Cron has not run within expected interval (24h)' : '' }}">
|
||||||
|
{% if dnsCronStale|default(false) %}
|
||||||
|
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400 mr-2"></i>
|
||||||
|
<span class="text-amber-700 dark:text-amber-300">{{ lastDnsCheckRun|date('M d, Y H:i') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||||
|
<span class="text-gray-700 dark:text-slate-300">{{ lastDnsCheckRun|date('M d, Y H:i') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||||
|
<span class="text-gray-500 dark:text-slate-400">Never run</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log Files -->
|
<!-- Log Files -->
|
||||||
@@ -804,17 +881,24 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Cron Log</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain Cron Log</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Domain check execution logs</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">WHOIS / expiration check logs</p>
|
||||||
</div>
|
</div>
|
||||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/cron.log</code>
|
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/cron.log</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">DNS Cron Log</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">DNS record check logs</p>
|
||||||
|
</div>
|
||||||
|
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">TLD registry import logs</p>
|
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">TLD registry import logs</p>
|
||||||
</div>
|
</div>
|
||||||
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/tld_import_*.log</code>
|
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/tld_import.log</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||||
<i class="fas fa-sync-alt"></i>
|
<i class="fas fa-sync-alt"></i>
|
||||||
@@ -267,10 +267,10 @@
|
|||||||
<a href="/domains/{{ domain.id }}" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
<a href="/domains/{{ domain.id }}" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||||
<i class="fas fa-eye mr-1"></i> View
|
<i class="fas fa-eye mr-1"></i> View
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="flex-1">
|
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="flex-1">
|
||||||
{{ csrf_field() }}
|
{{ csrf_field() }}
|
||||||
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors">
|
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors">
|
||||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
856
cron/check_dns.php
Normal file
856
cron/check_dns.php
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS Record Monitoring Cron Job
|
||||||
|
*
|
||||||
|
* Checks DNS records for all active domains and sends notifications
|
||||||
|
* when changes are detected (new records, removed records, changed records).
|
||||||
|
*
|
||||||
|
* Also handles crt.sh subdomain fetching internally via self-invocation
|
||||||
|
* with a hard timeout (no separate script needed).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php cron/check_dns.php — run the full DNS check
|
||||||
|
* php cron/check_dns.php --crtsh <domain> [max] — (internal) crt.sh subprocess
|
||||||
|
*
|
||||||
|
* Crontab: 0 0,6,12,18 * * * /usr/bin/php /path/to/project/cron/check_dns.php
|
||||||
|
*
|
||||||
|
* NOTE: Requires a `crtsh_last_fetched` column on the domains table:
|
||||||
|
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Dotenv\Dotenv;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\DnsRecord;
|
||||||
|
use App\Models\NotificationChannel;
|
||||||
|
use App\Models\NotificationGroup;
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\DnsService;
|
||||||
|
use App\Services\NotificationService;
|
||||||
|
use App\Services\Logger;
|
||||||
|
use Core\Database;
|
||||||
|
|
||||||
|
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
|
$dotenv->load();
|
||||||
|
new Database();
|
||||||
|
|
||||||
|
// ─── Crt.sh subprocess mode ─────────────────────────────────────────────────
|
||||||
|
// When invoked with --crtsh, this script acts as its own subprocess for
|
||||||
|
// crt.sh fetching. Outputs a JSON array of subdomains to stdout and exits.
|
||||||
|
|
||||||
|
if (isset($argv[1]) && $argv[1] === '--crtsh') {
|
||||||
|
runCrtshSubprocess($argv);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main cron mode ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** crt.sh subprocess hard kill (seconds). In practice crt.sh 503s in <60s, but HTTP timeout is 900s as insurance. */
|
||||||
|
const CRTSH_TIMEOUT_SECONDS = 1800;
|
||||||
|
|
||||||
|
/** Max unique subdomains from crt.sh per domain (0 = no limit) */
|
||||||
|
const CRTSH_MAX_SUBDOMAINS = 100;
|
||||||
|
|
||||||
|
/** How often to re-fetch crt.sh per domain (hours). New certs appear gradually. */
|
||||||
|
const CRTSH_REFRESH_HOURS = 24;
|
||||||
|
|
||||||
|
/** Microseconds to sleep between domains */
|
||||||
|
const INTER_DOMAIN_DELAY_US = 500000;
|
||||||
|
|
||||||
|
// Initialize services and models
|
||||||
|
$domainModel = new Domain();
|
||||||
|
$dnsModel = new DnsRecord();
|
||||||
|
$channelModel = new NotificationChannel();
|
||||||
|
$groupModel = new NotificationGroup();
|
||||||
|
$logModel = new NotificationLog();
|
||||||
|
$notificationModel = new \App\Models\Notification();
|
||||||
|
$settingModel = new Setting();
|
||||||
|
$userModel = new User();
|
||||||
|
$dnsService = new DnsService();
|
||||||
|
$notificationService = new NotificationService();
|
||||||
|
$logger = new Logger('dns-cron');
|
||||||
|
|
||||||
|
// Set timezone from settings
|
||||||
|
try {
|
||||||
|
$appSettings = $settingModel->getAppSettings();
|
||||||
|
date_default_timezone_set($appSettings['app_timezone']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logFile = __DIR__ . '/../logs/dns_cron.log';
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
logMessage("=== Starting DNS check cron job ===");
|
||||||
|
|
||||||
|
$domains = $domainModel->where('is_active', 1);
|
||||||
|
$domains = array_values(array_filter($domains, fn($d) => ($d['dns_monitoring_enabled'] ?? 1) == 1));
|
||||||
|
logMessage("Found " . count($domains) . " domain(s) with DNS monitoring enabled");
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'checked' => 0,
|
||||||
|
'changes_detected' => 0,
|
||||||
|
'records_added' => 0,
|
||||||
|
'records_removed' => 0,
|
||||||
|
'records_changed' => 0,
|
||||||
|
'notifications_sent' => 0,
|
||||||
|
'in_app_notifications' => 0,
|
||||||
|
'errors' => 0,
|
||||||
|
'skipped_unresolved' => 0,
|
||||||
|
'crtsh_skipped' => 0,
|
||||||
|
'crtsh_fetched' => 0,
|
||||||
|
'domains_with_changes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||||
|
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
$domainName = $domain['domain_name'];
|
||||||
|
$domainStartTime = microtime(true);
|
||||||
|
logMessage("Checking DNS: $domainName");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Quick existence check — skip if domain doesn't resolve at all
|
||||||
|
if (!domainResolves($domainName)) {
|
||||||
|
logMessage(" ⏭ Domain does not resolve (no SOA/A/AAAA), skipping");
|
||||||
|
logTimeSince($domainStartTime);
|
||||||
|
$stats['skipped_unresolved']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousRecords = $dnsModel->getPreviousSnapshot($domain['id']);
|
||||||
|
$isFirstScan = empty($previousRecords);
|
||||||
|
|
||||||
|
// Gather subdomain candidates: known hosts from DB
|
||||||
|
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
|
||||||
|
|
||||||
|
// Decide whether to call crt.sh or use cached hosts
|
||||||
|
$ctSubs = [];
|
||||||
|
|
||||||
|
if (shouldFetchCrtsh($domain, $existingHosts)) {
|
||||||
|
logMessage(" 🔍 crt.sh: fetching subdomains...");
|
||||||
|
|
||||||
|
[$ctSubs, $crtshOk] = fetchCrtshWithTimeout($domainName);
|
||||||
|
|
||||||
|
logMessage(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
|
||||||
|
$stats['crtsh_fetched']++;
|
||||||
|
|
||||||
|
// Update timestamp if server responded (200 OK).
|
||||||
|
// Empty [] is valid (no CT entries) — still counts as a successful fetch.
|
||||||
|
// Only skip update if all attempts 503'd / timed out.
|
||||||
|
if ($crtshOk) {
|
||||||
|
$domainModel->update($domain['id'], [
|
||||||
|
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logMessage(" ⏩ crt.sh skipped (" . count($existingHosts) . " known host(s), refresh in "
|
||||||
|
. crtshHoursUntilRefresh($domain) . "h)");
|
||||||
|
$stats['crtsh_skipped']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
|
||||||
|
|
||||||
|
// Fetch fresh DNS records
|
||||||
|
$newRecords = $dnsService->lookup($domainName, $extraSubs);
|
||||||
|
$totalRecords = array_sum(array_map('count', $newRecords));
|
||||||
|
|
||||||
|
if ($totalRecords === 0) {
|
||||||
|
logMessage(" ⚠ No DNS records found for $domainName");
|
||||||
|
logTimeSince($domainStartTime);
|
||||||
|
$stats['errors']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich A/AAAA records with IP details (PTR, ASN, geo)
|
||||||
|
enrichIpDetails($newRecords, $dnsService);
|
||||||
|
|
||||||
|
// Save snapshot
|
||||||
|
$saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords);
|
||||||
|
$domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]);
|
||||||
|
|
||||||
|
$stats['checked']++;
|
||||||
|
logMessage(" ✓ $totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})");
|
||||||
|
|
||||||
|
if ($isFirstScan) {
|
||||||
|
logMessage(" → First scan — baseline saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect changes
|
||||||
|
$changes = $dnsService->diffRecords($previousRecords, $newRecords);
|
||||||
|
$hasChanges = !empty($changes['added']) || !empty($changes['removed']) || !empty($changes['changed']);
|
||||||
|
|
||||||
|
if (!$hasChanges) {
|
||||||
|
logMessage(" → No changes detected");
|
||||||
|
logTimeSince($domainStartTime);
|
||||||
|
usleep(INTER_DOMAIN_DELAY_US);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats['changes_detected']++;
|
||||||
|
$stats['records_added'] += count($changes['added']);
|
||||||
|
$stats['records_removed'] += count($changes['removed']);
|
||||||
|
$stats['records_changed'] += count($changes['changed']);
|
||||||
|
|
||||||
|
$summary = $dnsService->formatChangesSummary($changes, $domainName);
|
||||||
|
$detail = $dnsService->formatChangesDetail($changes, $domainName);
|
||||||
|
logMessage(" 🔄 $summary");
|
||||||
|
|
||||||
|
$stats['domains_with_changes'][] = [
|
||||||
|
'domain' => $domainName,
|
||||||
|
'added' => count($changes['added']),
|
||||||
|
'removed' => count($changes['removed']),
|
||||||
|
'changed' => count($changes['changed']),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Send external notifications (channel alerts)
|
||||||
|
sendExternalNotifications(
|
||||||
|
$domain, $domainModel, $channelModel, $logModel,
|
||||||
|
$notificationService, $detail, $summary, $stats, $logger
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create in-app notifications (bell icon)
|
||||||
|
sendInAppNotifications(
|
||||||
|
$domain, $domainName, $isolationMode, $userModel, $groupModel,
|
||||||
|
$notificationService, $summary, $stats
|
||||||
|
);
|
||||||
|
|
||||||
|
logTimeSince($domainStartTime);
|
||||||
|
usleep(INTER_DOMAIN_DELAY_US);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logMessage(" ✗ Error: " . $e->getMessage());
|
||||||
|
logTimeSince($domainStartTime);
|
||||||
|
$logger->error("DNS check failed", [
|
||||||
|
'domain' => $domainName,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$stats['errors']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$settingModel->setValue('last_dns_check_run', date('Y-m-d H:i:s'));
|
||||||
|
printSummary($stats, $startTime);
|
||||||
|
exit(0);
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Crt.sh smart caching
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should we fetch crt.sh for this domain right now?
|
||||||
|
*
|
||||||
|
* Skip if we already have enough known hosts and fetched recently.
|
||||||
|
* Always fetch on first scan or if we have very few known hosts.
|
||||||
|
*
|
||||||
|
* NOTE: Requires a `crtsh_last_fetched` DATETIME column on the domains table.
|
||||||
|
* ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL;
|
||||||
|
*/
|
||||||
|
function shouldFetchCrtsh(array $domain, array $existingHosts): bool
|
||||||
|
{
|
||||||
|
// Always fetch if we've never successfully fetched before
|
||||||
|
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
||||||
|
if (empty($lastFetched)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect the refresh interval — even if domain has few hosts,
|
||||||
|
// crt.sh already answered (maybe with [] or few results). Don't hammer it.
|
||||||
|
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
||||||
|
return $hoursSince >= CRTSH_REFRESH_HOURS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hours remaining until next crt.sh refresh (for log messages).
|
||||||
|
*/
|
||||||
|
function crtshHoursUntilRefresh(array $domain): string
|
||||||
|
{
|
||||||
|
$lastFetched = $domain['crtsh_last_fetched'] ?? null;
|
||||||
|
if (empty($lastFetched)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
$hoursSince = (time() - strtotime($lastFetched)) / 3600;
|
||||||
|
$remaining = max(0, CRTSH_REFRESH_HOURS - $hoursSince);
|
||||||
|
return sprintf('%.1f', $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Crt.sh subprocess (self-invocation with hard timeout)
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal crt.sh subprocess entry point.
|
||||||
|
* Called when this script is invoked with: --crtsh <domain> [max_subdomains]
|
||||||
|
* Outputs a JSON array of subdomains to stdout.
|
||||||
|
*
|
||||||
|
* Wildcard query ?q=%.domain.com with 5 retry attempts.
|
||||||
|
* All HTTP response details are written to stderr for real-time debugging.
|
||||||
|
*/
|
||||||
|
function runCrtshSubprocess(array $argv): void
|
||||||
|
{
|
||||||
|
if (empty($argv[2])) {
|
||||||
|
fwrite(STDERR, "Usage: {$argv[0]} --crtsh <domain> [max_subdomains]\n");
|
||||||
|
echo '[]';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = $argv[2];
|
||||||
|
$maxSubdomains = isset($argv[3]) ? max(0, (int) $argv[3]) : 0;
|
||||||
|
$maxAttempts = 5;
|
||||||
|
$retryDelay = 10;
|
||||||
|
$httpTimeout = 900;
|
||||||
|
|
||||||
|
$url = 'https://crt.sh/?q=%25.' . urlencode($domain) . '&output=json';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = [];
|
||||||
|
$gotHttp200 = false;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||||
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: GET $url\n");
|
||||||
|
|
||||||
|
$response = fetchCrtshWithDebug($url, $httpTimeout);
|
||||||
|
|
||||||
|
// HTTP 200 — server answered, don't retry regardless of content
|
||||||
|
if ($response['status'] === 200) {
|
||||||
|
$gotHttp200 = true;
|
||||||
|
if (!empty($response['data'])) {
|
||||||
|
$result = extractSubdomains($response['data'], $domain);
|
||||||
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: " . count($result) . " subdomain(s) extracted\n");
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: 200 OK but no cert data (domain may have no CT entries)\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-200 (503, timeout, connection error) — retry
|
||||||
|
if ($attempt < $maxAttempts) {
|
||||||
|
fwrite(STDERR, "attempt $attempt/$maxAttempts: retrying in {$retryDelay}s...\n");
|
||||||
|
sleep($retryDelay);
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "all $maxAttempts attempts failed\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cap
|
||||||
|
if ($maxSubdomains > 0 && count($result) > $maxSubdomains) {
|
||||||
|
fwrite(STDERR, "result: " . count($result) . " subdomain(s), capped to $maxSubdomains\n");
|
||||||
|
$result = array_slice(array_values($result), 0, $maxSubdomains);
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "result: " . count($result) . " subdomain(s)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => $gotHttp200, 'subs' => array_values($result)]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
fwrite(STDERR, "crt.sh error: " . $e->getMessage() . "\n");
|
||||||
|
echo json_encode(['ok' => false, 'subs' => []]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a crt.sh URL with full debug output to stderr.
|
||||||
|
* Dumps HTTP response headers + body preview immediately so you see
|
||||||
|
* exactly what the server returned — like watching curl in real-time.
|
||||||
|
*
|
||||||
|
* @param string $url Full crt.sh URL
|
||||||
|
* @param int $timeout HTTP timeout in seconds
|
||||||
|
* @return array{status: int, body_length: int, data: array, time: float}
|
||||||
|
*/
|
||||||
|
function fetchCrtshWithDebug(string $url, int $timeout = 900): array
|
||||||
|
{
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => $timeout,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'header' => implode("\r\n", [
|
||||||
|
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept: application/json, text/plain, */*',
|
||||||
|
'Accept-Language: en-US,en;q=0.9',
|
||||||
|
'Connection: keep-alive',
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$http_response_header = null;
|
||||||
|
$body = @file_get_contents($url, false, $ctx);
|
||||||
|
$elapsed = microtime(true) - $start;
|
||||||
|
|
||||||
|
// ── Dump full response to stderr ──────────────────────────────────
|
||||||
|
fwrite(STDERR, "--- response ---\n");
|
||||||
|
fwrite(STDERR, "Time: " . sprintf('%.1f', $elapsed) . "s\n");
|
||||||
|
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
foreach ($http_response_header as $h) {
|
||||||
|
fwrite(STDERR, "$h\n");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fwrite(STDERR, "(no response headers — connection failed or timeout)\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$bodyLen = is_string($body) ? strlen($body) : 0;
|
||||||
|
fwrite(STDERR, "Body: $bodyLen bytes\n");
|
||||||
|
|
||||||
|
if (is_string($body) && $bodyLen > 0) {
|
||||||
|
// Show first 2000 chars of body so you can see errors, JSON start, etc.
|
||||||
|
$preview = $bodyLen > 2000 ? substr($body, 0, 2000) . "\n... [truncated, $bodyLen total]" : $body;
|
||||||
|
fwrite(STDERR, $preview . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "--- end response ---\n");
|
||||||
|
|
||||||
|
// ── Parse status and JSON ─────────────────────────────────────────
|
||||||
|
$status = 0;
|
||||||
|
if (isset($http_response_header[0]) && preg_match('/\d{3}/', $http_response_header[0], $m)) {
|
||||||
|
$status = (int) $m[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
if ($status === 200 && is_string($body) && $bodyLen > 2) {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$data = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'body_length' => $bodyLen,
|
||||||
|
'data' => $data,
|
||||||
|
'time' => $elapsed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract unique subdomain names from raw crt.sh JSON response.
|
||||||
|
*
|
||||||
|
* Each entry has a `name_value` field that may contain multiple newline-separated
|
||||||
|
* names, including wildcards. We strip wildcards, filter to our target domain,
|
||||||
|
* and return only the subdomain prefixes (e.g. "www", "mail", "api").
|
||||||
|
*
|
||||||
|
* @param array $crtshData Decoded JSON array from crt.sh
|
||||||
|
* @param string $domain The base domain (e.g. "example.com")
|
||||||
|
* @return string[] Unique subdomain prefixes
|
||||||
|
*/
|
||||||
|
function extractSubdomains(array $crtshData, string $domain): array
|
||||||
|
{
|
||||||
|
$domainLower = strtolower($domain);
|
||||||
|
$suffix = '.' . $domainLower;
|
||||||
|
$suffixLen = strlen($suffix);
|
||||||
|
$subs = [];
|
||||||
|
|
||||||
|
foreach ($crtshData as $entry) {
|
||||||
|
if (empty($entry['name_value'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\n", $entry['name_value']) as $name) {
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
|
// Strip wildcard prefix
|
||||||
|
if (strpos($name, '*.') === 0) {
|
||||||
|
$name = substr($name, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the apex domain itself
|
||||||
|
if ($name === $domainLower) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be a subdomain of our domain
|
||||||
|
if (substr($name, -$suffixLen) !== $suffix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the subdomain part (everything before .domain.tld)
|
||||||
|
$sub = substr($name, 0, strlen($name) - $suffixLen);
|
||||||
|
if (!empty($sub)) {
|
||||||
|
$subs[$sub] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($subs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Subprocess management (main process side)
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a subprocess of this script in --crtsh mode with a hard timeout.
|
||||||
|
* Relays stderr from the subprocess to logMessage in REAL-TIME so you see
|
||||||
|
* every HTTP response, retry, and status as it happens.
|
||||||
|
*
|
||||||
|
* @return array{0: string[], 1: bool} [subdomains, ok (true if server responded 200)]
|
||||||
|
*/
|
||||||
|
function fetchCrtshWithTimeout(string $domainName): array
|
||||||
|
{
|
||||||
|
$phpBin = defined('PHP_BINARY') && PHP_BINARY ? PHP_BINARY : 'php';
|
||||||
|
$cmd = [$phpBin, __FILE__, '--crtsh', $domainName];
|
||||||
|
|
||||||
|
if (CRTSH_MAX_SUBDOMAINS > 0) {
|
||||||
|
$cmd[] = (string) CRTSH_MAX_SUBDOMAINS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$proc = proc_open($cmd, [
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
], $pipes, __DIR__ . '/..');
|
||||||
|
|
||||||
|
if (!is_resource($proc)) {
|
||||||
|
return [[], false];
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($pipes[0]);
|
||||||
|
stream_set_blocking($pipes[1], false);
|
||||||
|
stream_set_blocking($pipes[2], false);
|
||||||
|
|
||||||
|
$start = time();
|
||||||
|
$stdout = '';
|
||||||
|
$stderrBuffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$status = proc_get_status($proc);
|
||||||
|
|
||||||
|
if (!$status['running']) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = time() - $start;
|
||||||
|
|
||||||
|
// Hard timeout — kill the subprocess
|
||||||
|
if ($elapsed >= CRTSH_TIMEOUT_SECONDS) {
|
||||||
|
$stdout .= drainStream($pipes[1]);
|
||||||
|
$stderrBuffer .= drainStream($pipes[2]);
|
||||||
|
flushStderrLines($stderrBuffer);
|
||||||
|
proc_terminate($proc, 9);
|
||||||
|
proc_close($proc);
|
||||||
|
logMessage(" ✗ crt.sh killed after {$elapsed}s (hard timeout)");
|
||||||
|
return [[], false];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read available data from pipes
|
||||||
|
$readable = [$pipes[1], $pipes[2]];
|
||||||
|
$w = $e = null;
|
||||||
|
if (@stream_select($readable, $w, $e, 0, 200000) > 0) {
|
||||||
|
foreach ($readable as $stream) {
|
||||||
|
$chunk = stream_get_contents($stream);
|
||||||
|
if ($stream === $pipes[1]) {
|
||||||
|
$stdout .= $chunk;
|
||||||
|
} else {
|
||||||
|
$stderrBuffer .= $chunk;
|
||||||
|
// Flush complete lines to terminal immediately
|
||||||
|
flushStderrLines($stderrBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain remaining output
|
||||||
|
$stdout .= stream_get_contents($pipes[1]);
|
||||||
|
$stderrBuffer .= stream_get_contents($pipes[2]);
|
||||||
|
flushStderrLines($stderrBuffer);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
proc_close($proc);
|
||||||
|
|
||||||
|
$decoded = json_decode($stdout, true);
|
||||||
|
$ok = is_array($decoded) && !empty($decoded['ok']);
|
||||||
|
$subs = is_array($decoded) && isset($decoded['subs']) ? $decoded['subs'] : [];
|
||||||
|
return [$subs, $ok];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush complete lines from stderr buffer to logMessage in real-time.
|
||||||
|
* Keeps any incomplete trailing line in the buffer for next call.
|
||||||
|
*/
|
||||||
|
function flushStderrLines(string &$buffer): void
|
||||||
|
{
|
||||||
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||||
|
$line = trim(substr($buffer, 0, $pos));
|
||||||
|
$buffer = substr($buffer, $pos + 1);
|
||||||
|
if ($line !== '') {
|
||||||
|
logMessage(" ↳ $line");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// DNS helpers
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a domain resolves at all (SOA, A, or AAAA).
|
||||||
|
*/
|
||||||
|
function domainResolves(string $domain): bool
|
||||||
|
{
|
||||||
|
return @checkdnsrr($domain, 'SOA')
|
||||||
|
|| @checkdnsrr($domain, 'A')
|
||||||
|
|| @checkdnsrr($domain, 'AAAA');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich A/AAAA records in-place with IP metadata (PTR, ASN, geo).
|
||||||
|
*/
|
||||||
|
function enrichIpDetails(array &$newRecords, DnsService $dnsService): void
|
||||||
|
{
|
||||||
|
$ips = [];
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
foreach ($newRecords[$type] ?? [] as $r) {
|
||||||
|
if (!empty($r['value'])) {
|
||||||
|
$ips[] = $r['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($ips)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipDetails = $dnsService->lookupIpDetails($ips);
|
||||||
|
|
||||||
|
foreach (['A', 'AAAA'] as $type) {
|
||||||
|
if (empty($newRecords[$type])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($newRecords[$type] as &$rec) {
|
||||||
|
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
|
||||||
|
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Notification helpers
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send external notifications via configured channels.
|
||||||
|
*/
|
||||||
|
function sendExternalNotifications(
|
||||||
|
array $domain,
|
||||||
|
Domain $domainModel,
|
||||||
|
NotificationChannel $channelModel,
|
||||||
|
NotificationLog $logModel,
|
||||||
|
NotificationService $notificationService,
|
||||||
|
string $detail,
|
||||||
|
string $summary,
|
||||||
|
array &$stats,
|
||||||
|
Logger $logger
|
||||||
|
): void {
|
||||||
|
if (empty($domain['notification_group_id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($logModel->wasSentRecently($domain['id'], 'dns_change', 6)) {
|
||||||
|
logMessage(" → DNS change notification sent recently, skipping external");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channels = $channelModel->getActiveByGroupId($domain['notification_group_id']);
|
||||||
|
if (empty($channels)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage(" 📤 Sending alerts to " . count($channels) . " channel(s)");
|
||||||
|
|
||||||
|
$domainData = $domainModel->find($domain['id']);
|
||||||
|
$results = $notificationService->sendDnsChangeAlert($domainData, $channels, $detail);
|
||||||
|
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$ok = $result['success'];
|
||||||
|
logMessage($ok
|
||||||
|
? " ✓ Sent to {$result['channel']}"
|
||||||
|
: " ✗ Failed: {$result['channel']}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
$stats['notifications_sent']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logModel->log(
|
||||||
|
$domain['id'],
|
||||||
|
'dns_change',
|
||||||
|
$result['channel'],
|
||||||
|
$summary,
|
||||||
|
$ok,
|
||||||
|
$ok ? null : 'Failed to send notification'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create in-app (bell icon) notifications for relevant users.
|
||||||
|
*/
|
||||||
|
function sendInAppNotifications(
|
||||||
|
array $domain,
|
||||||
|
string $domainName,
|
||||||
|
string $isolationMode,
|
||||||
|
User $userModel,
|
||||||
|
NotificationGroup $groupModel,
|
||||||
|
NotificationService $notificationService,
|
||||||
|
string $summary,
|
||||||
|
array &$stats
|
||||||
|
): void {
|
||||||
|
$usersToNotify = [];
|
||||||
|
|
||||||
|
if ($isolationMode === 'isolated') {
|
||||||
|
$userId = $domain['user_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$userId && !empty($domain['notification_group_id'])) {
|
||||||
|
$group = $groupModel->find($domain['notification_group_id']);
|
||||||
|
$userId = $group['user_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId) {
|
||||||
|
$usersToNotify[] = $userId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($userModel->where('is_active', 1) as $user) {
|
||||||
|
$usersToNotify[] = $user['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($usersToNotify)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getConnection();
|
||||||
|
$notifiedCount = 0;
|
||||||
|
|
||||||
|
foreach ($usersToNotify as $userId) {
|
||||||
|
// Deduplicate: skip if already notified in the last 6 hours
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM user_notifications
|
||||||
|
WHERE user_id = ? AND domain_id = ? AND type = 'dns_change'
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)"
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $domain['id']]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($row && $row['cnt'] > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$notificationService->notifyDnsChange($userId, $domainName, $domain['id'], $summary);
|
||||||
|
$notifiedCount++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logMessage(" ⚠ In-app notification failed for user $userId: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($notifiedCount > 0) {
|
||||||
|
logMessage(" 🔔 Notified $notifiedCount user(s) in-app");
|
||||||
|
$stats['in_app_notifications'] += $notifiedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Logging / formatting helpers
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function logMessage(string $message): void
|
||||||
|
{
|
||||||
|
global $logFile;
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$line = "[$timestamp] $message\n";
|
||||||
|
file_put_contents($logFile, $line, FILE_APPEND);
|
||||||
|
echo $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTimeSince(float $since): void
|
||||||
|
{
|
||||||
|
logMessage(" ⏱ " . formatDuration(microtime(true) - $since));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(float $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return sprintf("%.1fs", $seconds);
|
||||||
|
}
|
||||||
|
$m = (int) floor($seconds / 60);
|
||||||
|
$s = $seconds - $m * 60;
|
||||||
|
return $m . 'm ' . sprintf("%.1fs", $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedTime(float $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return sprintf("%.2f seconds", $seconds);
|
||||||
|
}
|
||||||
|
if ($seconds < 3600) {
|
||||||
|
$m = (int) floor($seconds / 60);
|
||||||
|
$s = $seconds - $m * 60;
|
||||||
|
return sprintf("%d minute%s %.2f seconds", $m, $m !== 1 ? 's' : '', $s);
|
||||||
|
}
|
||||||
|
$h = (int) floor($seconds / 3600);
|
||||||
|
$m = (int) floor(($seconds - $h * 3600) / 60);
|
||||||
|
$s = $seconds - $h * 3600 - $m * 60;
|
||||||
|
return sprintf("%d hour%s %d minute%s %.2f seconds",
|
||||||
|
$h, $h !== 1 ? 's' : '', $m, $m !== 1 ? 's' : '', $s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain remaining data from a non-blocking stream and close it.
|
||||||
|
*/
|
||||||
|
function drainStream($stream): string
|
||||||
|
{
|
||||||
|
if (!is_resource($stream)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$data = stream_get_contents($stream);
|
||||||
|
fclose($stream);
|
||||||
|
return $data ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(array $stats, float $startTime): void
|
||||||
|
{
|
||||||
|
$elapsed = formatElapsedTime(microtime(true) - $startTime);
|
||||||
|
|
||||||
|
logMessage("\n=== DNS cron job completed ===");
|
||||||
|
logMessage("Domains checked: {$stats['checked']}");
|
||||||
|
logMessage("Skipped (unresolved): {$stats['skipped_unresolved']}");
|
||||||
|
logMessage("Crt.sh fetched: {$stats['crtsh_fetched']}");
|
||||||
|
logMessage("Crt.sh skipped (cached): {$stats['crtsh_skipped']}");
|
||||||
|
logMessage("Changes detected: {$stats['changes_detected']}");
|
||||||
|
logMessage("Records added: {$stats['records_added']}");
|
||||||
|
logMessage("Records removed: {$stats['records_removed']}");
|
||||||
|
logMessage("Records changed: {$stats['records_changed']}");
|
||||||
|
logMessage("External notifications: {$stats['notifications_sent']}");
|
||||||
|
logMessage("In-app notifications: {$stats['in_app_notifications']}");
|
||||||
|
logMessage("Errors: {$stats['errors']}");
|
||||||
|
logMessage("Execution time: $elapsed");
|
||||||
|
|
||||||
|
if (!empty($stats['domains_with_changes'])) {
|
||||||
|
logMessage("\n--- Domains with DNS changes ---");
|
||||||
|
foreach ($stats['domains_with_changes'] as $info) {
|
||||||
|
logMessage(" {$info['domain']}: +{$info['added']} added, -{$info['removed']} removed, ~{$info['changed']} changed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("==========================\n");
|
||||||
|
}
|
||||||
@@ -141,6 +141,7 @@ CREATE TABLE IF NOT EXISTS domains (
|
|||||||
updated_date DATE,
|
updated_date DATE,
|
||||||
abuse_email VARCHAR(255),
|
abuse_email VARCHAR(255),
|
||||||
last_checked TIMESTAMP NULL,
|
last_checked TIMESTAMP NULL,
|
||||||
|
dns_last_checked TIMESTAMP NULL,
|
||||||
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
|
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
|
||||||
whois_data JSON,
|
whois_data JSON,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
@@ -341,6 +342,32 @@ CREATE TABLE IF NOT EXISTS tld_import_logs (
|
|||||||
INDEX idx_status (status)
|
INDEX idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- DNS MONITORING
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- DNS records table for tracking DNS record changes
|
||||||
|
CREATE TABLE IF NOT EXISTS dns_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
domain_id INT NOT NULL,
|
||||||
|
record_type VARCHAR(10) NOT NULL COMMENT 'A, AAAA, MX, TXT, NS, CNAME, SOA',
|
||||||
|
host VARCHAR(255) NOT NULL DEFAULT '@',
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
ttl INT NULL,
|
||||||
|
priority INT NULL COMMENT 'MX priority',
|
||||||
|
is_cloudflare BOOLEAN DEFAULT FALSE,
|
||||||
|
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
||||||
|
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_domain_id (domain_id),
|
||||||
|
INDEX idx_record_type (record_type),
|
||||||
|
INDEX idx_domain_type (domain_id, record_type),
|
||||||
|
INDEX idx_last_seen (last_seen_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
-- SYSTEM SETTINGS
|
-- SYSTEM SETTINGS
|
||||||
-- =====================================================
|
-- =====================================================
|
||||||
@@ -397,6 +424,13 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
|||||||
-- User isolation settings
|
-- User isolation settings
|
||||||
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'),
|
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'),
|
||||||
|
|
||||||
|
-- Domain view settings
|
||||||
|
('domain_view_template', 'detailed', 'string', 'Domain view template: detailed or default'),
|
||||||
|
|
||||||
|
-- DNS monitoring settings
|
||||||
|
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
|
||||||
|
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran'),
|
||||||
|
|
||||||
-- Update system settings
|
-- Update system settings
|
||||||
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
|
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
|
||||||
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')
|
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')
|
||||||
|
|||||||
39
database/migrations/027_add_dns_monitoring.sql
Normal file
39
database/migrations/027_add_dns_monitoring.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- DNS Monitoring - Add dns_records table for tracking DNS record changes
|
||||||
|
CREATE TABLE IF NOT EXISTS dns_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
domain_id INT NOT NULL,
|
||||||
|
record_type VARCHAR(10) NOT NULL COMMENT 'A, AAAA, MX, TXT, NS, CNAME, SOA',
|
||||||
|
host VARCHAR(255) NOT NULL DEFAULT '@',
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
ttl INT NULL,
|
||||||
|
priority INT NULL COMMENT 'MX priority',
|
||||||
|
is_cloudflare BOOLEAN DEFAULT FALSE,
|
||||||
|
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
|
||||||
|
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_domain_id (domain_id),
|
||||||
|
INDEX idx_record_type (record_type),
|
||||||
|
INDEX idx_domain_type (domain_id, record_type),
|
||||||
|
INDEX idx_last_seen (last_seen_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Track when DNS was last checked per domain
|
||||||
|
ALTER TABLE domains ADD COLUMN dns_last_checked TIMESTAMP NULL AFTER last_checked;
|
||||||
|
|
||||||
|
-- crt.sh subdomain fetch tracking
|
||||||
|
ALTER TABLE domains ADD COLUMN crtsh_last_fetched DATETIME NULL DEFAULT NULL COMMENT 'Last time crt.sh subdomains were fetched for this domain';
|
||||||
|
|
||||||
|
-- Toggle DNS monitoring per domain (WHOIS and DNS are separate)
|
||||||
|
ALTER TABLE domains ADD COLUMN dns_monitoring_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=DNS monitoring active, 0=disabled' AFTER is_active;
|
||||||
|
|
||||||
|
-- Add DNS check interval setting
|
||||||
|
INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
|
||||||
|
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
|
||||||
|
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran')
|
||||||
|
ON DUPLICATE KEY UPDATE setting_key=setting_key;
|
||||||
|
|
||||||
|
INSERT INTO migrations (migration) VALUES ('027_add_dns_monitoring.sql')
|
||||||
|
ON DUPLICATE KEY UPDATE migration=migration;
|
||||||
@@ -84,7 +84,9 @@ $router->get('/domains/{id}', [DomainController::class, 'show']);
|
|||||||
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']);
|
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']);
|
||||||
$router->post('/domains/{id}/update', [DomainController::class, 'update']);
|
$router->post('/domains/{id}/update', [DomainController::class, 'update']);
|
||||||
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
|
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
|
||||||
$router->post('/domains/{id}/refresh', [DomainController::class, 'refresh']);
|
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
|
||||||
|
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
|
||||||
|
$router->post('/domains/{id}/refresh-all', [DomainController::class, 'refreshAll']);
|
||||||
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
|
||||||
|
|
||||||
// Notification Groups
|
// Notification Groups
|
||||||
|
|||||||
Reference in New Issue
Block a user