Files
domnitor/cron/discover_dns.php

226 lines
7.9 KiB
PHP
Raw Normal View History

Enhance DNS discovery, validation & transfers Add comprehensive DNS management and input validation, plus safer transfer and logging behavior. - Add CronHelper utilities for cron scripts and unify logging/formatting. - Improve InputValidator: sanitizeDomainInput and validateRootDomain (handles multi-level TLDs) and use throughout domain import/create flows to reject subdomains. - DomainController: refactor DNS refresh to support quick/deep discovery (background deep scans), add endpoints to discover, add/delete/bulk-delete DNS records, import BIND zone files, enrich IP metadata via enrichIpDetails, and strengthen bulk import/reporting messages. - DnsRecord model: add source column handling (discovered/manual/imported), avoid auto-deleting manual/imported records, and add helpers for deleting, bulk deleting, manual adding and importing zone records. - Tag, NotificationGroup and Domain transfer logic: unlink groups when ownership changes, remove tags that belong to other users, add audit logging via Logger and improved bulk transfer reporting. TagController/View: show transferable users for admins and skip global tags on transfer. - Notification channels (Discord, Mattermost, etc.) and EmailHelper: allow explicit subjects and improve payload fields based on notification type. - Add new migration 029_add_dns_record_source.sql and wire it into the installer; update migrations detection. - Add new views/partials for confirm/import/transfer modals, update various domain/group/tag templates, and update cron scripts and routes for discovery. These changes preserve manual/imported DNS records, improve root-domain validation, enable background deep discovery, and add better logging/audit trails for transfers and imports.
2026-03-10 22:54:28 +02:00
#!/usr/bin/env php
<?php
/**
* DNS Discovery Script
*
* Performs DNS subdomain discovery via brute force wordlist, crt.sh Certificate
* Transparency logs, and wildcard detection. Separate from check_dns.php which
* only re-checks existing records.
*
* Usage:
* php cron/discover_dns.php deep scan all domains
* php cron/discover_dns.php --domain example.com deep scan single domain
* php cron/discover_dns.php --domain example.com --quick quick scan single domain
*
* Crontab (optional, weekly): 0 3 * * 0 /usr/bin/php /path/to/project/cron/discover_dns.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use App\Models\Domain;
use App\Models\DnsRecord;
use App\Services\DnsService;
use App\Services\Logger;
use App\Helpers\CronHelper;
use Core\Database;
if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "This script must be run from the command line.\n");
exit(1);
}
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
new Database();
$domainModel = new Domain();
$dnsModel = new DnsRecord();
$dnsService = new DnsService();
$logger = new Logger('dns-discover');
$settingModel = new \App\Models\Setting();
try {
$appSettings = $settingModel->getAppSettings();
date_default_timezone_set($appSettings['app_timezone']);
} catch (\Exception $e) {
date_default_timezone_set('UTC');
}
$logFile = __DIR__ . '/../logs/dns_discover.log';
$cron = new CronHelper($logFile);
// ─── Parse CLI arguments ─────────────────────────────────────────────────────
$targetDomain = null;
$quickMode = false;
for ($i = 1; $i < count($argv); $i++) {
if ($argv[$i] === '--domain' && isset($argv[$i + 1])) {
$targetDomain = $argv[++$i];
} elseif ($argv[$i] === '--quick') {
$quickMode = true;
}
}
// ─── Resolve domains to scan ────────────────────────────────────────────────
if ($targetDomain) {
$domainRow = null;
foreach ($domainModel->all() as $d) {
if (strcasecmp($d['domain_name'], $targetDomain) === 0) {
$domainRow = $d;
break;
}
}
if (!$domainRow) {
$cron->log("Domain not found in database: $targetDomain");
exit(1);
}
$domains = [$domainRow];
} else {
$checkableStatuses = ['active', 'expiring_soon'];
$allDnsEnabled = array_values(array_filter(
$domainModel->where('is_active', 1),
static fn($d): bool => ($d['dns_monitoring_enabled'] ?? 1) == 1
));
$domains = array_values(array_filter($allDnsEnabled, static function ($d) use ($checkableStatuses): bool {
$status = strtolower($d['status'] ?? '');
return in_array($status, $checkableStatuses, true);
}));
}
$modeLabel = $quickMode ? 'Quick Scan' : 'Deep Scan';
$startTime = microtime(true);
$cron->log("=== DNS Discovery ({$modeLabel}) — " . count($domains) . " domain(s) ===");
$stats = [
'scanned' => 0,
'skipped' => 0,
'added' => 0,
'updated' => 0,
'removed' => 0,
'errors' => 0,
];
// ─── crt.sh settings (deep scan only) ──────────────────────────────────────
const CRTSH_TIMEOUT_SECONDS = 1800;
const CRTSH_MAX_SUBDOMAINS = 100;
// ─── Scan loop ───────────────────────────────────────────────────────────────
foreach ($domains as $domain) {
$domainName = $domain['domain_name'];
$domStart = microtime(true);
$cron->log("Discovering: $domainName ($modeLabel)");
try {
if (!CronHelper::hostnameResolves($domainName)) {
$cron->log(" ⏭ Domain does not resolve, skipping");
$cron->logTimeSince($domStart);
$stats['skipped']++;
continue;
}
if ($quickMode) {
$newRecords = $dnsService->quickScan($domainName);
} else {
// Deep scan: gather crt.sh subdomains + existing hosts, then full lookup
$existingHosts = $dnsModel->getDistinctHosts($domain['id']);
$ctSubs = [];
$cron->log(" 🔍 crt.sh: fetching subdomains...");
[$ctSubs, $crtshOk] = $dnsService->fetchCrtshSubdomains(
$domainName,
CRTSH_MAX_SUBDOMAINS,
CRTSH_TIMEOUT_SECONDS,
fn(string $line) => $cron->log("$line")
);
$cron->log(" 🔍 crt.sh: " . count($ctSubs) . " subdomain(s) found");
if ($crtshOk) {
$domainModel->update($domain['id'], [
'crtsh_last_fetched' => date('Y-m-d H:i:s'),
]);
}
$extraSubs = array_unique(array_merge($existingHosts, $ctSubs));
$newRecords = $dnsService->lookup($domainName, $extraSubs, fn(string $msg) => $cron->log(" 🔎 $msg"));
}
$totalRecords = array_sum(array_map('count', $newRecords));
if ($totalRecords === 0) {
$cron->log(" ⚠ No DNS records found");
$stats['errors']++;
$cron->logTimeSince($domStart);
continue;
}
// Enrich IP details
$ips = [];
foreach (['A', 'AAAA'] as $type) {
foreach ($newRecords[$type] ?? [] as $r) {
if (!empty($r['value'])) {
$ips[] = $r['value'];
}
}
}
if (!empty($ips)) {
$ipDetails = $dnsService->lookupIpDetails($ips);
foreach (['A', 'AAAA'] as $type) {
foreach ($newRecords[$type] as &$rec) {
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
}
}
unset($rec);
}
}
$saveStats = $dnsModel->saveSnapshot($domain['id'], $newRecords);
$domainModel->update($domain['id'], ['dns_last_checked' => date('Y-m-d H:i:s')]);
$stats['scanned']++;
$stats['added'] += $saveStats['added'];
$stats['updated'] += $saveStats['updated'];
$stats['removed'] += $saveStats['removed'];
$cron->log("$totalRecords record(s) (added: {$saveStats['added']}, updated: {$saveStats['updated']}, removed: {$saveStats['removed']})");
$logger->info("DNS discovery completed", [
'domain' => $domainName,
'mode' => $quickMode ? 'quick' : 'deep',
'records' => $totalRecords,
'added' => $saveStats['added'],
]);
$cron->logTimeSince($domStart);
} catch (\Exception $e) {
$cron->log(" ✗ Error: " . $e->getMessage());
$cron->logTimeSince($domStart);
$logger->error("DNS discovery failed", [
'domain' => $domainName,
'error' => $e->getMessage(),
]);
$stats['errors']++;
}
}
// ─── Summary ─────────────────────────────────────────────────────────────────
$elapsed = CronHelper::formatElapsedTime(microtime(true) - $startTime);
$cron->log("\n=== DNS Discovery completed ===");
$cron->log("Domains scanned: {$stats['scanned']}");
$cron->log("Skipped: {$stats['skipped']}");
$cron->log("Records added: {$stats['added']}");
$cron->log("Records updated: {$stats['updated']}");
$cron->log("Records removed: {$stats['removed']}");
$cron->log("Errors: {$stats['errors']}");
$cron->log("Execution time: $elapsed");
$cron->log("==========================\n");
exit(0);