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.
This commit is contained in:
Hosteroid
2026-03-10 22:54:28 +02:00
parent 5365af00fd
commit a265a58456
46 changed files with 3130 additions and 1494 deletions

View File

@@ -80,10 +80,12 @@ class DnsRecord extends Model
/**
* Save a snapshot of DNS records for a domain.
* Updates existing records, inserts new ones, removes stale ones.
* Updates existing records, inserts new ones.
* Only auto-removes stale records whose source is 'discovered' — manual and imported records are preserved.
*
* @return array{added: int, updated: int, removed: int}
*/
public function saveSnapshot(int $domainId, array $groupedRecords): array
public function saveSnapshot(int $domainId, array $groupedRecords, string $source = 'discovered'): array
{
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
$now = date('Y-m-d H:i:s');
@@ -108,27 +110,26 @@ class DnsRecord extends Model
$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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, 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]);
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $source, $now, $now, $now, $now]);
$seenIds[] = (int)$this->db->lastInsertId();
$stats['added']++;
}
}
}
// Remove records that no longer exist
// Only auto-remove stale discovered records — manual/imported records are never auto-deleted
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})"
"DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered' 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 = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ? AND source = 'discovered'");
$deleteStmt->execute([$domainId]);
$stats['removed'] = $deleteStmt->rowCount();
}
@@ -213,4 +214,82 @@ class DnsRecord extends Model
return $grouped;
}
/**
* Delete a single DNS record belonging to a domain.
*/
public function deleteRecord(int $id, int $domainId): bool
{
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE id = ? AND domain_id = ?");
$stmt->execute([$id, $domainId]);
return $stmt->rowCount() > 0;
}
/**
* Bulk delete DNS records belonging to a domain.
*
* @return int Number of records deleted
*/
public function bulkDeleteRecords(array $ids, int $domainId): int
{
if (empty($ids)) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $this->db->prepare(
"DELETE FROM dns_records WHERE domain_id = ? AND id IN ({$placeholders})"
);
$stmt->execute(array_merge([$domainId], $ids));
return $stmt->rowCount();
}
/**
* Add a single manually-created DNS record.
*/
public function addManualRecord(int $domainId, string $type, string $host, string $value, ?int $ttl = null, ?int $priority = null): int
{
$now = date('Y-m-d H:i:s');
$stmt = $this->db->prepare(
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, source, first_seen_at, last_seen_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 'manual', ?, ?, ?, ?)"
);
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $now, $now, $now, $now]);
return (int)$this->db->lastInsertId();
}
/**
* Bulk-insert records from a zone file import.
*
* @return int Number of records imported
*/
public function addImportedRecords(int $domainId, array $groupedRecords): int
{
$now = date('Y-m-d H:i:s');
$count = 0;
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) {
continue;
}
$stmt = $this->db->prepare(
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, source, first_seen_at, last_seen_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?, ?)"
);
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
$count++;
}
}
return $count;
}
}

View File

@@ -282,6 +282,66 @@ class Tag extends Model
}
}
/**
* Remove custom (non-global) tags from a domain that don't belong to the specified user.
* Global tags (user_id IS NULL) are preserved.
*/
public function removeOtherUserTagsFromDomain(int $domainId, int $keepUserId): int
{
$sql = "DELETE dt FROM domain_tags dt
JOIN tags t ON dt.tag_id = t.id
WHERE dt.domain_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$domainId, $keepUserId]);
$affected = $stmt->rowCount();
if ($affected > 0) {
$this->updateAllUsageCounts();
}
return $affected;
}
/**
* Remove domain_tags for a tag where the domain doesn't belong to the specified user.
*/
public function removeTagFromOtherUserDomains(int $tagId, int $keepUserId): int
{
$sql = "DELETE dt FROM domain_tags dt
JOIN domains d ON dt.domain_id = d.id
WHERE dt.tag_id = ? AND d.user_id != ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$tagId, $keepUserId]);
$affected = $stmt->rowCount();
if ($affected > 0) {
$this->updateUsageCount($tagId);
}
return $affected;
}
/**
* Remove custom (non-global) tags from all domains in a notification group
* that don't belong to the specified user.
*/
public function removeOtherUserTagsFromDomainsByGroup(int $groupId, int $keepUserId): int
{
$sql = "DELETE dt FROM domain_tags dt
JOIN tags t ON dt.tag_id = t.id
JOIN domains d ON dt.domain_id = d.id
WHERE d.notification_group_id = ? AND t.user_id IS NOT NULL AND t.user_id != ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$groupId, $keepUserId]);
$affected = $stmt->rowCount();
if ($affected > 0) {
$this->updateAllUsageCounts();
}
return $affected;
}
/**
* Get available colors for tags
*/