Files
domnitor/app/Services/DnsService.php
Hosteroid 8559e903b9 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.
2026-03-08 14:32:05 +02:00

767 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}