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:
Hosteroid
2026-03-08 14:32:05 +02:00
parent db094d6d8b
commit 8559e903b9
29 changed files with 4493 additions and 100 deletions

View File

@@ -608,6 +608,7 @@ class DomainController extends Controller
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
$isActive = isset($_POST['is_active']) ? 1 : 0;
$dnsMonitoringEnabled = isset($_POST['dns_monitoring_enabled']) ? 1 : 0;
$tagsInput = trim($_POST['tags'] ?? '');
$manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null;
@@ -642,6 +643,7 @@ class DomainController extends Controller
$this->domainModel->update($id, [
'notification_group_id' => $groupId,
'is_active' => $isActive,
'dns_monitoring_enabled' => $dnsMonitoringEnabled,
'expiration_date' => $manualExpirationDate
]);
@@ -664,6 +666,24 @@ class DomainController extends Controller
$notificationService->sendToGroup($groupId, $subject, $message);
}
// Send notification if DNS monitoring changed and has notification group
$dnsMonitoringChanged = (($domain['dns_monitoring_enabled'] ?? 1) != $dnsMonitoringEnabled);
if ($dnsMonitoringChanged && $groupId) {
$notificationService = new \App\Services\NotificationService();
if ($dnsMonitoringEnabled) {
$message = "🟢 DNS monitoring has been ENABLED for {$domain['domain_name']}\n\n" .
"DNS records will be checked for changes and you'll receive alerts when they change.";
$subject = "✅ DNS Monitoring Enabled: {$domain['domain_name']}";
} else {
$message = "🔴 DNS monitoring has been DISABLED for {$domain['domain_name']}\n\n" .
"DNS records will no longer be checked. You will not receive DNS change alerts.";
$subject = "⏸️ DNS Monitoring Disabled: {$domain['domain_name']}";
}
$notificationService->sendToGroup($groupId, $subject, $message);
}
// Also send notification if group changed and monitoring is active
if (!$statusChanged && $isActive && $oldGroupId != $groupId) {
$notificationService = new \App\Services\NotificationService();
@@ -697,50 +717,26 @@ class DomainController extends Controller
$this->redirect('/domains/' . $id);
}
public function refresh($params = [])
/**
* Perform WHOIS lookup and persist results.
* @return string|null Status message on success, null on failure.
*/
private function performWhoisRefresh(int $id, array $domain): ?string
{
$id = $params['id'] ?? 0;
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
// Log domain refresh start
$logger = new \App\Services\Logger();
$logger->info('Domain refresh started', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'user_id' => \Core\Auth::id(),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
// Get fresh WHOIS information
$whoisData = $this->whoisService->getDomainInfo($domain['domain_name']);
if (!$whoisData) {
$logger->error('Domain refresh failed - WHOIS data not retrieved', [
$logger->error('WHOIS refresh failed', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'user_id' => \Core\Auth::id()
]);
$_SESSION['error'] = 'Could not retrieve WHOIS information';
// Check if we came from view page
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (strpos($referer, '/domains/' . $id) !== false) {
$this->redirect('/domains/' . $id);
} else {
$this->redirect('/domains');
}
return;
return null;
}
// Use WHOIS expiration date if available, otherwise preserve manual expiration date
$expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date'];
$status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData);
$this->domainModel->update($id, [
@@ -754,8 +750,7 @@ class DomainController extends Controller
'whois_data' => json_encode($whoisData)
]);
// Log successful domain refresh
$logger->info('Domain refresh completed successfully', [
$logger->info('WHOIS refresh completed', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'new_status' => $status,
@@ -764,19 +759,129 @@ class DomainController extends Controller
'user_id' => \Core\Auth::id()
]);
$_SESSION['success'] = 'Domain information refreshed';
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'] ?? '';
if (strpos($referer, '/domains/' . $id) !== false) {
// Came from view page, go back to view page
$this->redirect('/domains/' . $id);
$this->redirect('/domains/' . $id . $hash);
} else {
// Came from list page, stay on list page
$this->redirect('/domains');
}
}
public function refreshWhois($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$result = $this->performWhoisRefresh($id, $domain);
if ($result === null) {
$_SESSION['error'] = 'Could not retrieve WHOIS information';
} else {
$_SESSION['success'] = 'WHOIS information refreshed';
}
$this->redirectBackToDomain($id);
}
public function refreshAll($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$messages = [];
$messages[] = $this->performWhoisRefresh($id, $domain) ?? 'WHOIS failed';
if (!empty($domain['dns_monitoring_enabled'])) {
$messages[] = $this->performDnsRefresh($id, $domain);
} else {
$messages[] = 'DNS skipped (monitoring disabled)';
}
$_SESSION['success'] = 'Domain refreshed: ' . implode(', ', $messages);
$this->redirectBackToDomain($id);
}
public function delete($params = [])
{
$id = $params['id'] ?? 0;
@@ -842,11 +947,39 @@ class DomainController extends Controller
$availableTags = $tagModel->getAllWithUsage();
}
$this->view('domains/view', [
// Get DNS records for the DNS tab
$dnsModel = new \App\Models\DnsRecord();
$dnsRecords = $dnsModel->getByDomainGrouped($id);
$dnsRecordCount = $dnsModel->countByDomain($id);
$dnsHasCloudflare = $dnsModel->hasCloudflare($id);
// Extract cached IP details (PTR, ASN, geo) from stored raw_data
$dnsIpDetails = [];
foreach (['A', 'AAAA'] as $type) {
if (!empty($dnsRecords[$type])) {
foreach ($dnsRecords[$type] as $r) {
if (!empty($r['raw_data']) && !empty($r['value'])) {
$raw = json_decode($r['raw_data'], true);
if (!empty($raw['_ip_info'])) {
$dnsIpDetails[$r['value']] = $raw['_ip_info'];
}
}
}
}
}
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
$templateName = $viewTemplate === 'detailed' ? 'domains/view-detailed' : 'domains/view';
$this->view($templateName, [
'domain' => $formattedDomain,
'whoisData' => $whoisData,
'logs' => $logs,
'availableTags' => $availableTags,
'dnsRecords' => $dnsRecords,
'dnsRecordCount' => $dnsRecordCount,
'dnsHasCloudflare' => $dnsHasCloudflare,
'dnsIpDetails' => $dnsIpDetails,
'title' => $domain['domain_name']
]);
}
@@ -1279,9 +1412,14 @@ class DomainController extends Controller
// Validate notes length
$lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes');
$settingModel = new \App\Models\Setting();
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
$redirect = '/domains/' . $id . ($viewTemplate === 'detailed' ? '#overview' : '');
if ($lengthError) {
$_SESSION['error'] = $lengthError;
$this->redirect('/domains/' . $id);
$this->redirect($redirect);
return;
}
@@ -1290,7 +1428,7 @@ class DomainController extends Controller
]);
$_SESSION['success'] = 'Notes updated successfully';
$this->redirect('/domains/' . $id);
$this->redirect($redirect);
}
public function bulkAddTags()
@@ -1615,6 +1753,32 @@ class DomainController extends Controller
$this->redirect('/domains');
}
// ========================================
// DNS MONITORING
// ========================================
public function refreshDns($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$result = $this->performDnsRefresh($id, $domain);
if (strpos($result, 'no records') !== false) {
$_SESSION['warning'] = 'No DNS records found for this domain';
} else {
$_SESSION['success'] = $result;
}
$this->redirectBackToDomain($id, '#dns');
}
/**
* Get tags for specific domains (API endpoint)
*/

