Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Content\DnsManager;
use OpenNIT\Core\Database;
use OpenNIT\Dns\AgentScripts;
use OpenNIT\Dns\RecordDiff;
/**
* Deckt die drei Punkte ab, mit denen die Beta-Kennzeichnung des DNS-Imports
* faellt: Aenderungsverlauf je Zone, weitere Quellen neben Windows-DNS und der
* Abgleich mit der IP-Verwaltung.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$src = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);
/** Kurzform fuer einen Record. */
$rec = static fn (string $name, string $typ, string $daten, ?int $ttl = 3600): array =>
['name' => $name, 'type' => $typ, 'data' => $daten, 'ttl' => $ttl];
// ----------------------------------------------------------------
// Aenderungsverlauf
// ----------------------------------------------------------------
test('Ein unveraenderter Satz erzeugt keine Eintraege', function () use ($rec) {
// Der Agent laeuft regelmaessig; ohne das stuende nach jedem Lauf
// die ganze Zone im Verlauf.
$a = [$rec('www', 'A', '10.0.0.1'), $rec('@', 'MX', '10 mail')];
// Auch in anderer Reihenfolge.
$b = [$rec('@', 'MX', '10 mail'), $rec('www', 'A', '10.0.0.1')];
assertSame([], RecordDiff::vergleiche($a, $b));
});
test('Ein umgezogener Record wird als Aenderung gefuehrt', function () use ($rec) {
// Nicht als Loeschung plus Neuanlage - das waere fuer den Leser
// dieselbe Information in unbrauchbarer Form.
$diff = RecordDiff::vergleiche([$rec('www', 'A', '10.0.0.1')], [$rec('www', 'A', '10.0.0.2')]);
assertSame(1, count($diff));
assertSame('changed', $diff[0]['change_type']);
assertSame('10.0.0.1', $diff[0]['old_data']);
assertSame('10.0.0.2', $diff[0]['new_data']);
});
test('Eine reine TTL-Aenderung wird erkannt', function () use ($rec) {
$diff = RecordDiff::vergleiche(
[$rec('www', 'A', '10.0.0.1', 3600)],
[$rec('www', 'A', '10.0.0.1', 300)]
);
assertSame(1, count($diff));
assertSame('changed', $diff[0]['change_type']);
assertSame(3600, $diff[0]['old_ttl']);
assertSame(300, $diff[0]['new_ttl']);
});
test('Mehrere Werte eines Namens gelten nicht als staendige Aenderung', function () use ($rec) {
// Mehrere A-Records fuer einen Namen sind ein ueblicher Aufbau. Ein
// Vergleich nur ueber Name und Typ wuerde sie bei jedem Lauf melden.
$vorher = [$rec('lb', 'A', '10.0.0.1', 60), $rec('lb', 'A', '10.0.0.2', 60)];
$nachher = [$rec('lb', 'A', '10.0.0.1', 60), $rec('lb', 'A', '10.0.0.2', 60), $rec('lb', 'A', '10.0.0.3', 60)];
$diff = RecordDiff::vergleiche($vorher, $nachher);
assertSame(1, count($diff), 'Mehr als der neue Wert gemeldet');
assertSame('added', $diff[0]['change_type']);
assertSame('10.0.0.3', $diff[0]['new_data']);
});
test('Hinzugefuegt und entfernt werden benannt', function () use ($rec) {
$diff = RecordDiff::vergleiche(
[$rec('alt', 'A', '10.0.0.9'), $rec('bleibt', 'A', '10.0.0.1')],
[$rec('bleibt', 'A', '10.0.0.1'), $rec('neu', 'CNAME', 'bleibt')]
);
$arten = array_column($diff, 'change_type');
sort($arten);
assertSame(['added', 'removed'], $arten);
});
test('Ein doppelter identischer Record erzeugt keinen Unterschied', function () use ($rec) {
// Zweimal derselbe Wert ist in einer Zone bedeutungslos; sonst
// erschiene beim naechsten Lauf eine Aenderung, die niemand vornahm.
assertSame([], RecordDiff::vergleiche(
[$rec('www', 'A', '10.0.0.1', 60)],
[$rec('www', 'A', '10.0.0.1', 60), $rec('www', 'A', '10.0.0.1', 60)]
));
});
test('Der Erstimport einer Zone erzeugt keinen Verlauf', function () use ($src) {
// Sonst stuenden beim ersten Lauf saemtliche Records als
// "hinzugefuegt" im Verlauf.
$code = $src('/src/Content/DnsManager.php');
$von = strpos($code, 'public function importZone');
$block = substr($code, (int)$von, 4000);
assertTrue(str_contains($block, '$istErstimport = $vorher === []'), 'Erstimport nicht erkannt');
assertTrue(str_contains($block, '$istErstimport ? [] : RecordDiff::vergleiche'), 'Erstimport erzeugt Verlauf');
});
test('Verglichen wird gegen dieselbe Form, die gespeichert wird', function () use ($src) {
// Sonst scheitert der Vergleich an Schreibweisen (Leerzeichen,
// Gross-/Kleinschreibung des Typs) statt an echten Aenderungen.
$code = $src('/src/Content/DnsManager.php');
$von = strpos($code, 'public function importZone');
$block = substr($code, (int)$von, 4000);
$norm = strpos($block, '$normalisiert[] = [');
$insert = strpos($block, "INSERT INTO dns_records");
$diff = strpos($block, 'RecordDiff::vergleiche');
assertTrue($norm !== false && $norm < $insert, 'Erst speichern, dann normalisieren');
assertTrue($diff > $insert, 'Vergleich vor dem Speichern');
assertTrue(str_contains($block, "\$rec['name'], \$rec['type'], \$rec['data'], \$rec['ttl']"), 'Gespeichert wird etwas anderes');
});
test('Der Verlauf wird begrenzt aufbewahrt', function () use ($src) {
$code = $src('/src/Content/DnsManager.php');
$von = strpos($code, 'private function raeumeVerlaufAuf');
$block = substr($code, (int)$von, 700);
assertTrue(str_contains($block, 'if ($tage <= 0)'), '0 schaltet die Begrenzung nicht ab');
assertTrue(str_contains($block, 'DELETE FROM dns_zone_changes'), 'Es wird nichts entfernt');
});
// ----------------------------------------------------------------
// Weitere Quellen
// ----------------------------------------------------------------
test('Die Herkunft einer Zone wird gefuehrt', function () use ($src) {
assertSame(['windows', 'bind', 'powerdns', 'agent'], DnsManager::QUELLEN);
$code = $src('/src/Content/DnsManager.php');
$von = strpos($code, 'public function importZone');
$block = substr($code, (int)$von, 2000);
// Eine unbekannte Angabe faellt auf 'agent' zurueck, statt gespeichert
// zu werden - sonst waere die Spalte nicht auswertbar.
assertTrue(str_contains($block, 'in_array($quelle, self::QUELLEN, true)'), 'Quelle ungeprueft');
assertTrue(str_contains($block, "\$quelle = 'agent'"), 'Kein Ausweichwert');
assertTrue(str_contains($block, 'source = VALUES(source)'), 'Quelle wird nie aktualisiert');
});
test('Beide neuen Agenten sind gueltiges Python', function () use ($wurzel) {
// Ein Syntaxfehler faellt sonst erst auf dem Nameserver auf.
$tmp = sys_get_temp_dir() . '/opennit-dns-agent-test';
@mkdir($tmp, 0700, true);
foreach (['bind' => AgentScripts::bind('https://opennit.example'),
'powerdns' => AgentScripts::powerdns('https://opennit.example')] as $name => $inhalt) {
$pfad = $tmp . '/' . $name . '.py';
file_put_contents($pfad, $inhalt);
// Ohne Python im Bild bleibt wenigstens die Platzhalter-Prüfung.
@exec('python3 -m py_compile ' . escapeshellarg($pfad) . ' 2>&1', $ausgabe, $code);
if ($code === 127) {
continue; // kein Python vorhanden
}
assertSame(0, $code, "Syntaxfehler im {$name}-Agenten: " . implode("\n", $ausgabe));
}
@array_map('unlink', glob($tmp . '/*') ?: []);
});
test('Die Agenten tragen die eigene Adresse und die Quelle', function () {
$bind = AgentScripts::bind('https://opennit.example/');
$pdns = AgentScripts::powerdns('https://opennit.example/');
foreach (['bind' => $bind, 'powerdns' => $pdns] as $quelle => $skript) {
assertFalse(str_contains($skript, '__SERVER_URL__'), "Platzhalter blieb stehen: {$quelle}");
assertTrue(str_contains($skript, 'https://opennit.example'), "Adresse fehlt: {$quelle}");
// Ein Trailing-Slash wuerde zu einer doppelten Schraegstrich-URL führen.
assertFalse(str_contains($skript, 'https://opennit.example/\''), "Schraegstrich nicht entfernt: {$quelle}");
assertTrue(str_contains($skript, "'source': '{$quelle}'"), "Quelle nicht gesetzt: {$quelle}");
// Alle Agenten liefern an denselben Endpunkt.
assertTrue(str_contains($skript, '/api/ex/dns/zones'), "Falscher Endpunkt: {$quelle}");
}
});
test('Ein leerer Abruf loescht nicht den Bestand', function () {
// Prune wird nur mitgeschickt, wenn wirklich Zonen gelesen wurden.
foreach ([AgentScripts::bind('https://x'), AgentScripts::powerdns('https://x')] as $skript) {
assertTrue(
str_contains($skript, 'damit nichts geloescht wird'),
'Leerer Abruf wird gesendet'
);
}
});
test('Die Agenten sind im Backend erreichbar', function () use ($src) {
$routen = $src('/src/Core/Application.php');
foreach (['download-windows', 'download-bind', 'download-powerdns'] as $pfad) {
assertTrue(str_contains($routen, "'/admin/dns-agent/{$pfad}'"), "Route fehlt: {$pfad}");
}
$tpl = $src('/templates/admin/dns-agent/index.php');
foreach (['download-bind', 'download-powerdns'] as $pfad) {
assertTrue(str_contains($tpl, $pfad), "Kein Download-Knopf: {$pfad}");
}
});
// ----------------------------------------------------------------
// Abgleich mit der IP-Verwaltung
// ----------------------------------------------------------------
test('Freie Adressen gelten nicht als Auffaelligkeit', function () use ($src) {
// In der Richtung IPAM -> DNS: eine freie Adresse soll gerade keinen
// Namen tragen. Beide Abfragen dieser Richtung - Liste und Zaehlung -
// nehmen sie aus.
$code = $src('/src/Dns/IpamComparison.php');
assertSame(2, substr_count($code, "a.status <> 'free'"), 'Freie Adressen nicht in beiden Abfragen ausgenommen');
// In der Gegenrichtung DNS -> IPAM aber bewusst nicht: eine als frei
// gefuehrte Adresse ist der IP-Verwaltung trotzdem bekannt und damit
// keine Auffaelligkeit.
$von = strpos($code, 'public function namenOhneAdresse');
$block = substr($code, (int)$von, 1200);
assertFalse(str_contains($block, "a.status"), 'Bekannte Adresse faelschlich als unbekannt gemeldet');
assertTrue(str_contains($block, 'NOT EXISTS'), 'Falsche Pruefung');
});
test('Verglichen werden nur Records, die eine Adresse tragen', function () use ($src) {
$code = $src('/src/Dns/IpamComparison.php');
assertTrue(str_contains($code, "ADRESS_TYPEN = ['A', 'AAAA']"), 'Falsche Typen');
assertTrue(str_contains($code, 'MAX_TREFFER'), 'Keine Obergrenze');
});
test('Ohne IP-Verwaltung bleibt der Abgleich still', function () use ($src) {
$code = $src('/src/Dns/IpamComparison.php');
assertSame(3, substr_count($code, 'catch (\Throwable)'), 'Fehlende Tabellen schlagen durch');
// Die Seite erscheint nur bei aktivem Modul.
$ctrl = $src('/src/Controllers/DnsController.php');
assertTrue(str_contains($ctrl, "config('modules.ipam')"), 'Modul ungeprueft');
$tpl = $src('/templates/dns/ipam_check.php');
assertTrue(str_contains($tpl, 'ist nicht aktiv'), 'Kein Hinweis bei abgeschaltetem Modul');
});
test('Der Abgleich aendert nichts', function () use ($src) {
// Beide Richtungen koennen voellig in Ordnung sein - eine automatische
// Korrektur waere hier falsch.
$code = $src('/src/Dns/IpamComparison.php');
foreach (['INSERT', 'UPDATE', 'DELETE'] as $wort) {
assertFalse((bool)preg_match('/\b' . $wort . '\s+(INTO|FROM|ipam|dns)/i', $code), "Schreibender Zugriff: {$wort}");
}
$tpl = $src('/templates/dns/ipam_check.php');
assertFalse(str_contains($tpl, '<form'), 'Die Seite bietet eine Aktion an');
});
// ----------------------------------------------------------------
// Zone entfernen
// ----------------------------------------------------------------
test('Eine Zone laesst sich samt Records und Verlauf entfernen', function () {
$db = new class extends Database {
public array $sql = [];
public function __construct() {}
public function fetchOne(string $sql, array $params = []): ?array
{
$this->sql[] = $sql;
return ['id' => 7, 'name' => 'alt.example', 'dns_server' => 'dns01', 'source' => 'windows'];
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
$this->sql[] = $sql;
return 12;
}
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->sql[] = $sql;
return new \PDOStatement();
}
};
$zone = (new DnsManager($db))->deleteZone(7);
assertSame('alt.example', $zone['name']);
// Fuer das Audit muss erkennbar bleiben, wie viel mit der Zone ging.
assertSame(12, $zone['record_count']);
assertTrue(
(bool)preg_grep('/DELETE FROM dns_zones WHERE id = \?/', $db->sql),
'Zone nicht geloescht'
);
// Records und Verlauf haengen per Fremdschluessel an der Zone.
assertFalse((bool)preg_grep('/DELETE FROM dns_records/', $db->sql), 'Records einzeln geloescht');
});
test('Eine unbekannte Zone wird nicht geloescht', function () {
$db = new class extends Database {
public array $sql = [];
public function __construct() {}
public function fetchOne(string $sql, array $params = []): ?array { return null; }
public function execute(string $sql, array $params = []): \PDOStatement
{
$this->sql[] = $sql;
return new \PDOStatement();
}
};
assertSame(null, (new DnsManager($db))->deleteZone(999));
assertSame([], $db->sql, 'Es wurde trotzdem geschrieben');
});
test('Zonen entfernen bleibt der Verwaltung vorbehalten', function () use ($src) {
$ctrl = $src('/src/Controllers/DnsController.php');
assertTrue(str_contains($ctrl, "can('manage_dns'"), 'Keine Rechtepruefung');
assertTrue(str_contains($ctrl, "'dns_zone_deleted'"), 'Loeschung ohne Audit-Eintrag');
// Die Route selbst ist ebenfalls gebunden - nicht nur der Controller.
$app = $src('/src/Core/Application.php');
assertTrue(
(bool)preg_match("#delete\('/dns/zones/\{id\}'.*'capability' => 'manage_dns'#", $app),
'Route ungeschuetzt'
);
// Der Knopf erscheint nur, wenn die Person die Zone auch entfernen darf.
$tpl = $src('/templates/dns/zone_show.php');
assertTrue(str_contains($tpl, 'name="_method" value="DELETE"'), 'Kein DELETE-Formular');
assertTrue(str_contains($tpl, 'name="_csrf"'), 'Formular ohne CSRF-Feld');
assertTrue(str_contains($tpl, "!empty(\$can_manage)"), 'Knopf ungegated');
});
// ----------------------------------------------------------------
// Migration
// ----------------------------------------------------------------
test('Die Migration ist wiederholbar', function () use ($wurzel) {
$sql = (string)file_get_contents($wurzel . '/database/migrations/217_dns_zone_changes.sql');
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `dns_zone_changes`'), 'Tabelle nicht bedingt');
assertTrue(str_contains($sql, 'INSERT IGNORE'), 'Nicht wiederholbarer Datensatz');
assertFalse(str_contains($sql, 'TRUNCATE'), 'Zerstoerendes Statement');
// Der Verlauf verschwindet mit seiner Zone.
assertTrue(str_contains($sql, 'ON DELETE CASCADE'), 'Verwaiste Eintraege moeglich');
});
};