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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9d80af3795
commit
968d19b611
15 files changed
+392
-80
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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“.
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+46
-20
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+117
-36
@@ -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<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 */
|
||||
private function pruefen(array $daten): ?string
|
||||
{
|
||||
|
||||
@@ -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<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 = [];
|
||||
@@ -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<string, mixed>|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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
<?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>
|
||||
<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 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 Installation passt zu Suche und Filter. <a href="/">Alle anzeigen</a></p>
|
||||
<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">
|
||||
@@ -44,7 +76,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if ($seiten > 1) { ?>
|
||||
<?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 } ?>
|
||||
@@ -63,5 +96,4 @@
|
||||
<?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 } ?>
|
||||
<?php } ?>
|
||||
</div>
|
||||
@@ -1,11 +1,37 @@
|
||||
<?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">
|
||||
<h1><?= $v->e($i['domain']) ?></h1>
|
||||
<?php if ($i['unbekannt']) { ?><span class="marke-fremd">Unbekannt</span><?php } ?>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h2 id="t-stand">Stand</h2>
|
||||
<dl class="fakten">
|
||||
@@ -52,6 +78,7 @@
|
||||
</form>
|
||||
<?php } ?>
|
||||
</section>
|
||||
<?php } ?>
|
||||
|
||||
<section class="karte" aria-labelledby="t-kunde">
|
||||
<h2 id="t-kunde">Kunde</h2>
|
||||
@@ -66,6 +93,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php if (!$tresor) { ?>
|
||||
<section class="karte" aria-labelledby="t-verlauf">
|
||||
<h2 id="t-verlauf">Aufträge</h2>
|
||||
<?php if ([] === $i['verlauf']) { ?>
|
||||
@@ -88,6 +116,7 @@
|
||||
</div>
|
||||
<?php } ?>
|
||||
</section>
|
||||
<?php } ?>
|
||||
|
||||
<details class="karte gefahr">
|
||||
<summary>Eintrag entfernen</summary>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?= $v->e($titel) ?> · KantSites Zentrale</title>
|
||||
<link rel="stylesheet" href="/app.css?v=3">
|
||||
<script src="/app.js?v=1" defer></script>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,6 +24,15 @@
|
||||
<?php } ?>
|
||||
</div>
|
||||
</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">
|
||||
<?php if (null !== $hinweis) { ?>
|
||||
<p class="hinweis hinweis-<?= $v->e($hinweis['art']) ?>" role="<?= 'fehler' === $hinweis['art'] ? 'alert' : 'status' ?>"><?= $v->e($hinweis['text']) ?></p>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<?php /** @var Zentrale\Ansicht $v */ ?>
|
||||
<?php $tresor = 'kanttresor' === $produkt; ?>
|
||||
<div class="titelzeile">
|
||||
<h1>Installationen</h1>
|
||||
<form method="post" action="/fassung" class="neueste">
|
||||
<?= $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>
|
||||
<h1><?= $tresor ? 'KantTresor-Instanzen' : 'Installationen' ?></h1>
|
||||
<?php if ($tresor) { ?>
|
||||
<p class="neueste">Höchste gemeldete Fassung: <strong><?= null === $neueste ? 'unbekannt' : $v->e($neueste['version']) ?></strong></p>
|
||||
<?php } else { ?>
|
||||
<form method="post" action="/fassung" class="neueste">
|
||||
<?= $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>
|
||||
|
||||
<?php
|
||||
@@ -15,8 +20,12 @@ $kacheln = [
|
||||
'veraltet' => ['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']];
|
||||
}
|
||||
?>
|
||||
<nav aria-label="Nach Zustand filtern">
|
||||
<ul class="zahlen">
|
||||
@@ -40,13 +49,13 @@ $kacheln = [
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($zahlen['alle'] > 0) { ?>
|
||||
<form method="get" action="/" class="suche" role="search">
|
||||
<form method="get" action="<?= $v->e($pfad) ?>" class="suche" role="search">
|
||||
<?php if (null !== $filter) { ?><input type="hidden" name="filter" value="<?= $v->e($filter) ?>"><?php } ?>
|
||||
<label for="q">Suchen</label>
|
||||
<div class="suche-zeile">
|
||||
<input id="q" name="q" type="search" value="<?= $v->e($suche) ?>" placeholder="Domain, Kunde, Fassung, PHP oder IP" autocomplete="off" aria-describedby="treffer" aria-controls="liste">
|
||||
<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">
|
||||
<button type="submit" class="knopf">Suchen</button>
|
||||
<a class="knopf knopf-leise zuruecksetzen" href="/"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
|
||||
<a class="knopf knopf-leise zuruecksetzen" href="<?= $v->e($pfad) ?>"<?= '' === $suche && null === $filter ? ' hidden' : '' ?>>Zurücksetzen</a>
|
||||
</div>
|
||||
<p class="klein" id="treffer" role="status"><?= $v->e($trefferText) ?></p>
|
||||
</form>
|
||||
|
||||
@@ -181,3 +181,10 @@ input[type="text"]:focus-visible, input[type="password"]:focus-visible { border-
|
||||
|
||||
#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; }
|
||||
@@ -98,7 +98,7 @@
|
||||
feld.value = '';
|
||||
const filter = filterFeld();
|
||||
if (filter) filter.remove();
|
||||
laden(new URL('/', location.href), 'push');
|
||||
laden(new URL(form.action, location.href), 'push');
|
||||
feld.focus();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user