View File

@@ -57,6 +57,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
try {
@@ -200,6 +201,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
}
@@ -423,6 +425,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");

View File

@@ -136,6 +136,21 @@ class SettingsController extends Controller
// Rollback availability
$rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']);
// Cron staleness: show warning if last run is overdue
$intervalHours = (int)($settings['check_interval_hours'] ?? 24);
$domainStaleThreshold = $intervalHours * 1.5; // e.g. 36h for 24h interval
$dnsStaleThreshold = 24; // DNS cron runs every 6h, 24h = overdue
$domainCronStale = false;
$dnsCronStale = false;
if (!empty($settings['last_check_run'])) {
$hoursSince = (time() - strtotime($settings['last_check_run'])) / 3600;
$domainCronStale = $hoursSince > $domainStaleThreshold;
}
if (!empty($settings['last_dns_check_run'])) {
$hoursSince = (time() - strtotime($settings['last_dns_check_run'])) / 3600;
$dnsCronStale = $hoursSince > $dnsStaleThreshold;
}
$this->view('settings/index', [
'settings' => $settings,
'appSettings' => $appSettings,
@@ -154,6 +169,8 @@ class SettingsController extends Controller
'cachedUpdateAvailable' => $cachedUpdateAvailable,
'cachedUpdateData' => $cachedUpdateData,
'rollbackAvailable' => $rollbackAvailable,
'domainCronStale' => $domainCronStale,
'dnsCronStale' => $dnsCronStale,
'title' => 'Settings'
]);
}
@@ -320,6 +337,13 @@ class SettingsController extends Controller
$this->settingModel->setValue('registration_enabled', $registrationEnabled);
$this->settingModel->setValue('require_email_verification', $requireEmailVerification);
// Update domain view template
$viewTemplate = trim($_POST['domain_view_template'] ?? 'detailed');
if (!in_array($viewTemplate, ['legacy', 'detailed'])) {
$viewTemplate = 'detailed';
}
$this->settingModel->setValue('domain_view_template', $viewTemplate);
$_SESSION['success'] = 'Application settings updated successfully';
$this->redirect('/settings#app');

