diff --git a/app/Services/Proxy/CertificateSweep.php b/app/Services/Proxy/CertificateSweep.php index 7be45ff..5422ca6 100644 --- a/app/Services/Proxy/CertificateSweep.php +++ b/app/Services/Proxy/CertificateSweep.php @@ -22,18 +22,17 @@ class CertificateSweep /** * Die Namen aus der Umgebung in die Liste übernehmen. * - * Anlegen und aktualisieren, nie löschen: ein Name, der aus der Umgebung - * verschwindet, bleibt als Zeile stehen. Sein Zertifikat läuft ja weiter, - * und ein Eintrag, der still verschwindet, ist genau das Gegenteil einer - * Übersicht. - * * @return int wie viele Zeilen neu hinzugekommen sind */ public function sync(): int { + $bekannt = KnownHostnames::all(); + + $this->pruneVanished($bekannt); + $added = 0; - foreach (KnownHostnames::all() as $hostname => $purpose) { + foreach ($bekannt as $hostname => $purpose) { $existing = ProxyHost::query()->where('hostname', $hostname)->first(); if ($existing === null) { @@ -59,6 +58,41 @@ class CertificateSweep return $added; } + /** + * Zeilen, die aus der Umgebung kamen und dort nicht mehr stehen. + * + * Zwei Fälle, und sie brauchen zwei Antworten: + * + * - Die Zeile hatte NIE ein Zertifikat. Dann war sie ein Fehlgriff — so + * kam `127.0.0.1` aus `ADMIN_HOSTS` in die Liste, wo eine nackte IP als + * Wiederzugang stehen darf, für die es aber nie ein Zertifikat geben + * wird. Sie geht weg; ein dauerhaft roter Eintrag, den niemand beheben + * kann, macht die Übersicht unlesbar. + * - Die Zeile HAT ein Zertifikat. Dann wird sie zu einem Eintrag von Hand, + * statt zu verschwinden. Das Zertifikat läuft ja weiter, und etwas mit + * einer laufenden Frist still aus einer Übersicht zu nehmen ist das + * Gegenteil einer Übersicht — der Betreiber entfernt sie selbst. + * + * @param array $bekannt + */ + private function pruneVanished(array $bekannt): void + { + $verschwunden = ProxyHost::query() + ->where('source', 'config') + ->whereNotIn('hostname', array_keys($bekannt)) + ->get(); + + foreach ($verschwunden as $host) { + if ($host->hasCertificate()) { + $host->forceFill(['source' => 'manual'])->save(); + + continue; + } + + $host->delete(); + } + } + /** @return array{checked: int, failing: int} */ public function check(): array { diff --git a/app/Support/KnownHostnames.php b/app/Support/KnownHostnames.php index fc9460d..6a5eb49 100644 --- a/app/Support/KnownHostnames.php +++ b/app/Support/KnownHostnames.php @@ -59,6 +59,27 @@ final class KnownHostnames return; } + // KEINE IP-Adressen. + // + // `ADMIN_HOSTS` darf ausdrücklich eine nackte IP führen — sie ist der + // Wiederzugang zur Konsole, wenn ein Name einmal nicht auflöst. Aber für + // eine IP stellt Let's Encrypt kein Zertifikat aus, also steht sie in + // einer Zertifikatsübersicht nur als dauerhaft roter Eintrag, den + // niemand beheben kann. + // + // Der erste Filter verlangte bloß einen Punkt — `127.0.0.1` hat drei. + // Deshalb hier beides: die Adressprüfung UND die Bedingung, dass die + // letzte Marke keine Zahl ist. Eine Endung ist nie numerisch, und das + // fängt auch die Formen ab, die `FILTER_VALIDATE_IP` durchlässt. + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return; + } + + $letzteMarke = substr($host, strrpos($host, '.') + 1); + if ($letzteMarke === '' || ctype_digit($letzteMarke)) { + return; + } + // Konsole gewinnt gegen öffentlich, wenn ein Name in beiden Listen // steht: die engere Aussage ist die richtige. if (($found[$host] ?? null) !== 'console') { diff --git a/tests/Feature/Admin/ProxyHostsTest.php b/tests/Feature/Admin/ProxyHostsTest.php index 56ae339..897c10d 100644 --- a/tests/Feature/Admin/ProxyHostsTest.php +++ b/tests/Feature/Admin/ProxyHostsTest.php @@ -198,3 +198,51 @@ it('counts what is valid, what expires soon and what is broken', function () { ->and($summary['soon'])->toBe(1) ->and($summary['failing'])->toBe(1); }); + +/** + * Der Fund vom Live-Server: `127.0.0.1` stand in der Übersicht, als Konsole, + * mit „Kein gültiges Zertifikat: Connection refused". + * + * Er kam aus `ADMIN_HOSTS`, wo eine nackte IP als Wiederzugang ausdrücklich + * stehen darf. Der erste Filter verlangte nur einen Punkt — davon hat + * `127.0.0.1` drei. Für eine IP stellt Let's Encrypt kein Zertifikat aus, also + * war das ein dauerhaft roter Eintrag, den niemand beheben kann. + */ +it('keeps IP addresses out of a certificate overview', function () { + config()->set('admin_access.hosts', ['admin.clupilot.com', '127.0.0.1', '2001:db8::1', '198.51.100.7']); + + $gefunden = array_keys(App\Support\KnownHostnames::all()); + + expect($gefunden)->toContain('admin.clupilot.com') + ->and($gefunden)->not->toContain('127.0.0.1') + ->and($gefunden)->not->toContain('198.51.100.7') + ->and($gefunden)->not->toContain('2001:db8::1'); +}); + +/** + * Und die Zeile, die schon in der Datenbank steht, geht beim nächsten Abgleich + * weg — sonst bliebe mein Fehler auf jeder Installation stehen, die einmal + * aktualisiert hat. + */ +it('clears out a config row that should never have been there', function () { + $ip = ProxyHost::create(['hostname' => '127.0.0.1', 'purpose' => 'console', 'source' => 'config']); + + proxyPage(); + + expect(ProxyHost::query()->whereKey($ip->id)->exists())->toBeFalse(); +}); + +/** + * Aber nichts mit laufendem Zertifikat verschwindet still. Es wird zu einem + * Eintrag von Hand, den der Betreiber selbst entfernt — eine Frist, die weiter + * läuft, gehört nicht wortlos aus einer Übersicht genommen. + */ +it('turns a vanished name with a live certificate into a manual entry', function () { + $alt = ProxyHost::create(['hostname' => 'frueher.clupilot.com', 'purpose' => 'public', 'source' => 'config']); + $alt->forceFill(['certificate_expires_at' => now()->addDays(40), 'certificate_checked_at' => now()])->save(); + + proxyPage(); + + expect($alt->fresh()->isFromConfig())->toBeFalse() + ->and(ProxyHost::query()->whereKey($alt->id)->exists())->toBeTrue(); +});