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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user