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.
217 lines
7.4 KiB
PHP
217 lines
7.4 KiB
PHP
<?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;
|
|
}
|
|
}
|