View File

@@ -205,6 +205,7 @@ class TwoFactorController extends Controller
$this->view('2fa/verify', [
'user' => $user,
'canSendEmailCode' => !empty($user['email_verified']),
'title' => 'Two-Factor Authentication'
]);
}

View File

@@ -511,7 +511,10 @@ class EmailHelper
public static function getEmailSubject(array $data): string
{
if (isset($data['domain'])) {
$daysLeft = $data['days_left'];
$daysLeft = $data['days_left'] ?? null;
if ($daysLeft === null) {
return "⚠️ Domain Expiration Alert: {$data['domain']}";
}
if ($daysLeft <= 0) {
return "🚨 URGENT: Domain {$data['domain']} has EXPIRED";
}

216
app/Models/DnsRecord.php Normal file
View 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;
}
}

View File

@@ -259,6 +259,24 @@ class Domain extends Model
return $stats;
}
/**
* Get effective status for sorting (active + daysLeft within threshold = expiring_soon)
*/
private static function getEffectiveStatusForSort(array $domain, int $expiringThreshold): string
{
$status = $domain['status'] ?? '';
if ($status === 'inactive') {
return 'inactive';
}
if ($status === 'active' && !empty($domain['expiration_date'])) {
$daysLeft = (int) floor((strtotime($domain['expiration_date']) - time()) / 86400);
if ($daysLeft <= $expiringThreshold && $daysLeft >= 0) {
return 'expiring_soon';
}
}
return $status ?: 'error';
}
/**
* Get filtered, sorted, and paginated domains
*/
@@ -332,10 +350,33 @@ class Domain extends Model
$totalDomains = count($domains);
// Apply sorting
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
usort($domains, function($a, $b) use ($sortBy, $sortOrder, $expiringThreshold) {
$aVal = $a[$sortBy] ?? '';
$bVal = $b[$sortBy] ?? '';
// When sorting by status: use effective status (active + daysLeft<=threshold = expiring_soon) and logical priority order
if ($sortBy === 'status') {
$aVal = self::getEffectiveStatusForSort($a, $expiringThreshold);
$bVal = self::getEffectiveStatusForSort($b, $expiringThreshold);
$priority = [
'expired' => 1,
'redemption_period' => 2,
'pending_delete' => 3,
'expiring_soon' => 4,
'active' => 5,
'available' => 6,
'error' => 7,
'inactive' => 8,
];
$aOrder = $priority[$aVal] ?? 99;
$bOrder = $priority[$bVal] ?? 99;
$comparison = $aOrder <=> $bOrder;
if ($comparison === 0) {
$comparison = strcasecmp($a['domain_name'] ?? '', $b['domain_name'] ?? '');
}
return $sortOrder === 'desc' ? -$comparison : $comparison;
}
$comparison = strcasecmp($aVal, $bVal);
return $sortOrder === 'desc' ? -$comparison : $comparison;
});

View File

