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>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-27 10:02:07 +01:00
1 parent 9d80af3795
commit 968d19b611
15 files changed
+392 -80

No files matched your search

+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
{
+44 -3
View File
@@ -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),
];
}
+9
View File
@@ -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
+36 -4
View File
@@ -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>
+30 -1
View File
@@ -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>
+11 -2
View File
@@ -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>
+19 -10
View File
@@ -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>