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:
216
app/Models/DnsRecord.php
Normal file
216
app/Models/DnsRecord.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Core\Model;
|
||||
|
||||
class DnsRecord extends Model
|
||||
{
|
||||
protected static string $table = 'dns_records';
|
||||
|
||||
/**
|
||||
* Get all DNS records for a domain, grouped by type
|
||||
*/
|
||||
public function getByDomainGrouped(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC, priority ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$type = $row['record_type'];
|
||||
if (!isset($grouped[$type])) {
|
||||
$grouped[$type] = [];
|
||||
}
|
||||
$grouped[$type][] = $row;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all DNS records for a domain (flat list)
|
||||
*/
|
||||
public function getByDomain(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count DNS records for a domain
|
||||
*/
|
||||
public function countByDomain(int $domainId): int
|
||||
{
|
||||
$stmt = $this->db->prepare("SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND record_type != 'SOA'");
|
||||
$stmt->execute([$domainId]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct non-root host labels for a domain.
|
||||
* Used to preserve previously discovered subdomains across refreshes.
|
||||
*/
|
||||
public function getDistinctHosts(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT DISTINCT host FROM dns_records WHERE domain_id = ? AND host != '@'"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return array_column($stmt->fetchAll(), 'host');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain has any Cloudflare-proxied records
|
||||
*/
|
||||
public function hasCloudflare(int $domainId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND is_cloudflare = 1"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a snapshot of DNS records for a domain.
|
||||
* Updates existing records, inserts new ones, removes stale ones.
|
||||
* @return array{added: int, updated: int, removed: int}
|
||||
*/
|
||||
public function saveSnapshot(int $domainId, array $groupedRecords): array
|
||||
{
|
||||
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$seenIds = [];
|
||||
|
||||
foreach ($groupedRecords as $type => $records) {
|
||||
foreach ($records as $record) {
|
||||
$host = $record['host'] ?? '@';
|
||||
$value = $record['value'] ?? '';
|
||||
$ttl = $record['ttl'] ?? null;
|
||||
$priority = $record['priority'] ?? null;
|
||||
$isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0;
|
||||
$rawData = isset($record['raw']) ? json_encode($record['raw']) : null;
|
||||
|
||||
$existing = $this->findExisting($domainId, $type, $host, $value, $priority);
|
||||
|
||||
if ($existing) {
|
||||
$this->db->prepare(
|
||||
"UPDATE dns_records SET ttl = ?, is_cloudflare = ?, raw_data = ?, last_seen_at = ?, updated_at = ? WHERE id = ?"
|
||||
)->execute([$ttl, $isCloudflare, $rawData, $now, $now, $existing['id']]);
|
||||
$seenIds[] = $existing['id'];
|
||||
$stats['updated']++;
|
||||
} else {
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
|
||||
$seenIds[] = (int)$this->db->lastInsertId();
|
||||
$stats['added']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove records that no longer exist
|
||||
if (!empty($seenIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
|
||||
$deleteStmt = $this->db->prepare(
|
||||
"DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})"
|
||||
);
|
||||
$deleteStmt->execute(array_merge([$domainId], $seenIds));
|
||||
$stats['removed'] = $deleteStmt->rowCount();
|
||||
} else {
|
||||
// No records found at all — remove everything
|
||||
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
||||
$deleteStmt->execute([$domainId]);
|
||||
$stats['removed'] = $deleteStmt->rowCount();
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing record by its natural key
|
||||
*/
|
||||
private function findExisting(int $domainId, string $type, string $host, string $value, ?int $priority): ?array
|
||||
{
|
||||
if ($priority !== null) {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority = ? LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, $type, $host, $value, $priority]);
|
||||
} else {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority IS NULL LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$domainId, $type, $host, $value]);
|
||||
}
|
||||
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all DNS records for a domain
|
||||
*/
|
||||
public function deleteByDomain(int $domainId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
|
||||
return $stmt->execute([$domainId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get record counts grouped by type for a domain
|
||||
*/
|
||||
public function getCountsByType(int $domainId): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT record_type, COUNT(*) as count FROM dns_records WHERE domain_id = ? GROUP BY record_type ORDER BY record_type"
|
||||
);
|
||||
$stmt->execute([$domainId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[$row['record_type']] = (int)$row['count'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get previous snapshot as grouped records (for diff comparison).
|
||||
* Reconstructs the same format that DnsService::lookup() returns.
|
||||
*/
|
||||
public function getPreviousSnapshot(int $domainId): array
|
||||
{
|
||||
$records = $this->getByDomainGrouped($domainId);
|
||||
$grouped = [];
|
||||
|
||||
foreach ($records as $type => $rows) {
|
||||
$grouped[$type] = [];
|
||||
foreach ($rows as $row) {
|
||||
$entry = [
|
||||
'host' => $row['host'],
|
||||
'value' => $row['value'],
|
||||
'ttl' => $row['ttl'] ? (int)$row['ttl'] : null,
|
||||
];
|
||||
if ($row['priority'] !== null) {
|
||||
$entry['priority'] = (int)$row['priority'];
|
||||
}
|
||||
if ($row['is_cloudflare']) {
|
||||
$entry['is_cloudflare'] = true;
|
||||
}
|
||||
$grouped[$type][] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user