@@ -114,13 +114,14 @@ class TldRegistry extends Model
*/
public function search(string $search): array
{
$search = '%' . $search . '%';
$tldNorm = strtolower(trim(trim($search), '.'));
$suffix = '%.' . $tldNorm;
$sql = "SELECT * FROM tld_registry
WHERE (LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))
WHERE (LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)
ORDER BY tld ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute([$search, $search, $search]);
$stmt->execute([$tldNorm, $suffix]);
return $stmt->fetchAll();
}
@@ -144,11 +145,12 @@ class TldRegistry extends Model
$whereConditions = [];
$params = [];
// Search filter
// Search filter: exact match OR TLDs ending with .{search} (e.g. "za" -> .za, .co.za, .net.za)
if (!empty($search)) {
$searchParam = '%' . $search . '%';
$whereConditions[] = "(LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))";
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
$tldNorm = strtolower(trim(trim($search), '.'));
$suffix = '%.' . $tldNorm;
$whereConditions[] = "(LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)";
$params = array_merge($params, [$tldNorm, $suffix]);
}
// Status filter

View File

@@ -73,12 +73,12 @@ class DiscordChannel implements NotificationChannelInterface
],
[
'name' => 'Days Left',
'value' => $data['days_left'],
'value' => (string) ($data['days_left'] ?? 'N/A'),
'inline' => true
],
[
'name' => 'Expiration Date',
'value' => $data['expiration_date'],
'value' => $data['expiration_date'] ?? 'N/A',
'inline' => true
]
];

766
app/Services/DnsService.php Normal file
View 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);
}
}

View File

@@ -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)
*/

View File

@@ -848,7 +848,7 @@ class WhoisService
// Check if domain is not found/available
$whoisDataLower = strtolower($whoisData);
// More specific patterns to avoid false positives
// Exact line match (original patterns)
if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) {
@@ -856,6 +856,12 @@ class WhoisService
$data['registrar'] = 'Not Registered';
return $data;
}
// Broader patterns for formats that don't match exact line (e.g. .rs "%ERROR:103: Domain is not registered", .io "Domain not found.", .co "The queried object does not exist: DOMAIN NOT FOUND")
if (preg_match('/domain\s+is\s+not\s+registered|domain\s+not\s+found\.?(\s|$)|queried\s+object\s+does\s+not\s+exist|%error:\d+:\s*domain/i', $whoisDataLower)) {
$data['status'][] = 'AVAILABLE';
$data['registrar'] = 'Not Registered';
return $data;
}
// Special handling for .eu domains that are available
// EURid returns "Status: AVAILABLE" in a specific format
@@ -1196,7 +1202,10 @@ class WhoisService
}
// No expiration date and no clear status indicators
// This should only happen for newly added domains or error cases
// Fallback: registrar "Not Registered" means domain is available (e.g. parseWhoisData set it but status was lost in merge)
if (!empty($whoisData['registrar']) && $whoisData['registrar'] === 'Not Registered') {
return 'available';
}
// Return error to avoid incorrectly marking registered domains as available
return 'error';
}

View File

@@ -140,15 +140,25 @@
</div>
<!-- Active Monitoring -->
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700">
<label class="flex items-center cursor-pointer">
<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-start cursor-pointer">
<input type="checkbox"
name="is_active"
{{ 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">
<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>
</label>
</div>
@@ -177,7 +187,7 @@
<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>
</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() }}
<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">

View File

@@ -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">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ 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">
<i class="fas fa-sync-alt"></i>

View 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>

View 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>

View 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 %}

View 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 &mdash; 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>

View 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>

View 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>

View 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 %}

View File

@@ -50,11 +50,11 @@
{% endfor %}
</div>
<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() }}
<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>
Refresh
Refresh WHOIS
</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]">

View File

@@ -238,6 +238,7 @@
<!-- Add Domain Button -->
<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">
{{ csrf_field()|raw }}
<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>
<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">

View File

@@ -8,6 +8,7 @@
{% set currentNotificationDays = settings.notification_days_before|default('30,15,7,3,1') %}
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
{% 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 updateChannel = updateSettings.update_channel|default('stable') %}
@@ -148,6 +149,28 @@
</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 &mdash; Classic single-page layout
</option>
<option value="detailed" {{ settings.domain_view_template == 'detailed' or settings.domain_view_template is not defined ? 'selected' : '' }}>
Detailed &mdash; 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">
<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>
@@ -464,25 +487,7 @@
</option>
{% endfor %}
</select>
</div>
<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>
<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>
</div>
</div>
@@ -772,27 +777,99 @@
</div>
<div class="p-6 space-y-6">
<!-- Cron Command -->
<!-- Cron Commands -->
<div>
<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>
Cron Job Command
Cron Job Commands
</h4>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
<code>php cron/check_domains.php</code>
<div class="space-y-2">
<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>
</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>
<!-- Crontab Entry -->
<!-- Crontab Entries -->
<div>
<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>
Recommended Crontab Entry
Recommended Crontab Entries
</h4>
<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>
<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">
<code>0 */{{ currentCheckInterval }} * * * php {{ cronPath }}</code>
</div>
</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>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the path to match your server installation</p>
</div>
<!-- Log Files -->
@@ -804,17 +881,24 @@
<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>
<p class="text-sm font-medium text-gray-900 dark:text-white">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-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">WHOIS / expiration check logs</p>
</div>
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/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>
<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>
<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>
</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>

View File

@@ -213,7 +213,7 @@
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ csrf_field() }}
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
<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">
<i class="fas fa-eye mr-1"></i> View
</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() }}
<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>
</form>
</div>

856
cron/check_dns.php Normal file
View 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");
}

View File

@@ -141,6 +141,7 @@ CREATE TABLE IF NOT EXISTS domains (
updated_date DATE,
abuse_email VARCHAR(255),
last_checked TIMESTAMP NULL,
dns_last_checked TIMESTAMP NULL,
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active',
whois_data JSON,
notes TEXT,
@@ -341,6 +342,32 @@ CREATE TABLE IF NOT EXISTS tld_import_logs (
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- DNS MONITORING
-- =====================================================
-- DNS records table for tracking DNS record changes
CREATE TABLE IF NOT EXISTS dns_records (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_id INT NOT NULL,
record_type VARCHAR(10) NOT NULL COMMENT 'A, AAAA, MX, TXT, NS, CNAME, SOA',
host VARCHAR(255) NOT NULL DEFAULT '@',
value TEXT NOT NULL,
ttl INT NULL,
priority INT NULL COMMENT 'MX priority',
is_cloudflare BOOLEAN DEFAULT FALSE,
raw_data JSON NULL COMMENT 'Full record data from dns_get_record()',
first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
INDEX idx_domain_id (domain_id),
INDEX idx_record_type (record_type),
INDEX idx_domain_type (domain_id, record_type),
INDEX idx_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- SYSTEM SETTINGS
-- =====================================================
@@ -397,6 +424,13 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
-- User isolation settings
('user_isolation_mode', 'shared', 'string', 'User data visibility mode: shared (all users see all data) or isolated (users see only their own data)'),
-- Domain view settings
('domain_view_template', 'detailed', 'string', 'Domain view template: detailed or default'),
-- DNS monitoring settings
('dns_check_interval_hours', '24', 'string', 'DNS record check interval in hours'),
('last_dns_check_run', NULL, 'datetime', 'Last time DNS cron job ran'),
-- Update system settings
('update_channel', 'stable', 'string', 'Update channel: stable (releases only) or latest (releases + hotfixes)'),
('update_badge_enabled', '1', 'string', 'Show update available badge in top menu when an update is available (1=yes, 0=no)')

View 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;

View File

@@ -84,7 +84,9 @@ $router->get('/domains/{id}', [DomainController::class, 'show']);
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']);
$router->post('/domains/{id}/update', [DomainController::class, 'update']);
$router->post('/domains/{id}/update-notes', [DomainController::class, 'updateNotes']);
$router->post('/domains/{id}/refresh', [DomainController::class, 'refresh']);
$router->post('/domains/{id}/refresh-whois', [DomainController::class, 'refreshWhois']);
$router->post('/domains/{id}/refresh-dns', [DomainController::class, 'refreshDns']);
$router->post('/domains/{id}/refresh-all', [DomainController::class, 'refreshAll']);
$router->post('/domains/{id}/delete', [DomainController::class, 'delete']);
// Notification Groups