From 968d19b61131aea28cb7446f95a6fe673dd6e99e Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Tue, 27 Oct 2026 10:02:07 +0100 Subject: [PATCH] Add a KantTresor tab to the central registry KantTresor instances (from 1.82.0) report to /api/kanttresor/melden with the same key-then-HMAC protocol. The new tab lists them with tenants, disk usage and failing services; it never offers or accepts an update order, since KantTresor is updated with ./update.sh on the server. See ADR 124. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 10 +- docs/STATUS.md | 12 ++ scripts/zentrale-check.php | 27 +++++ zentrale/README.md | 21 ++++ zentrale/app/Ansicht.php | 5 +- zentrale/app/Anwendung.php | 66 +++++++---- zentrale/app/Api.php | 153 +++++++++++++++++++------ zentrale/app/Bestand.php | 47 +++++++- zentrale/app/Datenbank.php | 9 ++ zentrale/app/vorlagen/_liste.php | 40 ++++++- zentrale/app/vorlagen/installation.php | 31 ++++- zentrale/app/vorlagen/rahmen.php | 13 ++- zentrale/app/vorlagen/uebersicht.php | 29 +++-- zentrale/public/app.css | 7 ++ zentrale/public/app.js | 2 +- 15 files changed, 392 insertions(+), 80 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 42ed14c..0ec72ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,14 @@ # Architekturentscheidungen -Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen. +Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen. + +## ADR 124 – KantTresor meldet sich in der Zentrale, ohne Update aus der Ferne (27.10.2026) + +**Kontext:** Umut will in der Zentrale neben den KantSites-Installationen auch die KantTresor-Instanzen sehen (eigener Reiter); ein Update per Knopfdruck braucht KantTresor nicht. + +**Entscheidung:** Dieselbe Tabelle `installation` mit neuer Spalte `produkt` (Migration 2 der Zentrale; Altbestand wird `kantsites`), dazu `laufzeit`, `mandanten`, `stoerungen`, `speicher`. Eigener Endpunkt `POST /api/kanttresor/melden` (`Api::meldenTresor`) mit demselben Schlüssel-/HMAC-Verfahren wie ADR 123; eine Kennung bleibt an ihr Produkt gebunden (403 sonst). Die Antwort ist nur `{"ok":true}` – nie ein Auftrag, und `Bestand::beauftragen` lehnt KantTresor-Kennungen ab. Übersicht unter `/kanttresor` mit eigenen Spalten und der Kachel „Dienst gestört oder Speicher knapp“ (≥ 90 %). Als neueste Fassung gilt die höchste gemeldete, weil die KantArchiv-Releaseseite nicht öffentlich ist und ihre Tags nicht gepflegt sind. Gegenstück in KantArchiv: `controlplane/zentrale.py`, beat-Task alle 15 min, ab 1.82.0. + +**Folgen:** Die Zentrale muss neu hochgeladen werden; die Datenbank migriert beim ersten Aufruf. „Veraltet“ ist bei KantTresor relativ zum Bestand – ist keine Instanz auf dem neuesten Stand, fällt das nicht auf. KantTresor gibt keine Tür nach außen frei; er sendet nur. Die IP-Adresse wird wie bei KantSites gespeichert. ## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026) diff --git a/docs/STATUS.md b/docs/STATUS.md index a375811..406dad7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## Zentrale: Reiter KantTresor (27.10.2026) + +**Anlass:** Umut: KantTresor soll sich in der Zentrale melden, eigener Reiter; „was wir nicht brauchen ist Aktualisierung per Knopfdruck“. + +**Erledigt:** `zentrale/`: Migration 2 (`produkt`, `laufzeit`, `mandanten`, `stoerungen`, `speicher`; Altbestand = KantSites), Endpunkt `POST /api/kanttresor/melden`, Reiter KantSites/KantTresor, Übersicht `/kanttresor` mit Kacheln (inkl. „Dienst gestört oder Speicher knapp“), Live-Suche, Detailseite ohne Update- und Auftragsteil, serverseitige Ablehnung von Aufträgen für KantTresor. ADR 124, `zentrale/README.md`. KantArchiv (eigenes Repo): `controlplane/zentrale.py`, Modell `ZentraleMeldung` (Migration 0016), beat-Task `controlplane.zentrale_melden` alle 15 min, Einstellungen `ZENTRALE_URL`/`ZENTRALE_ADRESSE`/`ZENTRALE_POLL_SECONDS`, Tests – dort als 1.82.0 committet und gepusht (fc2019b, b261658). + +**Geprüft:** `php scripts/zentrale-check.php` 28/28 (11 neue zu KantTresor). KantArchiv: `tests.test_zentrale` + `test_manage_gui` 24/24, ruff, mypy, `migrate_all` im Dev-Stack. Ende-zu-Ende: echte Meldung aus dem KantTresor-Container per HTTP an die lokale Zentrale (200, Antwortunterschrift gültig). Upgrade einer Zentrale-DB im Stand 1. Browser (Port 8115): Reiter, Filter, Live-Suche, Zurücksetzen bleibt auf `/kanttresor`, Detailseite, Notiz, untergeschobener Update-Auftrag wird abgelehnt, KantSites-Reiter unverändert mit Update-Knöpfen, 375 px ohne Querscrollen, keine Konsolenfehler. Demo-DB wieder gelöscht. Nicht geprüft: Live-Zentrale, Meldung einer echten KantTresor-Produktion über das Internet. + +**Fund unterwegs:** Der Dev-Stack von KantArchiv hat `host.docker.internal` in `DJANGO_ALLOWED_HOSTS` und hätte sich sonst an die echte Zentrale gemeldet. Jetzt gesperrt über `.internal` und `DJANGO_DEBUG=True`; vor dem ersten Takt gestoppt, es ging nichts raus. + +**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (`config.php` und `var/` bleiben). KantTresor-Instanzen mit `./update.sh` auf 1.82.0 heben; nach spätestens 15 min erscheinen sie im Reiter. KantSites-Änderungen sind noch nicht committet. + ## Zentrale: Warnung bei unsicherem PHP (22.10.2026) **Anlass:** Kritische PHP-Lücke „php2shell“ (CVE-2026-109618/-109619, CVSS 9.2, Patches vom 21.10.2026: 8.2.35, 8.3.36, 8.4.27, 8.5.12). Umut: Warnung „bei sitesviewer einbauen, nicht in KantSites selbst“. diff --git a/scripts/zentrale-check.php b/scripts/zentrale-check.php index 047d03b..f96dc83 100644 --- a/scripts/zentrale-check.php +++ b/scripts/zentrale-check.php @@ -110,5 +110,32 @@ $pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Best $pruefe(null === Zentrale\Bestand::phpLuecke('8.4.27') && null === Zentrale\Bestand::phpLuecke('8.6.0RC4') && null === Zentrale\Bestand::phpLuecke(''), 'Gepatchtes, neueres und unbekanntes PHP gilt nicht als unsicher'); $pruefe(array_key_exists('php_luecke', $bestand->alle(null)[0] ?? []), 'Die Übersicht kennt den PHP-Zustand'); +// KantTresor (ADR 124): eigener Pfad, gleiches Verfahren, nie ein Auftrag. Die Gegenseite ist +// Python (controlplane/zentrale.py in KantArchiv); hier wird sie mit denselben Feldern nachgestellt. +$tresorId = bin2hex(random_bytes(16)); +$tresorSchluessel = bin2hex(random_bytes(32)); +$tresorMelden = static function (array $daten, ?string $schluessel = null) use ($api, $tresorSchluessel): array { + $body = (string) json_encode($daten, JSON_UNESCAPED_SLASHES); + + return $api->meldenTresor($body, hash_hmac('sha256', $body, $schluessel ?? $tresorSchluessel), '198.51.100.4'); +}; +$tresorDaten = ['id' => $tresorId, 'adresse' => 'https://archiv.kunde-drei.de', 'fassung' => '1.82.0', 'laufzeit' => 'Python 3.12.7 · Django 5.2.8', 'datenbank' => 'postgresql', 'mandanten' => 3, 'stoerungen' => ['Objektspeicher'], 'speicher' => 93]; +$antwort = $tresorMelden([...$tresorDaten, 'schluessel' => $tresorSchluessel]); +$pruefe(200 === $antwort['status'] && hash_equals(hash_hmac('sha256', $antwort['body'], $tresorSchluessel), (string) $antwort['signatur']), 'KantTresor-Meldung wird angenommen und unterschrieben beantwortet'); +$pruefe(!str_contains($antwort['body'], 'auftrag'), 'KantTresor bekommt nie einen Auftrag'); +$tresorZeile = $bestand->eine($tresorId, '1.82.0'); +$pruefe('kanttresor' === ($tresorZeile['produkt'] ?? null) && 3 === (int) $tresorZeile['mandanten'] && 'Objektspeicher' === $tresorZeile['stoerungen'], 'Mandanten und Störungen stehen in der Zentrale'); +$pruefe(true === $tresorZeile['gestoert'] && true === $tresorZeile['speicher_knapp'], 'Gestörter Dienst und knapper Speicher fallen auf'); +$pruefe(false === $tresorZeile['kann_update'] && null !== $bestand->beauftragen($tresorId, '1.83.0'), 'Für KantTresor lässt sich kein Update beauftragen'); +$pruefe(1 === count($bestand->alle(null, 'kanttresor')) && [] === array_filter($bestand->alle(null), static fn (array $i): bool => $i['id'] === $tresorId), 'KantSites- und KantTresor-Liste bleiben getrennt'); +$pruefe(200 === $tresorMelden([...$tresorDaten, 'stoerungen' => [], 'speicher' => 41])['status'] && false === $bestand->eine($tresorId, null)['gestoert'], 'Folgemeldung ohne Schlüssel, Störung behoben'); +$pruefe(403 === $tresorMelden($tresorDaten, str_repeat('0', 64))['status'], 'KantTresor-Meldung mit falscher Unterschrift wird abgewiesen'); +$pruefe(409 === $tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16))])['status'], 'Unbekannte KantTresor-Kennung ohne Schlüssel bekommt 409'); +$kantsitesId = (string) $pdo->query("SELECT id FROM installation WHERE produkt = 'kantsites'")->fetchColumn(); +$kantsitesSchluessel = (string) $pdo->query("SELECT schluessel FROM installation WHERE produkt = 'kantsites'")->fetchColumn(); +$pruefe(403 === $tresorMelden([...$tresorDaten, 'id' => $kantsitesId], $kantsitesSchluessel)['status'], 'Eine KantSites-Kennung kann nicht als KantTresor melden'); +$tresorMelden([...$tresorDaten, 'id' => bin2hex(random_bytes(16)), 'schluessel' => $tresorSchluessel, 'fassung' => '1.83.1']); +$pruefe('1.83.1' === $bestand->hoechsteFassung('kanttresor') && true === $bestand->eine($tresorId, '1.83.1')['veraltet'], 'Höchste gemeldete KantTresor-Fassung macht ältere zu veraltet'); + echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n"; exit($fehler > 0 ? 1 : 0); diff --git a/zentrale/README.md b/zentrale/README.md index ef15126..bcedcc6 100644 --- a/zentrale/README.md +++ b/zentrale/README.md @@ -61,6 +61,27 @@ Einschränkungen: Besucher oder Cron-Aufruf das Update ab. So lange zeigt sie die Wartungsseite. - Ein abgeholter Auftrag ohne Ergebnis nach 2 Stunden erscheint als „keine Rückmeldung“. +## KantTresor + +Der Reiter **KantTresor** (`/kanttresor`) zeigt die KantTresor-Instanzen. Ab KantTresor +**1.82.0** meldet sich jede Instanz alle 15 Minuten über den beat-Task +`controlplane.zentrale_melden` an `POST /api/kanttresor/melden` – gleiches Verfahren wie +KantSites (Schlüssel bei der ersten Meldung, danach HMAC-Unterschrift in beide Richtungen). + +Übertragen werden: Adresse, Fassung, Python/Django, Datenbank, Anzahl aktiver Mandanten, +Namen gestörter Dienste und belegter Speicher in Prozent. Keine Mandantennamen, Dokumente +oder Zugangsdaten. + +Unterschiede zu KantSites: + +- **Kein Update von hier.** Die Zentrale gibt KantTresor nie einen Auftrag; aktualisiert wird + auf dem Server mit `./update.sh`. +- „Aktuell“ und „veraltet“ richten sich nach der **höchsten gemeldeten Fassung** – die + KantArchiv-Releaseseite ist nicht öffentlich. +- Statt der PHP-Kachel gibt es **„Dienst gestört oder Speicher knapp“** (ab 90 % belegt). +- Abschalten pro Instanz: `ZENTRALE_URL=aus` in `.env.prod`. Instanzen mit + `DJANGO_DEBUG=True` oder nur lokaler Adresse melden nie. + ## Fremde Installationen erkennen Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als diff --git a/zentrale/app/Ansicht.php b/zentrale/app/Ansicht.php index 513e03c..157cd46 100644 --- a/zentrale/app/Ansicht.php +++ b/zentrale/app/Ansicht.php @@ -9,7 +9,8 @@ namespace Zentrale; */ final class Ansicht { - public function __construct(private string $csrf) + /** @param string $pfad die Übersicht, auf die adresse() zeigt (KantSites oder KantTresor) */ + public function __construct(private string $csrf, private string $pfad = '/') { } @@ -43,7 +44,7 @@ final class Ansicht { $werte = array_filter($werte, static fn (mixed $wert): bool => null !== $wert && '' !== $wert && 1 !== $wert); - return '/'.([] === $werte ? '' : '?'.http_build_query($werte)); + return $this->pfad.([] === $werte ? '' : '?'.http_build_query($werte)); } /** Ein Zeitpunkt in deutscher Ortszeit. */ diff --git a/zentrale/app/Anwendung.php b/zentrale/app/Anwendung.php index e9a5dac..6eee748 100644 --- a/zentrale/app/Anwendung.php +++ b/zentrale/app/Anwendung.php @@ -37,8 +37,8 @@ final class Anwendung return; } - if ('/api/melden' === $pfad) { - $this->api($methode); + if ('/api/melden' === $pfad || '/api/kanttresor/melden' === $pfad) { + $this->api($methode, '/api/melden' === $pfad ? 'kantsites' : 'kanttresor'); return; } @@ -77,7 +77,8 @@ final class Anwendung $bestand = new Bestand($this->db); match (true) { - '/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen), + '/' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $fassungen->neueste(), 'kantsites'), + '/kanttresor' === $pfad && 'GET' === $methode => $this->uebersicht($bestand, $this->tresorNeueste($bestand), 'kanttresor'), '/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen), '/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen), '/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen), @@ -90,7 +91,7 @@ final class Anwendung }; } - private function api(string $methode): void + private function api(string $methode, string $produkt): void { header('Content-Type: application/json; charset=UTF-8'); if ('POST' !== $methode) { @@ -100,7 +101,8 @@ final class Anwendung return; } - $antwort = (new Api($this->db))->melden( + $api = new Api($this->db); + $antwort = ('kanttresor' === $produkt ? $api->meldenTresor(...) : $api->melden(...))( (string) file_get_contents('php://input', false, null, 0, 16385), (string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''), (string) ($_SERVER['REMOTE_ADDR'] ?? ''), @@ -112,10 +114,23 @@ final class Anwendung echo $antwort['body']; } - private function uebersicht(Bestand $bestand, Fassungen $fassungen): void + /** + * KantTresor hat keine öffentliche Releaseseite; die höchste gemeldete Fassung gilt als neueste. + * + * @return array{version: string}|null + */ + private function tresorNeueste(Bestand $bestand): ?array { - $neueste = $fassungen->neueste(); - $alle = $bestand->alle($neueste['version'] ?? null); + $hoechste = $bestand->hoechsteFassung('kanttresor'); + + return null === $hoechste ? null : ['version' => $hoechste]; + } + + /** @param array{version: string}|null $neueste */ + private function uebersicht(Bestand $bestand, ?array $neueste, string $produkt): void + { + $tresor = 'kanttresor' === $produkt; + $alle = $bestand->alle($neueste['version'] ?? null, $produkt); // Die Zähler oben sind zugleich die Filter; sie zählen immer den ganzen Bestand. $filter = [ @@ -123,8 +138,12 @@ final class Anwendung 'veraltet' => static fn (array $i): bool => $i['veraltet'], 'still' => static fn (array $i): bool => $i['still'], 'unbekannt' => static fn (array $i): bool => $i['unbekannt'], - 'php' => static fn (array $i): bool => null !== $i['php_luecke'], ]; + if ($tresor) { + $filter['gestoert'] = static fn (array $i): bool => $i['gestoert']; + } else { + $filter['php'] = static fn (array $i): bool => null !== $i['php_luecke']; + } $zahlen = ['alle' => count($alle)]; foreach ($filter as $name => $passt) { $zahlen[$name] = count(array_filter($alle, $passt)); @@ -142,7 +161,7 @@ final class Anwendung if ('' === $suche) { return true; } - foreach (['domain', 'domain_vorher', 'adresse', 'notiz', 'fassung', 'php', 'ip'] as $feld) { + foreach (['domain', 'domain_vorher', 'adresse', 'notiz', 'fassung', 'php', 'laufzeit', 'stoerungen', 'ip'] as $feld) { if (null !== $i[$feld] && false !== mb_stripos((string) $i[$feld], $suche)) { return true; } @@ -155,17 +174,20 @@ final class Anwendung $seiten = max(1, (int) ceil(count($treffer) / $proSeite)); $seite = min($seiten, max(1, (int) ($_GET['seite'] ?? 1))); - $namen = ['aktuell' => 'aktuell', 'veraltet' => 'veraltet', 'still' => 'ohne Meldung seit 48 h', 'unbekannt' => 'ohne Kundenzuordnung', 'php' => 'PHP mit Sicherheitslücke']; + $namen = ['aktuell' => 'aktuell', 'veraltet' => 'veraltet', 'still' => 'ohne Meldung seit 48 h', 'unbekannt' => 'ohne Kundenzuordnung', 'php' => 'PHP mit Sicherheitslücke', 'gestoert' => 'Dienst gestört oder Speicher knapp']; $von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1; $bis = min(count($treffer), $seite * $proSeite); - $trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), 1 === count($treffer) ? 'Installation' : 'Installationen') + $einheit = $tresor ? (1 === count($treffer) ? 'Instanz' : 'Instanzen') : (1 === count($treffer) ? 'Installation' : 'Installationen'); + $trefferText = [] === $treffer ? 'Keine Treffer.' : sprintf('%d–%d von %d %s', $von, $bis, count($treffer), $einheit) .(null !== $gewaehlt ? ' · Filter: '.$namen[$gewaehlt] : '') .('' !== $suche ? ' · Suche: „'.$suche.'“' : ''); // Die Live-Suche holt nur die Liste; ohne JavaScript kommt die ganze Seite. $teil = 'liste' === ($_GET['teil'] ?? null); $this->zeigen($teil ? '_liste' : 'uebersicht', [ - 'titel' => 'Installationen', + 'titel' => $tresor ? 'KantTresor' : 'Installationen', + 'produkt' => $produkt, + 'pfad' => $tresor ? '/kanttresor' : '/', 'trefferText' => $trefferText, 'neueste' => $neueste, 'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite), @@ -176,20 +198,22 @@ final class Anwendung 'seiten' => $seiten, 'zahlen' => $zahlen, // Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen. - 'aktualisierbar' => array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))), + 'aktualisierbar' => $tresor ? [] : array_values(array_filter($alle, static fn (array $i): bool => $i['kann_update'] && !$i['still'] && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null))), ], true, $teil); } private function detail(Bestand $bestand, Fassungen $fassungen): void { - $neueste = $fassungen->neueste(); - $eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null); + $id = (string) ($_GET['id'] ?? ''); + $produkt = $bestand->produkt($id); + $neueste = 'kanttresor' === $produkt ? $this->tresorNeueste($bestand) : $fassungen->neueste(); + $eine = $bestand->eine($id, $neueste['version'] ?? null); if (null === $eine) { $this->nichtGefunden(); return; } - $this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste]); + $this->zeigen('installation', ['titel' => $eine['domain'], 'i' => $eine, 'neueste' => $neueste, 'produkt' => $eine['produkt']]); } private function auftrag(Bestand $bestand, Fassungen $fassungen): void @@ -244,9 +268,10 @@ final class Anwendung return; } + $produkt = $bestand->produkt($id); $bestand->entfernen($id); $this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok'); - $this->weiter('/'); + $this->weiter('kanttresor' === $produkt ? '/kanttresor' : '/'); } private function fassungNeu(Fassungen $fassungen): void @@ -373,20 +398,21 @@ final class Anwendung private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void { header('Content-Type: text/html; charset=UTF-8'); + $v = new Ansicht((string) ($_SESSION['csrf'] ?? ''), (string) ($daten['pfad'] ?? '/')); if ($ohneRahmen) { - echo (new Ansicht((string) ($_SESSION['csrf'] ?? '')))->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); + echo $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); return; } $hinweis = $_SESSION['hinweis'] ?? null; unset($_SESSION['hinweis']); - $v = new Ansicht((string) ($_SESSION['csrf'] ?? '')); $inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [ 'titel' => $daten['titel'] ?? 'Zentrale', 'inhalt' => $inhalt, 'hinweis' => $hinweis, 'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null, + 'produkt' => $daten['produkt'] ?? null, ]); } } diff --git a/zentrale/app/Api.php b/zentrale/app/Api.php index 9b921c0..9fbb7b3 100644 --- a/zentrale/app/Api.php +++ b/zentrale/app/Api.php @@ -11,6 +11,10 @@ namespace Zentrale; * Schlüssel mit; danach muss jede Meldung damit unterschrieben sein (HMAC-SHA256 über den * Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die * Installation nur Aufträge von hier annimmt. + * + * POST /api/kanttresor/melden nimmt auf dieselbe Weise die Meldung einer KantTresor-Instanz an + * (ADR 124) - mit eigenen Angaben und nie mit einem Auftrag: KantTresor wird auf dem Server + * mit ./update.sh aktualisiert. */ final class Api { @@ -36,36 +40,17 @@ final class Api if (is_string($pruef)) { return $this->fehler(400, $pruef); } - $signatur = strtolower(trim($signatur)); - - $gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?'); - $gespeichert->execute([$daten['id']]); - $installation = $gespeichert->fetch() ?: null; - $mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null; - - if (null === $installation) { - if (null === $mitgebracht) { - // Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt - // daraufhin ihren Schlüssel noch einmal. - return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.'); - } - $schluessel = $mitgebracht; - } else { - $schluessel = (string) $installation['schluessel']; - if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) { - return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.'); - } - } - if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) { - return $this->fehler(403, 'Die Unterschrift stimmt nicht.'); + $anmeldung = $this->anmeldung($daten, $body, $signatur, 'kantsites'); + if (isset($anmeldung['status'])) { + return $anmeldung; } + ['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung; $jetzt = Datenbank::jetzt(); - $domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)); $update = is_array($daten['update'] ?? null) ? $daten['update'] : []; $werte = [ 'adresse' => $daten['adresse'], - 'domain' => $domain, + 'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)), 'fassung' => $daten['fassung'], 'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20), 'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20), @@ -80,18 +65,7 @@ final class Api $this->db->beginTransaction(); try { - if (null === $installation) { - $sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')'; - $this->db->prepare($sql)->execute([$daten['id'], $schluessel, $jetzt, ...array_values($werte)]); - } else { - // Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation - // kopiert oder umgezogen. Beides will man sehen. - if ($installation['domain'] !== $domain) { - $werte['domain_vorher'] = $installation['domain']; - } - $sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?'; - $this->db->prepare($sql)->execute([...array_values($werte), $daten['id']]); - } + $this->speichern($daten['id'], $schluessel, $installation, $werte); $ergebnis = $daten['auftrag'] ?? null; if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) { @@ -117,6 +91,113 @@ final class Api return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)]; } + /** + * Die Meldung einer KantTresor-Instanz: Stand, Mandantenzahl, gestörte Dienste, Speicher. + * Die Antwort enthält nie einen Auftrag. + * + * @return array{status: int, body: string, signatur: ?string} + */ + public function meldenTresor(string $body, string $signatur, string $ip): array + { + if (strlen($body) > self::GROESSTE_MELDUNG) { + return $this->fehler(413, 'Die Meldung ist zu groß.'); + } + $daten = json_decode($body, true); + if (!is_array($daten)) { + return $this->fehler(400, 'Die Meldung ist kein JSON.'); + } + $pruef = $this->pruefen($daten); + if (is_string($pruef)) { + return $this->fehler(400, $pruef); + } + $anmeldung = $this->anmeldung($daten, $body, $signatur, 'kanttresor'); + if (isset($anmeldung['status'])) { + return $anmeldung; + } + ['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung; + + $stoerungen = is_array($daten['stoerungen'] ?? null) ? array_slice(array_values(array_filter($daten['stoerungen'], 'is_string')), 0, 10) : []; + $mandanten = $daten['mandanten'] ?? null; + $speicher = $daten['speicher'] ?? null; + $werte = [ + 'produkt' => 'kanttresor', + 'adresse' => $daten['adresse'], + 'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)), + 'fassung' => $daten['fassung'], + 'laufzeit' => mb_substr((string) ($daten['laufzeit'] ?? ''), 0, 60), + 'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20), + 'mandanten' => is_int($mandanten) && $mandanten >= 0 && $mandanten <= 100000 ? $mandanten : null, + 'stoerungen' => mb_substr(implode(', ', array_map(static fn (string $s): string => mb_substr(trim($s), 0, 60), $stoerungen)), 0, 300), + 'speicher' => is_int($speicher) && $speicher >= 0 && $speicher <= 100 ? $speicher : null, + 'ip' => mb_substr($ip, 0, 45), + 'letzte_meldung' => Datenbank::jetzt(), + ]; + $this->speichern($daten['id'], $schluessel, $installation, $werte); + + $antwort = '{"ok":true}'; + + return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)]; + } + + /** + * Wer meldet sich? Beim ersten Kontakt mit Schlüssel, danach nur mit gültiger Unterschrift. + * + * @param array $daten + * + * @return array{installation: ?array, schluessel: string}|array{status: int, body: string, signatur: null} + */ + private function anmeldung(array $daten, string $body, string $signatur, string $produkt): array + { + $signatur = strtolower(trim($signatur)); + $gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?'); + $gespeichert->execute([$daten['id']]); + $installation = $gespeichert->fetch() ?: null; + $mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null; + + if (null === $installation) { + if (null === $mitgebracht) { + // Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt + // daraufhin ihren Schlüssel noch einmal. + return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.'); + } + $schluessel = $mitgebracht; + } else { + $schluessel = (string) $installation['schluessel']; + if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) { + return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.'); + } + } + if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) { + return $this->fehler(403, 'Die Unterschrift stimmt nicht.'); + } + if (null !== $installation && $produkt !== $installation['produkt']) { + return $this->fehler(403, 'Diese Kennung gehört zu einem anderen Produkt.'); + } + + return ['installation' => $installation, 'schluessel' => $schluessel]; + } + + /** + * @param array|null $installation + * @param array $werte mit letzte_meldung + */ + private function speichern(string $id, string $schluessel, ?array $installation, array $werte): void + { + if (null === $installation) { + $sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')'; + $this->db->prepare($sql)->execute([$id, $schluessel, $werte['letzte_meldung'], ...array_values($werte)]); + + return; + } + // Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation + // kopiert oder umgezogen. Beides will man sehen. + if ($installation['domain'] !== $werte['domain']) { + $werte['domain_vorher'] = $installation['domain']; + } + $sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?'; + $this->db->prepare($sql)->execute([...array_values($werte), $id]); + } + /** @param array $daten */ private function pruefen(array $daten): ?string { diff --git a/zentrale/app/Bestand.php b/zentrale/app/Bestand.php index 3bbf1fe..a02e328 100644 --- a/zentrale/app/Bestand.php +++ b/zentrale/app/Bestand.php @@ -28,10 +28,18 @@ final class Bestand { } + /** Ab so viel Prozent belegtem Speicher gilt eine KantTresor-Instanz als knapp. */ + public const SPEICHER_KNAPP = 90; + + /** Die Produkte, die sich melden, mit ihrer Übersicht. */ + public const PRODUKTE = ['kantsites' => 'KantSites', 'kanttresor' => 'KantTresor']; + /** @return list> */ - public function alle(?string $neueste): array + public function alle(?string $neueste, string $produkt = 'kantsites'): array { - $zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll(); + $abfrage = $this->db->prepare('SELECT * FROM installation WHERE produkt = ? ORDER BY domain, erste_meldung'); + $abfrage->execute([$produkt]); + $zeilen = $abfrage->fetchAll(); // Natürlich sortiert und ohne www: kunde-2 vor kunde-10, www.ebsolar.de bei e. usort($zeilen, static fn (array $a, array $b): int => strnatcasecmp(preg_replace('/^www\./', '', (string) $a['domain']), preg_replace('/^www\./', '', (string) $b['domain']))); $auftraege = []; @@ -42,6 +50,34 @@ final class Bestand return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen); } + /** + * KantTresor hat keine öffentliche Releaseseite: Als neueste Fassung gilt die höchste, die + * eine Instanz meldet. Wer dahinter liegt, ist veraltet. + */ + public function hoechsteFassung(string $produkt): ?string + { + $abfrage = $this->db->prepare("SELECT fassung FROM installation WHERE produkt = ? AND fassung <> 'unbekannt'"); + $abfrage->execute([$produkt]); + $hoechste = null; + foreach ($abfrage->fetchAll(\PDO::FETCH_COLUMN) as $fassung) { + if (null === $hoechste || version_compare((string) $fassung, $hoechste, '>')) { + $hoechste = (string) $fassung; + } + } + + return $hoechste; + } + + /** Zu welchem Produkt gehört diese Kennung? */ + public function produkt(string $id): ?string + { + $abfrage = $this->db->prepare('SELECT produkt FROM installation WHERE id = ?'); + $abfrage->execute([$id]); + $produkt = $abfrage->fetchColumn(); + + return false === $produkt ? null : (string) $produkt; + } + /** @return array|null */ public function eine(string $id, ?string $neueste): ?array { @@ -117,7 +153,10 @@ final class Bestand // Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft. $laeuft = 'laeuft' === ($zustand['art'] ?? null); + $tresor = 'kanttresor' === $zeile['produkt']; + $grund = match (true) { + $tresor => 'KantTresor wird auf dem Server mit ./update.sh aktualisiert, nicht von hier.', null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.', !$veraltet => 'Diese Installation ist auf dem neuesten Stand.', 'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.', @@ -135,7 +174,9 @@ final class Bestand 'auftrag_zustand' => $zustand, 'kann_update' => '' === $grund, 'kein_update_grund' => $grund, - 'php_luecke' => self::phpLuecke((string) $zeile['php']), + 'php_luecke' => $tresor ? null : self::phpLuecke((string) $zeile['php']), + 'speicher_knapp' => null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP, + 'gestoert' => '' !== (string) $zeile['stoerungen'] || (null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP), ]; } diff --git a/zentrale/app/Datenbank.php b/zentrale/app/Datenbank.php index f13b047..3b55876 100644 --- a/zentrale/app/Datenbank.php +++ b/zentrale/app/Datenbank.php @@ -47,6 +47,15 @@ final class Datenbank )', 'CREATE INDEX auftrag_installation ON auftrag (installation, id)', ], + // KantTresor meldet sich ebenfalls (ADR 124). Bestehende Zeilen sind KantSites. + 2 => [ + 'ALTER TABLE installation ADD COLUMN produkt TEXT NOT NULL DEFAULT \'kantsites\'', + 'ALTER TABLE installation ADD COLUMN laufzeit TEXT NOT NULL DEFAULT \'\'', + 'ALTER TABLE installation ADD COLUMN mandanten INTEGER', + 'ALTER TABLE installation ADD COLUMN stoerungen TEXT NOT NULL DEFAULT \'\'', + 'ALTER TABLE installation ADD COLUMN speicher INTEGER', + 'CREATE INDEX installation_produkt ON installation (produkt)', + ], ]; public static function oeffnen(string $datei): \PDO diff --git a/zentrale/app/vorlagen/_liste.php b/zentrale/app/vorlagen/_liste.php index c990762..b820938 100644 --- a/zentrale/app/vorlagen/_liste.php +++ b/zentrale/app/vorlagen/_liste.php @@ -1,14 +1,46 @@
+

