Compare commits

..
8 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 968d19b611 Add a KantTresor tab to the central registry
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
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 <noreply@anthropic.com>
2026-10-27 10:02:07 +01:00
kantarciandClaude Opus 5.5 9d80af3795 Flag installations with vulnerable PHP in the central registry
Mark sites reporting a PHP release without the php2shell fixes
(CVE-2026-109618/-109619; patched in 8.2.35, 8.3.36, 8.4.27, 8.5.12)
or an end-of-life branch (8.1 and older). The overview gets a counter
tile that doubles as a filter, a status pill per row and PHP in the
search; the detail page explains that the hoster has to update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 06:54:12 +02:00
kantarciandClaude Opus 5.5 d3ee8e3d1e Note the 1.36.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:22:44 +02:00
kantarciandClaude Opus 5.5 6b99781819 Release 1.36.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:20:48 +02:00
kantarciandClaude Opus 5.5 6cfbc6cf7e Update Symfony to the latest 7.4 patch releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:20:48 +02:00
kantarciandClaude Opus 5.5 bb736e8258 Make the registry search live
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:13:38 +02:00
kantarciandClaude Opus 5.5 7cdb431fd4 Add search, filters and pages to the central registry
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:04:46 +02:00
kantarciandClaude Opus 5.5 821c799225 Note the 1.36.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:45:31 +02:00
17 changed files with 1002 additions and 341 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.36.0 1.36.1
Generated
+216 -217
View File
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -1,6 +1,14 @@
# Architekturentscheidungen # 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) ## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
+51 -1
View File
@@ -1,5 +1,53 @@
# Projektstatus # 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“.
**Erledigt:** Nur `zentrale/`, kein Eingriff ins CMS, keine Schemaänderung. `Bestand::PHP_SICHER` (erste sichere Fassung je Zweig) und `PHP_ALT_BIS` (8.1 und älter ohne Updates); `Bestand::phpLuecke()` liefert den Grund oder null. Unbekannte Angaben und neuere Zweige (8.6-Vorabfassungen) gelten nicht als unsicher. Übersicht: neue Kachel/Filter „PHP mit Sicherheitslücke“, rote Pille „PHP x.y.z unsicher“ in der Spalte Zustand, Suche findet auch die PHP-Fassung. Detailseite: Pille plus Hinweis, dass das Update beim Hoster liegt. Bei der nächsten PHP-Sicherheitsmeldung `PHP_SICHER` nachziehen.
**Geprüft:** `php scripts/zentrale-check.php` grün (drei neue Prüfungen zur PHP-Regel). Lokal im Browser (Port 8115, drei Demo-Installationen mit 8.4.22, 8.4.27, 8.1.30): Kachel zählt 2, Filter zeigt genau die zwei, Detailseite mit Hinweis. Demo-Datenbank wieder gelöscht. Nicht geprüft: Mobilansicht, Live-Zentrale.
**Bekannte Grenze:** Distributionen mit zurückportierten Patches (z. B. Debian/Ubuntu) melden weiter die alte Versionsnummer und erscheinen dann fälschlich als unsicher. Installationen vor 1.36.0 melden kein PHP.
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`, neu gebaut); `config.php` und `var/` auf dem Server bleiben. PHP bei allen Hostings auf 8.4.27 bringen.
## Symfony-Patchupdate, Release 1.36.1 (14.10.2026)
**Anlass:** Monatlicher Sicherheitscheck (`reports/sicherheit-2026-10.md`, nicht in Git): keine Sicherheitslücken, aber Patch-Rückstand bei Symfony. Umut: „dann mach neueste symfony version dann kann ich den updater mal testen“.
**Erledigt:** `composer update "symfony/*" --with-all-dependencies`: Symfony 7.4.15–7.4.18 → 7.4.19–7.4.21 (framework-bundle, http-kernel, security-*, twig-bundle, console, yaml u. a.), Polyfills 1.43.0, twig/twig 3.28 → 3.30, doctrine/lexer 3.0.3. Bewusst bei der LTS-Linie 7.4 geblieben (Sicherheitssupport bis 11/2029); 8.x wäre ein Major-Sprung mit PHP-8.4-Pflicht. `composer.json` unverändert, Plattform weiter PHP 8.2.12. Kein eigener Code geändert, keine Migration.
**Geprüft:** `composer audit` ohne Befund. PHPUnit 685 Tests grün unter PHP 8.4 und 8.2; unter 8.2 eine Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings` (`file_get_contents` auf eine von `git checkout` entfernte `.env`, `Updater.php:468`), die mit der alten `composer.lock` genauso auftritt – vorbestehend. php-cs-fixer ohne Befund, `npm test` 41/41, `test:editor` grün (erster Lauf hing einmal beim Warten auf „Veröffentlicht“, zweiter Lauf vollständig grün), `test:browser` gegen 8080 grün (zwei Kontrastbefunde aus im Editor gesetzten Farben auf `/grundlagen`, Inhalt, nicht Code).
**Release:** Tag `v1.36.1`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (fe286f49…), `releases/latest` meldet v1.36.1.
**Nächster Schritt:** Umut testet den Updater: eine Installation auf 1.36.0 per „System“ oder über die Zentrale auf 1.36.1 heben. Weitere Punkte aus dem Sicherheitscheck: HSTS auf den Kundenseiten einschalten (System → HSTS), PHP-Mindestfassung vor dem 31.12.2026 (Ende PHP 8.2) anheben.
## Zentrale: Suche, Filter und Seiten (14.10.2026)
**Anlass:** Umut: „Kannst du Suche und Pagination dort einbauen bei Zentrale“.
**Erledigt:** Übersicht in `zentrale/` mit Suche (Domain, frühere Domain, Kunde/Notiz, Fassung, IP), Zählerkacheln als Filter (mit Markierung und `aria-current`), Seiten zu je 25 Einträgen mit Zurück/Weiter und Auslassungen, Trefferzeile als Statusmeldung, eigener Leerzustand „Nichts gefunden“. Domains natürlich sortiert, „www.“ zählt nicht. „Alle aktualisieren“ nennt höchstens acht Domains und „und N weitere“. Kein Eingriff ins CMS, keine Schemaänderung.
**Geprüft:** Lokal mit 63 Demo-Installationen im Browser: Seite 2 von 3, Suche + Filter kombiniert (8 Treffer), kein Treffer, Seitenzahl außerhalb des Bereichs wird begrenzt, 375 px ohne Querscrollen. `php scripts/zentrale-check.php` grün.
**Nachtrag, gleicher Tag – Live-Suche:** Umut: „Suche sollte Ajax-Livesearch sein, nicht Enter-Suche“. `zentrale/public/app.js` lädt beim Tippen (250 ms Pause) nur die Liste nach (`/?…&teil=liste`, Vorlage `_liste.php`), ebenso bei Klick auf Kacheln und Seitenzahlen; Adresse per `history`, Zurück-Taste funktioniert, Trefferzeile bleibt Statusmeldung für Screenreader, abgelaufene Sitzung fällt auf normales Laden zurück. Der Suchen-Knopf verschwindet mit JavaScript. CSP erlaubt jetzt `script-src 'self'` und `connect-src 'self'`. Nebenbei behoben: `.knopf` überschrieb das `hidden`-Attribut. Geprüft im Browser mit 57 Demo-Installationen: Tippen ohne Neuladen, Filter + Suche, Zurücksetzen, Seite 2, Zurück-Taste, 375 px, keine Konsolenfehler. Die lokale Demo-Datenbank ist wieder gelöscht.
**Nächster Schritt:** Umut lädt `zentrale/app/` und `zentrale/public/` neu hoch (Paket `var/zentrale-upload.zip`); `config.php` und `var/` auf dem Server bleiben unangetastet.
## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026) ## Zentrale sites.kant-it.de: Meldung und Update aus der Ferne (14.10.2026)
**Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch. **Anlass:** Umut: Alle KantSites sollen sich bei einer Hauptplattform melden; dort Domains und Fassungen sehen und Updates anstoßen. Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle; Umut lädt die Plattform selbst hoch.
@@ -10,7 +58,9 @@
**Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git. **Bekannte Lücken:** Installationen vor 1.36.0 melden sich erst nach einem Update von Hand. Ohne Lizenzschlüssel bleibt unsichtbar, wer die Meldung ausbaut. Die IP-Adresse der letzten Meldung wird gespeichert – Hinweis in Datenschutz/AV-Vertrag nötig. Lokal fehlt PHP 8.4 ein Zertifikatsspeicher; die Launch-Konfiguration `zentrale` setzt dafür `curl.cainfo` auf das Bündel von Git.
**Nächster Schritt:** Release 1.36.0 bauen und veröffentlichen, `zentrale/` nach sites.kant-it.de hochladen (Docroot `public/`, HTTPS, `config.php`), erstes Konto anlegen, kant-it.de auf 1.36.0 heben und die erste Meldung sowie ein Fern-Update mit 1.36.1 prüfen. **Release:** sites.kant-it.de ist online (Umut; `/api/melden` antwortet, `config.php` nicht abrufbar). Fassung 1.36.0 am 14.10.2026 veröffentlicht: Tag `v1.36.0`, Releaseeintrag mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (688a0689…), `releases/latest` meldet v1.36.0.
**Nächster Schritt:** kant-it.de und die übrigen Live-Installationen unter „System“ auf 1.36.0 heben, erste Meldung in der Zentrale prüfen, das erste Fern-Update mit der nächsten Fassung ausprobieren.
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026) ## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
+32
View File
@@ -105,5 +105,37 @@ $umzug->melden();
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch(); $neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar'); $pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar');
// PHP-Fassungen mit bekannter Lücke fallen auf; gepatchte, unbekannte und Vorabfassungen nicht.
$pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Bestand::phpLuecke('8.1.30'), 'Ungepatchtes und veraltetes PHP gilt als unsicher');
$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"; echo $fehler > 0 ? "\n{$fehler} Prüfung(en) fehlgeschlagen.\n" : "\nAlle Prüfungen bestanden.\n";
exit($fehler > 0 ? 1 : 0); exit($fehler > 0 ? 1 : 0);
+29
View File
@@ -37,6 +37,14 @@ Nicht gemeldet wird von Installationen, deren `SITE_ORIGIN` auf `localhost`, `12
Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine Archiv, Wartungszustand, Stand des letzten Updates. Keine Inhalte, keine Besucher, keine
Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung. Zugangsdaten. Die Zentrale speichert zusätzlich die IP-Adresse der letzten Meldung.
## Übersicht
Die Zählerkacheln oben sind zugleich Filter (aktuell, veraltet, verstummt, ohne Kunde). Die Suche
findet Domain, frühere Domain, Kunde/Notiz, Fassung und IP-Adresse; Suche und Filter lassen sich
kombinieren. Die Suche filtert schon beim Tippen; Kacheln und Seitenzahlen wechseln nur die
Liste (`public/app.js`, ohne JavaScript funktioniert alles als normales Formular). 25 Installationen
je Seite, Domains natürlich sortiert und ohne „www.“.
## Update aus der Ferne ## Update aus der Ferne
1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken. 1. In der Übersicht „Auf x.y.z“ (oder „Alle aktualisieren“ mit Bestätigung) drücken.
@@ -53,6 +61,27 @@ Einschränkungen:
Besucher oder Cron-Aufruf das Update ab. So lange zeigt sie die Wartungsseite. 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“. - 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 ## Fremde Installationen erkennen
Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als Jede Installation, der in der Detailansicht kein Kunde zugeordnet ist, steht als
+14 -1
View File
@@ -9,7 +9,8 @@ namespace Zentrale;
*/ */
final class Ansicht 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 = '/')
{ {
} }
@@ -34,6 +35,18 @@ final class Ansicht
return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">'; return '<input type="hidden" name="_csrf" value="'.$this->e($this->csrf).'">';
} }
/**
* Eine Adresse der Übersicht; leere Werte und Seite 1 fallen weg.
*
* @param array<string, mixed> $werte
*/
public function adresse(array $werte): string
{
$werte = array_filter($werte, static fn (mixed $wert): bool => null !== $wert && '' !== $wert && 1 !== $wert);
return $this->pfad.([] === $werte ? '' : '?'.http_build_query($werte));
}
/** Ein Zeitpunkt in deutscher Ortszeit. */ /** Ein Zeitpunkt in deutscher Ortszeit. */
public function zeit(?string $iso): string public function zeit(?string $iso): string
{ {
+109 -27
View File
@@ -10,6 +10,9 @@ namespace Zentrale;
*/ */
final class Anwendung final class Anwendung
{ {
/** Zeilen je Seite in der Übersicht. */
private const PRO_SEITE = 25;
private \PDO $db; private \PDO $db;
/** @param array<string, mixed> $config */ /** @param array<string, mixed> $config */
@@ -34,8 +37,8 @@ final class Anwendung
return; return;
} }
if ('/api/melden' === $pfad) { if ('/api/melden' === $pfad || '/api/kanttresor/melden' === $pfad) {
$this->api($methode); $this->api($methode, '/api/melden' === $pfad ? 'kantsites' : 'kanttresor');
return; return;
} }
@@ -74,7 +77,8 @@ final class Anwendung
$bestand = new Bestand($this->db); $bestand = new Bestand($this->db);
match (true) { 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), '/installation' === $pfad && 'GET' === $methode => $this->detail($bestand, $fassungen),
'/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen), '/auftrag' === $pfad && 'POST' === $methode => $this->auftrag($bestand, $fassungen),
'/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen), '/auftrag/alle' === $pfad && 'POST' === $methode => $this->alleAuftraege($bestand, $fassungen),
@@ -87,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'); header('Content-Type: application/json; charset=UTF-8');
if ('POST' !== $methode) { if ('POST' !== $methode) {
@@ -97,7 +101,8 @@ final class Anwendung
return; 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) file_get_contents('php://input', false, null, 0, 16385),
(string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''), (string) ($_SERVER['HTTP_X_KANTSITES_SIGNATUR'] ?? ''),
(string) ($_SERVER['REMOTE_ADDR'] ?? ''), (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
@@ -109,36 +114,106 @@ final class Anwendung
echo $antwort['body']; 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(); $hoechste = $bestand->hoechsteFassung('kanttresor');
$alle = $bestand->alle($neueste['version'] ?? null);
$this->zeigen('uebersicht', [ return null === $hoechste ? null : ['version' => $hoechste];
'titel' => 'Installationen', }
/** @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 = [
'aktuell' => static fn (array $i): bool => null !== $neueste && !$i['veraltet'] && 'unbekannt' !== $i['fassung'],
'veraltet' => static fn (array $i): bool => $i['veraltet'],
'still' => static fn (array $i): bool => $i['still'],
'unbekannt' => static fn (array $i): bool => $i['unbekannt'],
];
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));
}
if (null === $neueste) {
$zahlen['aktuell'] = null;
}
$suche = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
$gewaehlt = is_string($_GET['filter'] ?? null) && isset($filter[$_GET['filter']]) ? $_GET['filter'] : null;
$treffer = array_values(array_filter($alle, static function (array $i) use ($suche, $gewaehlt, $filter): bool {
if (null !== $gewaehlt && !$filter[$gewaehlt]($i)) {
return false;
}
if ('' === $suche) {
return true;
}
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;
}
}
return false;
}));
$proSeite = self::PRO_SEITE;
$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', 'gestoert' => 'Dienst gestört oder Speicher knapp'];
$von = [] === $treffer ? 0 : ($seite - 1) * $proSeite + 1;
$bis = min(count($treffer), $seite * $proSeite);
$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' => $tresor ? 'KantTresor' : 'Installationen',
'produkt' => $produkt,
'pfad' => $tresor ? '/kanttresor' : '/',
'trefferText' => $trefferText,
'neueste' => $neueste, 'neueste' => $neueste,
'installationen' => $alle, 'installationen' => array_slice($treffer, ($seite - 1) * $proSeite, $proSeite),
'zahlen' => [ 'treffer' => count($treffer),
'alle' => count($alle), 'suche' => $suche,
'aktuell' => null === $neueste ? null : count(array_filter($alle, static fn (array $i): bool => !$i['veraltet'] && 'unbekannt' !== $i['fassung'])), 'filter' => $gewaehlt,
'veraltet' => count(array_filter($alle, static fn (array $i): bool => $i['veraltet'])), 'seite' => $seite,
'still' => count(array_filter($alle, static fn (array $i): bool => $i['still'])), 'seiten' => $seiten,
'unbekannt' => count(array_filter($alle, static fn (array $i): bool => $i['unbekannt'])), 'zahlen' => $zahlen,
],
// Verstummte bleiben außen vor: Sie holen den Auftrag womöglich nie ab. Wartende haben schon einen. // 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 private function detail(Bestand $bestand, Fassungen $fassungen): void
{ {
$neueste = $fassungen->neueste(); $id = (string) ($_GET['id'] ?? '');
$eine = $bestand->eine((string) ($_GET['id'] ?? ''), $neueste['version'] ?? null); $produkt = $bestand->produkt($id);
$neueste = 'kanttresor' === $produkt ? $this->tresorNeueste($bestand) : $fassungen->neueste();
$eine = $bestand->eine($id, $neueste['version'] ?? null);
if (null === $eine) { if (null === $eine) {
$this->nichtGefunden(); $this->nichtGefunden();
return; 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 private function auftrag(Bestand $bestand, Fassungen $fassungen): void
@@ -193,9 +268,10 @@ final class Anwendung
return; return;
} }
$produkt = $bestand->produkt($id);
$bestand->entfernen($id); $bestand->entfernen($id);
$this->hinweis('Eintrag entfernt. Meldet sich die Installation wieder, erscheint sie neu.', 'ok'); $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 private function fassungNeu(Fassungen $fassungen): void
@@ -311,7 +387,7 @@ final class Anwendung
private function kopfzeilen(): void private function kopfzeilen(): void
{ {
header("Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"); header("Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'");
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer'); header('Referrer-Policy: no-referrer');
header('X-Robots-Tag: noindex, nofollow'); header('X-Robots-Tag: noindex, nofollow');
@@ -319,18 +395,24 @@ final class Anwendung
} }
/** @param array<string, mixed> $daten */ /** @param array<string, mixed> $daten */
private function zeigen(string $vorlage, array $daten, bool $angemeldet = true): void private function zeigen(string $vorlage, array $daten, bool $angemeldet = true, bool $ohneRahmen = false): void
{ {
header('Content-Type: text/html; charset=UTF-8'); header('Content-Type: text/html; charset=UTF-8');
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''), (string) ($daten['pfad'] ?? '/'));
if ($ohneRahmen) {
echo $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
return;
}
$hinweis = $_SESSION['hinweis'] ?? null; $hinweis = $_SESSION['hinweis'] ?? null;
unset($_SESSION['hinweis']); unset($_SESSION['hinweis']);
$v = new Ansicht((string) ($_SESSION['csrf'] ?? ''));
$inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten); $inhalt = $v->vorlage($this->wurzel.'/app/vorlagen/'.$vorlage.'.php', $daten);
echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [ echo $v->vorlage($this->wurzel.'/app/vorlagen/rahmen.php', [
'titel' => $daten['titel'] ?? 'Zentrale', 'titel' => $daten['titel'] ?? 'Zentrale',
'inhalt' => $inhalt, 'inhalt' => $inhalt,
'hinweis' => $hinweis, 'hinweis' => $hinweis,
'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null, 'konto' => $angemeldet ? ($_SESSION['konto'] ?? null) : null,
'produkt' => $daten['produkt'] ?? null,
]); ]);
} }
} }
+117 -36
View File
@@ -11,6 +11,10 @@ namespace Zentrale;
* Schlüssel mit; danach muss jede Meldung damit unterschrieben sein (HMAC-SHA256 über den * 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 * Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die
* Installation nur Aufträge von hier annimmt. * 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 final class Api
{ {
@@ -36,36 +40,17 @@ final class Api
if (is_string($pruef)) { if (is_string($pruef)) {
return $this->fehler(400, $pruef); return $this->fehler(400, $pruef);
} }
$signatur = strtolower(trim($signatur)); $anmeldung = $this->anmeldung($daten, $body, $signatur, 'kantsites');
if (isset($anmeldung['status'])) {
$gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?'); return $anmeldung;
$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.');
} }
['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung;
$jetzt = Datenbank::jetzt(); $jetzt = Datenbank::jetzt();
$domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST));
$update = is_array($daten['update'] ?? null) ? $daten['update'] : []; $update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
$werte = [ $werte = [
'adresse' => $daten['adresse'], 'adresse' => $daten['adresse'],
'domain' => $domain, 'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)),
'fassung' => $daten['fassung'], 'fassung' => $daten['fassung'],
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20), 'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20), 'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
@@ -80,18 +65,7 @@ final class Api
$this->db->beginTransaction(); $this->db->beginTransaction();
try { try {
if (null === $installation) { $this->speichern($daten['id'], $schluessel, $installation, $werte);
$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']]);
}
$ergebnis = $daten['auftrag'] ?? null; $ergebnis = $daten['auftrag'] ?? null;
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? 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)]; 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<string, mixed> $daten
*
* @return array{installation: ?array<string, mixed>, 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<string, mixed>|null $installation
* @param array<string, mixed> $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<string, mixed> $daten */ /** @param array<string, mixed> $daten */
private function pruefen(array $daten): ?string private function pruefen(array $daten): ?string
{ {
+77 -2
View File
@@ -15,14 +15,33 @@ final class Bestand
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */ /** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
public const AUFTRAG_STUNDEN = 2; public const AUFTRAG_STUNDEN = 2;
/**
* Erste PHP-Fassung je Zweig ohne bekannte kritische Lücke (zuletzt php2shell,
* CVE-2026-109618/-109619, 21.10.2026). Bei der nächsten PHP-Sicherheitsmeldung hier nachziehen.
*/
public const PHP_SICHER = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
/** Bis hierher gibt es keine Sicherheitsupdates mehr. */
public const PHP_ALT_BIS = '8.1';
public function __construct(private \PDO $db) public function __construct(private \PDO $db)
{ {
} }
/** 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<array<string, mixed>> */ /** @return list<array<string, mixed>> */
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 = []; $auftraege = [];
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) { foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
$auftraege[$auftrag['installation']] = $auftrag; $auftraege[$auftrag['installation']] = $auftrag;
@@ -31,6 +50,34 @@ final class Bestand
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen); 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<string, mixed>|null */ /** @return array<string, mixed>|null */
public function eine(string $id, ?string $neueste): ?array public function eine(string $id, ?string $neueste): ?array
{ {
@@ -106,7 +153,10 @@ final class Bestand
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft. // Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
$laeuft = 'laeuft' === ($zustand['art'] ?? null); $laeuft = 'laeuft' === ($zustand['art'] ?? null);
$tresor = 'kanttresor' === $zeile['produkt'];
$grund = match (true) { $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.', null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.', !$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.', 'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
@@ -124,9 +174,34 @@ final class Bestand
'auftrag_zustand' => $zustand, 'auftrag_zustand' => $zustand,
'kann_update' => '' === $grund, 'kann_update' => '' === $grund,
'kein_update_grund' => $grund, 'kein_update_grund' => $grund,
'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),
]; ];
} }
/**
* Warum die gemeldete PHP-Fassung als unsicher gilt, oder null. Unbekannte und neuere
* Zweige (etwa 8.6-Vorabfassungen) gelten nicht als unsicher.
*/
public static function phpLuecke(string $php): ?string
{
if (1 !== preg_match('/^(\d+)\.(\d+)\.(\d+)/', $php, $teile)) {
return null;
}
$zweig = $teile[1].'.'.$teile[2];
$fassung = $zweig.'.'.$teile[3];
if (version_compare($zweig, self::PHP_ALT_BIS, '<=')) {
return sprintf('PHP %s bekommt keine Sicherheitsupdates mehr – auf PHP %s umstellen.', $zweig, self::PHP_SICHER['8.4']);
}
$sicher = self::PHP_SICHER[$zweig] ?? null;
if (null !== $sicher && version_compare($fassung, $sicher, '<')) {
return sprintf('PHP %s hat eine bekannte kritische Sicherheitslücke – mindestens %s nötig.', $fassung, $sicher);
}
return null;
}
/** /**
* @param array<string, mixed> $auftrag * @param array<string, mixed> $auftrag
* *
+9
View File
@@ -47,6 +47,15 @@ final class Datenbank
)', )',
'CREATE INDEX auftrag_installation ON auftrag (installation, id)', '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 public static function oeffnen(string $datei): \PDO
+99
View File
@@ -0,0 +1,99 @@
<?php /** @var Zentrale\Ansicht $v */ ?>
<div id="liste" tabindex="-1" data-treffer="<?= $v->e($trefferText) ?>">
<?php $tresor = 'kanttresor' === $produkt; ?>
<?php if (0 === $zahlen['alle']) { ?>
<div class="karte leer">
<h2>Noch keine Meldung</h2>
<?php if ($tresor) { ?>
<p>Sobald eine KantTresor-Instanz ab Fassung 1.82.0 läuft, meldet sie sich hier – alle 15 Minuten über den Hintergrunddienst (beat).</p>
<?php } else { ?>
<p>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.</p>
<?php } ?>
</div>
<?php } elseif (0 === $treffer) { ?>
<div class="karte leer">
<h2>Nichts gefunden</h2>
<p>Keine <?= $tresor ? 'Instanz' : 'Installation' ?> passt zu Suche und Filter. <a href="<?= $v->e($pfad) ?>">Alle anzeigen</a></p>
</div>
<?php } elseif ($tresor) { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<caption class="unsichtbar">Gemeldete KantTresor-Instanzen, Seite <?= $seite ?> von <?= $seiten ?></caption>
<thead>
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Mandanten</th><th scope="col">Speicher</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th></tr>
</thead>
<tbody>
<?php foreach ($installationen as $i) { ?>
<tr>
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span></td>
<td data-label="Mandanten"><?= $v->e($i['mandanten'] ?? '–') ?></td>
<td data-label="Speicher"><?php if (null === $i['speicher']) { ?>–<?php } else { ?><span class="<?= $i['speicher_knapp'] ? 'knapp' : '' ?>"><?= (int) $i['speicher'] ?> % belegt</span><?php } ?></td>
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
<td data-label="Zustand">
<?php if ('' !== (string) $i['stoerungen']) { ?><span class="pille p-fehler">Gestört: <?= $v->e($i['stoerungen']) ?></span> <?php } ?>
<?php if ($i['speicher_knapp']) { ?><span class="pille p-warn">Speicher knapp</span> <?php } ?>
<?php if (!$i['gestoert']) { ?><span class="pille p-ok">Alle Dienste laufen</span><?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } else { ?>
<div class="tabelle-rahmen">
<table class="tabelle">
<caption class="unsichtbar">Gemeldete Installationen, Seite <?= $seite ?> von <?= $seiten ?></caption>
<thead>
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr>
</thead>
<tbody>
<?php foreach ($installationen as $i) { ?>
<tr>
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
<td data-label="Zustand">
<?php if (null !== $i['php_luecke']) { ?><span class="pille p-fehler" title="<?= $v->e($i['php_luecke']) ?>">PHP <?= $v->e($i['php']) ?> unsicher</span> <?php } ?>
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung'] && null === $i['php_luecke']) { ?><span class="klein">–</span><?php } ?>
</td>
<td class="aktion">
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
</form>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>
<?php if ($treffer > 0 && $seiten > 1) { ?>
<nav class="seiten" aria-label="Seiten">
<?php $basis = ['q' => $suche, 'filter' => $filter]; ?>
<?php if ($seite > 1) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite - 1])) ?>" rel="prev">← Zurück</a><?php } ?>
<ul>
<?php
// Erste, letzte und zwei um die aktuelle; dazwischen Auslassungen.
$zeigen = array_unique(array_filter([1, $seite - 2, $seite - 1, $seite, $seite + 1, $seite + 2, $seiten], static fn (int $n): bool => $n >= 1 && $n <= $seiten));
sort($zeigen);
$vorher = 0;
foreach ($zeigen as $n) {
if ($n > $vorher + 1) { ?><li aria-hidden="true">…</li><?php }
$vorher = $n; ?>
<li><?php if ($n === $seite) { ?><span aria-current="page"><span class="unsichtbar">Seite </span><?= $n ?></span><?php } else { ?><a href="<?= $v->e($v->adresse([...$basis, 'seite' => $n])) ?>"><span class="unsichtbar">Seite </span><?= $n ?></a><?php } ?></li>
<?php } ?>
</ul>
<?php if ($seite < $seiten) { ?><a class="knopf knopf-leise" href="<?= $v->e($v->adresse([...$basis, 'seite' => $seite + 1])) ?>" rel="next">Weiter →</a><?php } ?>
</nav>
<?php } ?>
</div>
+32 -2
View File
@@ -1,11 +1,37 @@
<?php /** @var Zentrale\Ansicht $v */ ?> <?php /** @var Zentrale\Ansicht $v */ ?>
<p class="zurueck"><a href="/">← Alle Installationen</a></p> <?php $tresor = 'kanttresor' === $i['produkt']; ?>
<p class="zurueck"><?php if ($tresor) { ?><a href="/kanttresor">← Alle KantTresor-Instanzen</a><?php } else { ?><a href="/">← Alle Installationen</a><?php } ?></p>
<div class="titelzeile"> <div class="titelzeile">
<h1><?= $v->e($i['domain']) ?></h1> <h1><?= $v->e($i['domain']) ?></h1>
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?> <?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
</div> </div>
<div class="raster"> <div class="raster">
<?php if ($tresor) { ?>
<section class="karte" aria-labelledby="t-stand">
<h2 id="t-stand">Stand</h2>
<dl class="fakten">
<dt>Adresse</dt><dd><a href="<?= $v->e($i['adresse']) ?>" rel="noopener noreferrer"><?= $v->e($i['adresse']) ?></a></dd>
<?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?>
<dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – höchste gemeldete ist '.$v->e($neueste['version']) : '' ?></dd>
<dt>Laufzeit</dt><dd><?= $v->e($i['laufzeit'] ?: '–') ?></dd>
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
<dt>Mandanten</dt><dd><?= $v->e($i['mandanten'] ?? '–') ?></dd>
<dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd>
<dt>Erste Meldung</dt><dd><?= $v->e($v->zeit($i['erste_meldung'])) ?></dd>
<dt>IP-Adresse</dt><dd><?= $v->e($i['ip'] ?: '–') ?></dd>
</dl>
</section>
<section class="karte" aria-labelledby="t-betrieb">
<h2 id="t-betrieb">Betrieb</h2>
<dl class="fakten">
<dt>Dienste</dt><dd><?php if ('' !== (string) $i['stoerungen']) { ?><span class="pille p-fehler">Gestört: <?= $v->e($i['stoerungen']) ?></span><?php } else { ?><span class="pille p-ok">Alle Dienste laufen</span><?php } ?></dd>
<dt>Speicher</dt><dd><?php if (null === $i['speicher']) { ?>–<?php } else { ?><span class="<?= $i['speicher_knapp'] ? 'knapp' : '' ?>"><?= (int) $i['speicher'] ?> % belegt</span><?= $i['speicher_knapp'] ? ' – <strong>knapp</strong>' : '' ?><?php } ?></dd>
</dl>
<p class="klein abstand">Stand der letzten Meldung. Aktualisiert wird KantTresor auf dem Server mit <code>./update.sh</code>, nicht von hier.</p>
</section>
<?php } else { ?>
<section class="karte" aria-labelledby="t-stand"> <section class="karte" aria-labelledby="t-stand">
<h2 id="t-stand">Stand</h2> <h2 id="t-stand">Stand</h2>
<dl class="fakten"> <dl class="fakten">
@@ -13,7 +39,8 @@
<?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?> <?php if (null !== $i['domain_vorher']) { ?><dt>Vorher gemeldet als</dt><dd><?= $v->e($i['domain_vorher']) ?></dd><?php } ?>
<dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – neueste ist '.$v->e($neueste['version']) : '' ?></dd> <dt>Fassung</dt><dd><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?= $i['veraltet'] && null !== $neueste ? ' – neueste ist '.$v->e($neueste['version']) : '' ?></dd>
<dt>Aktualisiert über</dt><dd><?= 'git' === $i['art'] ? 'Git-Arbeitskopie' : 'Releasearchiv' ?></dd> <dt>Aktualisiert über</dt><dd><?= 'git' === $i['art'] ? 'Git-Arbeitskopie' : 'Releasearchiv' ?></dd>
<dt>PHP</dt><dd><?= $v->e($i['php'] ?: '–') ?></dd> <dt>PHP</dt><dd><?php if (null !== $i['php_luecke']) { ?><span class="pille p-fehler"><?= $v->e($i['php']) ?> unsicher</span>
<span class="klein block"><?= $v->e($i['php_luecke']) ?> Das Update macht der Hoster bzw. das Hosting-Panel, nicht KantSites.</span><?php } else { ?><?= $v->e($i['php'] ?: '–') ?><?php } ?></dd>
<dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd> <dt>Datenbank</dt><dd><?= $v->e($i['datenbank'] ?: '–') ?></dd>
<dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></dd> <dt>Wartungsseite</dt><dd><?= $i['wartung'] ? 'an' : 'aus' ?></dd>
<dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd> <dt>Letzte Meldung</dt><dd><?= $v->e($v->zeit($i['letzte_meldung'])) ?> (<?= $v->e($v->vor($i['alter'])) ?>)<?= $i['still'] ? ' – <strong>seit über 48 Stunden still</strong>' : '' ?></dd>
@@ -51,6 +78,7 @@
</form> </form>
<?php } ?> <?php } ?>
</section> </section>
<?php } ?>
<section class="karte" aria-labelledby="t-kunde"> <section class="karte" aria-labelledby="t-kunde">
<h2 id="t-kunde">Kunde</h2> <h2 id="t-kunde">Kunde</h2>
@@ -65,6 +93,7 @@
</section> </section>
</div> </div>
<?php if (!$tresor) { ?>
<section class="karte" aria-labelledby="t-verlauf"> <section class="karte" aria-labelledby="t-verlauf">
<h2 id="t-verlauf">Aufträge</h2> <h2 id="t-verlauf">Aufträge</h2>
<?php if ([] === $i['verlauf']) { ?> <?php if ([] === $i['verlauf']) { ?>
@@ -87,6 +116,7 @@
</div> </div>
<?php } ?> <?php } ?>
</section> </section>
<?php } ?>
<details class="karte gefahr"> <details class="karte gefahr">
<summary>Eintrag entfernen</summary> <summary>Eintrag entfernen</summary>
+11 -1
View File
@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title><?= $v->e($titel) ?> · KantSites Zentrale</title> <title><?= $v->e($titel) ?> · KantSites Zentrale</title>
<link rel="stylesheet" href="/app.css?v=1"> <link rel="stylesheet" href="/app.css?v=4">
<script src="/app.js?v=2" defer></script>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23173f6b'/%3E%3Cpath d='M9 8v16M9 16l9-8M12.5 13l7 11' stroke='%23fff' stroke-width='3.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
</head> </head>
<body> <body>
@@ -23,6 +24,15 @@
<?php } ?> <?php } ?>
</div> </div>
</header> </header>
<?php if (null !== $konto) { ?>
<nav class="reiter" aria-label="Produkte">
<ul class="reiter-innen">
<?php foreach (['kantsites' => ['/', 'KantSites'], 'kanttresor' => ['/kanttresor', 'KantTresor']] as $name => [$ziel, $text]) { ?>
<li><a href="<?= $ziel ?>"<?= $produkt === $name ? ' aria-current="page"' : '' ?>><?= $text ?></a></li>
<?php } ?>
</ul>
</nav>
<?php } ?>
<main id="inhalt" class="seite" tabindex="-1"> <main id="inhalt" class="seite" tabindex="-1">
<?php if (null !== $hinweis) { ?> <?php if (null !== $hinweis) { ?>
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p> <p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
+47 -52
View File
@@ -1,69 +1,64 @@
<?php /** @var Zentrale\Ansicht $v */ ?> <?php /** @var Zentrale\Ansicht $v */ ?>
<?php $tresor = 'kanttresor' === $produkt; ?>
<div class="titelzeile"> <div class="titelzeile">
<h1>Installationen</h1> <h1><?= $tresor ? 'KantTresor-Instanzen' : 'Installationen' ?></h1>
<form method="post" action="/fassung" class="neueste"> <?php if ($tresor) { ?>
<?= $v->csrf() ?> <p class="neueste">Höchste gemeldete Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></p>
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span> <?php } else { ?>
<button type="submit" class="knopf knopf-leise">Neu abfragen</button> <form method="post" action="/fassung" class="neueste">
</form> <?= $v->csrf() ?>
<span>Neueste Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></span>
<button type="submit" class="knopf knopf-leise">Neu abfragen</button>
</form>
<?php } ?>
</div> </div>
<ul class="zahlen" aria-label="Zusammenfassung"> <?php
<li><strong><?= $zahlen['alle'] ?></strong> gesamt</li> $kacheln = [
<li class="z-ok"><strong><?= $zahlen['aktuell'] ?? '–' ?></strong> aktuell</li> null => ['', 'gesamt', $zahlen['alle']],
<li class="z-warn"><strong><?= $zahlen['veraltet'] ?></strong> veraltet</li> 'aktuell' => ['z-ok', 'aktuell', $zahlen['aktuell'] ?? '–'],
<li class="z-still"><strong><?= $zahlen['still'] ?></strong> ohne Meldung seit 48 h</li> 'veraltet' => ['z-warn', 'veraltet', $zahlen['veraltet']],
<li class="z-fremd"><strong><?= $zahlen['unbekannt'] ?></strong> ohne Kundenzuordnung</li> 'still' => ['z-still', 'ohne Meldung seit 48 h', $zahlen['still']],
</ul> 'unbekannt' => ['z-fremd', 'ohne Kundenzuordnung', $zahlen['unbekannt']],
];
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']];
}
?>
<nav aria-label="Nach Zustand filtern">
<ul class="zahlen">
<?php foreach ($kacheln as $name => [$klasse, $text, $zahl]) { $aktiv = ($filter ?? '') === $name; ?>
<li class="<?= $klasse ?><?= $aktiv ? ' gewaehlt' : '' ?>"><a href="<?= $v->e($v->adresse(['q' => $suche, 'filter' => '' === $name ? null : $name])) ?>"<?= $aktiv ? ' aria-current="true"' : '' ?>><strong><?= $v->e($zahl) ?></strong> <?= $v->e($text) ?></a></li>
<?php } ?>
</ul>
</nav>
<?php if ([] !== $aktualisierbar && null !== $neueste) { ?> <?php if ([] !== $aktualisierbar && null !== $neueste) { ?>
<form method="post" action="/auftrag/alle" class="karte alle"> <form method="post" action="/auftrag/alle" class="karte alle">
<?= $v->csrf() ?> <?= $v->csrf() ?>
<div> <div>
<h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2> <h2>Alle auf <?= $v->e($neueste['version']) ?> aktualisieren</h2>
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_column($aktualisierbar, 'domain'))) ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p> <?php $namen = array_column($aktualisierbar, 'domain'); ?>
<p><?= count($aktualisierbar) ?> Installation(en): <?= $v->e(implode(', ', array_slice($namen, 0, 8))) ?><?= count($namen) > 8 ? ' und '.(count($namen) - 8).' weitere' : '' ?>. Jede holt den Auftrag mit ihrer nächsten Meldung ab, sichert sich vorher und schaltet bei einem Fehler zurück.</p>
<label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label> <label class="bestaetigen"><input type="checkbox" name="bestaetigt" value="ja" required> Ja, alle <?= count($aktualisierbar) ?> aktualisieren</label>
</div> </div>
<button type="submit" class="knopf">Alle aktualisieren</button> <button type="submit" class="knopf">Alle aktualisieren</button>
</form> </form>
<?php } ?> <?php } ?>
<?php if ([] === $installationen) { ?> <?php if ($zahlen['alle'] > 0) { ?>
<div class="karte leer"> <form method="get" action="<?= $v->e($pfad) ?>" class="suche" role="search">
<h2>Noch keine Meldung</h2> <?php if (null !== $filter) { ?><input type="hidden" name="filter" value="<?= $v->e($filter) ?>"><?php } ?>
<p>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.</p> <label for="q">Suchen</label>
</div> <div class="suche-zeile">
<?php } else { ?> <input id="q" name="q" type="search" value="<?= $v->e($suche) ?>" placeholder="<?= $tresor ? 'Domain, Kunde, Fassung, Störung oder IP' : 'Domain, Kunde, Fassung, PHP oder IP' ?>" autocomplete="off" aria-describedby="treffer" aria-controls="liste">
<div class="tabelle-rahmen"> <button type="submit" class="knopf">Suchen</button>
<table class="tabelle"> <a class="knopf knopf-leise zuruecksetzen" href="<?= $v->e($pfad) ?>"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
<caption class="unsichtbar">Alle gemeldeten Installationen</caption> </div>
<thead> <p class="klein" id="treffer" role="status"><?= $v->e($trefferText) ?></p>
<tr><th scope="col">Domain</th><th scope="col">Kunde</th><th scope="col">Fassung</th><th scope="col">Letzte Meldung</th><th scope="col">Zustand</th><th scope="col"><span class="unsichtbar">Aktion</span></th></tr> </form>
</thead>
<tbody>
<?php foreach ($installationen as $i) { ?>
<tr>
<th scope="row" data-label="Domain"><a href="/installation?id=<?= $v->e(rawurlencode($i['id'])) ?>"><?= $v->e($i['domain']) ?></a>
<?php if (null !== $i['domain_vorher']) { ?><span class="klein">vorher <?= $v->e($i['domain_vorher']) ?></span><?php } ?></th>
<td data-label="Kunde"><?= $i['unbekannt'] ? '<span class="marke-fremd">Unbekannt</span>' : $v->e($i['notiz']) ?></td>
<td data-label="Fassung"><span class="fassung <?= $i['veraltet'] ? 'f-alt' : 'f-ok' ?>"><?= $v->e($i['fassung']) ?></span><?php if ('git' === $i['art']) { ?> <span class="klein">Git</span><?php } ?></td>
<td data-label="Letzte Meldung"><span class="<?= $i['still'] ? 'still' : '' ?>"><?= $v->e($v->vor($i['alter'])) ?></span></td>
<td data-label="Zustand">
<?php if ($i['wartung']) { ?><span class="pille p-warn">Wartung</span> <?php } ?>
<?php if (null !== $i['auftrag_zustand']) { ?><span class="pille p-<?= $v->e($i['auftrag_zustand']['art']) ?>"><?= $v->e($i['auftrag_zustand']['text']) ?></span><?php } elseif (!$i['wartung']) { ?><span class="klein">–</span><?php } ?>
</td>
<td class="aktion">
<?php if ($i['kann_update'] && null !== $neueste && 'wartet' !== ($i['auftrag_zustand']['art'] ?? null)) { ?>
<form method="post" action="/auftrag">
<?= $v->csrf() ?>
<input type="hidden" name="id" value="<?= $v->e($i['id']) ?>">
<button type="submit" class="knopf knopf-klein">Auf <?= $v->e($neueste['version']) ?><span class="unsichtbar"> aktualisieren: <?= $v->e($i['domain']) ?></span></button>
</form>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?> <?php } ?>
<?php require __DIR__.'/_liste.php'; ?>
+31
View File
@@ -21,6 +21,8 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; } html { -webkit-text-size-adjust: 100%; }
body { body {
@@ -72,6 +74,9 @@ code { background: var(--marke-hell); padding: 1px 5px; border-radius: 4px; font
.zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; } .zahlen { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; } .zahlen li { flex: 1 1 110px; background: var(--flaeche); border: 1px solid var(--linie); border-radius: var(--radius); padding: 10px 14px; color: var(--leise); font-size: .9rem; }
.zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; } .zahlen strong { display: block; font-size: 1.35rem; color: var(--text); line-height: 1.2; }
.zahlen a { display: block; margin: -10px -14px; padding: 10px 14px; color: inherit; text-decoration: none; border-radius: inherit; }
.zahlen a:hover { background: var(--marke-hell); }
.zahlen li.gewaehlt { border-color: var(--marke); box-shadow: inset 0 0 0 1px var(--marke); }
.z-ok strong { color: var(--ok); } .z-ok strong { color: var(--ok); }
.z-warn strong { color: var(--warn); } .z-warn strong { color: var(--warn); }
.z-still strong { color: var(--still); } .z-still strong { color: var(--still); }
@@ -143,6 +148,22 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
.leer p { color: var(--leise); margin: 0; } .leer p { color: var(--leise); margin: 0; }
.suche { margin-bottom: 14px; }
.suche label { margin-top: 0; }
.suche-zeile { display: flex; gap: 8px; flex-wrap: wrap; }
.suche-zeile input { flex: 1 1 260px; width: auto; font: inherit; font-size: 16px; padding: 9px 11px; border: 1px solid #b9c3cf; border-radius: 8px; background: #fff; color: var(--text); }
.suche-zeile .knopf-leise { min-height: 40px; text-decoration: none; }
.suche .klein { margin: 8px 0 0; }
.seiten { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
.seiten .knopf-leise { text-decoration: none; }
.seiten ul { list-style: none; display: flex; gap: 4px; margin: 0; padding: 0; flex-wrap: wrap; }
.seiten li a, .seiten li span[aria-current] { display: inline-grid; place-items: center; min-width: 36px; min-height: 36px; padding: 0 8px; border-radius: 8px; text-decoration: none; }
.seiten li a { color: var(--marke); border: 1px solid var(--linie); background: var(--flaeche); }
.seiten li a:hover { background: var(--marke-hell); }
.seiten li span[aria-current] { background: var(--marke); color: #fff; font-weight: 700; }
.seiten li[aria-hidden] { align-self: center; color: var(--leise); padding: 0 2px; }
@media (max-width: 760px) { @media (max-width: 760px) {
h1 { font-size: 1.35rem; } h1 { font-size: 1.35rem; }
.karte { padding: 16px; } .karte { padding: 16px; }
@@ -157,3 +178,13 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
.fakten { grid-template-columns: 1fr; gap: 0; } .fakten { grid-template-columns: 1fr; gap: 0; }
.fakten dd { margin-bottom: 8px; } .fakten dd { margin-bottom: 8px; }
} }
#liste:focus { outline: none; }
#liste[aria-busy="true"] { opacity: .6; transition: opacity .15s; }
.reiter { background: var(--flaeche); border-bottom: 1px solid var(--linie); }
.reiter-innen { max-width: 1180px; margin: 0 auto; padding: 0 16px; list-style: none; display: flex; gap: 4px; overflow-x: auto; }
.reiter a { display: block; padding: 12px 14px 10px; color: var(--leise); font-weight: 600; text-decoration: none; border-bottom: 3px solid transparent; white-space: nowrap; }
.reiter a:hover { color: var(--marke); }
.reiter a[aria-current="page"] { color: var(--marke); border-bottom-color: var(--marke); }
.knapp { color: var(--warn); font-weight: 600; }
+118
View File
@@ -0,0 +1,118 @@
// Live-Suche der Übersicht: Tippen, Filter und Seitenwechsel laden nur die Liste nach.
// Ohne JavaScript bleibt alles ein gewöhnliches Formular mit Links.
(() => {
const form = document.querySelector('form.suche');
const liste = document.getElementById('liste');
if (!form || !liste || !window.fetch || !window.URL) {
return;
}
const feld = form.querySelector('#q');
const status = document.getElementById('treffer');
const zuruecksetzen = form.querySelector('.zuruecksetzen');
const knopf = form.querySelector('button[type="submit"]');
const filterFeld = () => form.querySelector('input[name="filter"]');
knopf.hidden = true;
let warten = 0;
let laufend = null;
/** Die Adresse aus Suchfeld und Filter, ohne leere Werte. */
const adresseAusFormular = () => {
const url = new URL(form.action, location.href);
const q = feld.value.trim();
if (q !== '') url.searchParams.set('q', q);
const filter = filterFeld();
if (filter && filter.value !== '') url.searchParams.set('filter', filter.value);
return url;
};
/** Kacheln und Filterfeld an Suche und Filter der neuen Adresse angleichen. */
const umgebungAnpassen = (url) => {
const q = url.searchParams.get('q') || '';
const filter = url.searchParams.get('filter') || '';
document.querySelectorAll('.zahlen a').forEach((a) => {
const ziel = new URL(a.href, location.href);
if (q === '') ziel.searchParams.delete('q'); else ziel.searchParams.set('q', q);
ziel.searchParams.delete('seite');
a.href = ziel.pathname + ziel.search;
const gewaehlt = (ziel.searchParams.get('filter') || '') === filter;
a.parentElement.classList.toggle('gewaehlt', gewaehlt);
if (gewaehlt) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
});
let feldFilter = filterFeld();
if (filter !== '' && !feldFilter) {
feldFilter = document.createElement('input');
feldFilter.type = 'hidden';
feldFilter.name = 'filter';
form.prepend(feldFilter);
}
if (feldFilter) {
if (filter === '') feldFilter.remove(); else feldFilter.value = filter;
}
if (feld.value.trim() !== q) feld.value = q;
zuruecksetzen.hidden = q === '' && filter === '';
};
const laden = (url, verlauf) => {
if (laufend) laufend.abort();
laufend = new AbortController();
const abruf = new URL(url, location.href);
abruf.searchParams.set('teil', 'liste');
liste.setAttribute('aria-busy', 'true');
fetch(abruf, { signal: laufend.signal, credentials: 'same-origin', headers: { Accept: 'text/html' } })
.then((antwort) => {
// Abgelaufene Sitzung: Die Antwort ist die Anmeldeseite, nicht die Liste.
if (!antwort.ok || antwort.redirected) throw new Error('voll');
return antwort.text();
})
.then((html) => {
const neu = new DOMParser().parseFromString(html, 'text/html').getElementById('liste');
if (!neu) throw new Error('voll');
liste.replaceChildren(...neu.childNodes);
liste.dataset.treffer = neu.dataset.treffer || '';
status.textContent = liste.dataset.treffer;
const sichtbar = new URL(url, location.href);
if (verlauf === 'push') history.pushState(null, '', sichtbar.pathname + sichtbar.search);
else if (verlauf === 'replace') history.replaceState(null, '', sichtbar.pathname + sichtbar.search);
umgebungAnpassen(sichtbar);
})
.catch((fehler) => {
if (fehler.name !== 'AbortError') location.href = url.toString();
})
.finally(() => liste.removeAttribute('aria-busy'));
};
feld.addEventListener('input', () => {
clearTimeout(warten);
warten = setTimeout(() => laden(adresseAusFormular(), 'replace'), 250);
});
form.addEventListener('submit', (ereignis) => {
ereignis.preventDefault();
clearTimeout(warten);
laden(adresseAusFormular(), 'replace');
});
zuruecksetzen.addEventListener('click', (ereignis) => {
ereignis.preventDefault();
feld.value = '';
const filter = filterFeld();
if (filter) filter.remove();
laden(new URL(form.action, location.href), 'push');
feld.focus();
});
// Filterkacheln und Seitenzahlen: nur die Liste wechseln.
document.addEventListener('click', (ereignis) => {
const link = ereignis.target.closest('.zahlen a, #liste .seiten a');
if (!link || ereignis.ctrlKey || ereignis.metaKey || ereignis.shiftKey || ereignis.button !== 0) return;
ereignis.preventDefault();
laden(new URL(link.href, location.href), 'push');
if (link.closest('.seiten')) {
form.scrollIntoView({ block: 'start' });
liste.focus({ preventScroll: true });
}
});
window.addEventListener('popstate', () => laden(new URL(location.href), null));
})();