Add a KantTresor tab to the central registry
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run

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

+9 -1
View File
@@ -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)
+12
View File
@@ -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“.
+27
View File
@@ -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);
+21
View File
@@ -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
+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>
+7
View File
@@ -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; }
+1 -1
View File
@@ -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();
});