Noch keine Meldung

-

Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.

+ +

Sobald eine KantTresor-Instanz ab Fassung 1.82.0 läuft, meldet sie sich hier – alle 15 Minuten über den Hintergrunddienst (beat).

+ +

Sobald eine KantSites-Installation ab Fassung 1.36.0 läuft, meldet sie sich hier – spätestens 15 Minuten nach dem ersten Besuch oder Cron-Aufruf.

+

Nichts gefunden

-

Keine Installation passt zu Suche und Filter. Alle anzeigen

+

Keine passt zu Suche und Filter. Alle anzeigen

+
+ +
+ + + + + + + + + + + + + + + + + + +
Gemeldete KantTresor-Instanzen, Seite von
DomainKundeFassungMandantenSpeicherLetzte MeldungZustand
e($i['domain']) ?> + vorher e($i['domain_vorher']) ?>Unbekannt' : $v->e($i['notiz']) ?>e($i['fassung']) ?>e($i['mandanten'] ?? '–') ?>– % belegte($v->vor($i['alter'])) ?> + Gestört: e($i['stoerungen']) ?> + Speicher knapp + Alle Dienste laufen +
@@ -44,7 +76,8 @@
- 1) { ?> + + 0 && $seiten > 1) { ?> -
diff --git a/zentrale/app/vorlagen/installation.php b/zentrale/app/vorlagen/installation.php index ede8683..726d735 100644 --- a/zentrale/app/vorlagen/installation.php +++ b/zentrale/app/vorlagen/installation.php @@ -1,11 +1,37 @@ -

← Alle Installationen

+ +

← Alle KantTresor-Instanzen← Alle Installationen

e($i['domain']) ?>

Unbekannt
+ +
+

Stand

+
+
Adresse
e($i['adresse']) ?>
+
Vorher gemeldet als
e($i['domain_vorher']) ?>
+
Fassung
e($i['fassung']) ?>e($neueste['version']) : '' ?>
+
Laufzeit
e($i['laufzeit'] ?: '–') ?>
+
Datenbank
e($i['datenbank'] ?: '–') ?>
+
Mandanten
e($i['mandanten'] ?? '–') ?>
+
Letzte Meldung
e($v->zeit($i['letzte_meldung'])) ?> (e($v->vor($i['alter'])) ?>)seit über 48 Stunden still' : '' ?>
+
Erste Meldung
e($v->zeit($i['erste_meldung'])) ?>
+
IP-Adresse
e($i['ip'] ?: '–') ?>
+
+
+ +
+

Betrieb

+
+
Dienste
Gestört: e($i['stoerungen']) ?>Alle Dienste laufen
+
Speicher
– % belegtknapp' : '' ?>
+
+

Stand der letzten Meldung. Aktualisiert wird KantTresor auf dem Server mit ./update.sh, nicht von hier.

+
+

Stand

@@ -52,6 +78,7 @@
+

Kunde

@@ -66,6 +93,7 @@
+

Aufträge

@@ -88,6 +116,7 @@
+
Eintrag entfernen diff --git a/zentrale/app/vorlagen/rahmen.php b/zentrale/app/vorlagen/rahmen.php index 2d5715d..9859348 100644 --- a/zentrale/app/vorlagen/rahmen.php +++ b/zentrale/app/vorlagen/rahmen.php @@ -6,8 +6,8 @@ <?= $v->e($titel) ?> · KantSites Zentrale - - + + @@ -24,6 +24,15 @@ + + +

e($hinweis['text']) ?>

diff --git a/zentrale/app/vorlagen/uebersicht.php b/zentrale/app/vorlagen/uebersicht.php index 9bd94a3..a28c478 100644 --- a/zentrale/app/vorlagen/uebersicht.php +++ b/zentrale/app/vorlagen/uebersicht.php @@ -1,11 +1,16 @@ +
-

Installationen

-
- csrf() ?> - Neueste Fassung: e($neueste['version']) ?> - -
+

+ +

Höchste gemeldete Fassung: e($neueste['version']) ?>

+ +
+ csrf() ?> + Neueste Fassung: e($neueste['version']) ?> + +
+
['z-warn', 'veraltet', $zahlen['veraltet']], 'still' => ['z-still', 'ohne Meldung seit 48 h', $zahlen['still']], 'unbekannt' => ['z-fremd', 'ohne Kundenzuordnung', $zahlen['unbekannt']], - 'php' => ['z-fremd', 'PHP mit Sicherheitslücke', $zahlen['php']], ]; +if ($tresor) { + $kacheln['gestoert'] = ['z-fremd', 'Dienst gestört oder Speicher knapp', $zahlen['gestoert']]; +} else { + $kacheln['php'] = ['z-fremd', 'PHP mit Sicherheitslücke', $zahlen['php']]; +} ?>