Keep IP addresses out of a certificate overview
127.0.0.1 was in the list on the live server, marked as console, showing "no valid certificate: Connection refused" in red. It came from ADMIN_HOSTS, where a bare IP is deliberately allowed — it is the way back into the console when a name does not resolve. My filter only asked for a dot, and 127.0.0.1 has three. Let's Encrypt does not issue for IP addresses, so that row was permanently red and nobody could ever fix it. A red line that cannot be acted on is worse than no line: it teaches the reader to skip the colour, which is the one thing the overview needs them not to do. Two checks now, not one: the address test, and a last label that is not numeric. A TLD is never a number, and that also catches the forms FILTER_VALIDATE_IP lets through. The row already in the database goes away on the next sync, because otherwise my mistake would sit on every installation that has already updated. Vanished config rows are handled by what they carry: one that never had a certificate was a mistake and is deleted; one that HAS a live certificate becomes a manual entry instead, so nothing with a running expiry disappears without the operator deciding. That is a change of mind from the previous commit, which said config rows are never removed — this case showed the cost of that rule where the row should never have existed. 2043 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/host-bootstrap
parent
d013124c12
commit
272cfe45e8
|
|
@ -22,18 +22,17 @@ class CertificateSweep
|
||||||
/**
|
/**
|
||||||
* Die Namen aus der Umgebung in die Liste übernehmen.
|
* 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
|
* @return int wie viele Zeilen neu hinzugekommen sind
|
||||||
*/
|
*/
|
||||||
public function sync(): int
|
public function sync(): int
|
||||||
{
|
{
|
||||||
|
$bekannt = KnownHostnames::all();
|
||||||
|
|
||||||
|
$this->pruneVanished($bekannt);
|
||||||
|
|
||||||
$added = 0;
|
$added = 0;
|
||||||
|
|
||||||
foreach (KnownHostnames::all() as $hostname => $purpose) {
|
foreach ($bekannt as $hostname => $purpose) {
|
||||||
$existing = ProxyHost::query()->where('hostname', $hostname)->first();
|
$existing = ProxyHost::query()->where('hostname', $hostname)->first();
|
||||||
|
|
||||||
if ($existing === null) {
|
if ($existing === null) {
|
||||||
|
|
@ -59,6 +58,41 @@ class CertificateSweep
|
||||||
return $added;
|
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<string, string> $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} */
|
/** @return array{checked: int, failing: int} */
|
||||||
public function check(): array
|
public function check(): array
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,27 @@ final class KnownHostnames
|
||||||
return;
|
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
|
// Konsole gewinnt gegen öffentlich, wenn ein Name in beiden Listen
|
||||||
// steht: die engere Aussage ist die richtige.
|
// steht: die engere Aussage ist die richtige.
|
||||||
if (($found[$host] ?? null) !== 'console') {
|
if (($found[$host] ?? null) !== 'console') {
|
||||||
|
|
|
||||||
|
|
@ -198,3 +198,51 @@ it('counts what is valid, what expires soon and what is broken', function () {
|
||||||
->and($summary['soon'])->toBe(1)
|
->and($summary['soon'])->toBe(1)
|
||||||
->and($summary['failing'])->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();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue