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>
227 lines
9.3 KiB
PHP
227 lines
9.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Zentrale;
|
||
|
||
/**
|
||
* Die Installationen und ihre Aufträge - was die Übersicht zeigt und was ihre Knöpfe tun.
|
||
*/
|
||
final class Bestand
|
||
{
|
||
/** Ohne Meldung seit so vielen Stunden gilt eine Installation als verstummt. */
|
||
public const STILL_STUNDEN = 48;
|
||
|
||
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
|
||
public const AUFTRAG_STUNDEN = 2;
|
||
|
||
/**
|
||
* Erste PHP-Fassung je Zweig ohne bekannte kritische Lücke (zuletzt php2shell,
|
||
* CVE-2026-109618/-109619, 21.10.2026). Bei der nächsten PHP-Sicherheitsmeldung hier nachziehen.
|
||
*/
|
||
public const PHP_SICHER = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
|
||
|
||
/** Bis hierher gibt es keine Sicherheitsupdates mehr. */
|
||
public const PHP_ALT_BIS = '8.1';
|
||
|
||
public function __construct(private \PDO $db)
|
||
{
|
||
}
|
||
|
||
/** 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, string $produkt = 'kantsites'): array
|
||
{
|
||
$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 = [];
|
||
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
|
||
$auftraege[$auftrag['installation']] = $auftrag;
|
||
}
|
||
|
||
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
|
||
{
|
||
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
|
||
$abfrage->execute([$id]);
|
||
$zeile = $abfrage->fetch() ?: null;
|
||
if (null === $zeile) {
|
||
return null;
|
||
}
|
||
$verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30');
|
||
$verlauf->execute([$id]);
|
||
$auftraege = $verlauf->fetchAll();
|
||
$eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste);
|
||
$eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege);
|
||
|
||
return $eine;
|
||
}
|
||
|
||
/**
|
||
* Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht.
|
||
*/
|
||
public function beauftragen(string $id, string $fassung): ?string
|
||
{
|
||
$eine = $this->eine($id, $fassung);
|
||
if (null === $eine) {
|
||
return 'Diese Installation gibt es nicht.';
|
||
}
|
||
if (!$eine['kann_update']) {
|
||
return $eine['kein_update_grund'];
|
||
}
|
||
$offen = $eine['auftrag'];
|
||
if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) {
|
||
$this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]);
|
||
|
||
return null;
|
||
}
|
||
$this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)')
|
||
->execute([$id, 'update', $fassung, Datenbank::jetzt()]);
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Einen noch nicht abgeholten Auftrag zurückziehen. */
|
||
public function zurueckziehen(string $id): bool
|
||
{
|
||
$abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL');
|
||
$abfrage->execute([$id]);
|
||
|
||
return $abfrage->rowCount() > 0;
|
||
}
|
||
|
||
public function notiz(string $id, string $notiz): void
|
||
{
|
||
$this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]);
|
||
}
|
||
|
||
public function entfernen(string $id): void
|
||
{
|
||
$this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $zeile
|
||
* @param array<string, mixed>|null $auftrag
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array
|
||
{
|
||
$alter = time() - (int) strtotime((string) $zeile['letzte_meldung']);
|
||
$veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<');
|
||
$zustand = null === $auftrag ? null : $this->auftragZustand($auftrag);
|
||
// 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.',
|
||
$laeuft => 'Ein Update läuft gerade.',
|
||
default => '',
|
||
};
|
||
|
||
return [
|
||
...$zeile,
|
||
'alter' => $alter,
|
||
'still' => $alter > self::STILL_STUNDEN * 3600,
|
||
'veraltet' => $veraltet,
|
||
'unbekannt' => '' === trim((string) $zeile['notiz']),
|
||
'auftrag' => $auftrag,
|
||
'auftrag_zustand' => $zustand,
|
||
'kann_update' => '' === $grund,
|
||
'kein_update_grund' => $grund,
|
||
'php_luecke' => $tresor ? null : self::phpLuecke((string) $zeile['php']),
|
||
'speicher_knapp' => null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP,
|
||
'gestoert' => '' !== (string) $zeile['stoerungen'] || (null !== $zeile['speicher'] && (int) $zeile['speicher'] >= self::SPEICHER_KNAPP),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Warum die gemeldete PHP-Fassung als unsicher gilt, oder null. Unbekannte und neuere
|
||
* Zweige (etwa 8.6-Vorabfassungen) gelten nicht als unsicher.
|
||
*/
|
||
public static function phpLuecke(string $php): ?string
|
||
{
|
||
if (1 !== preg_match('/^(\d+)\.(\d+)\.(\d+)/', $php, $teile)) {
|
||
return null;
|
||
}
|
||
$zweig = $teile[1].'.'.$teile[2];
|
||
$fassung = $zweig.'.'.$teile[3];
|
||
if (version_compare($zweig, self::PHP_ALT_BIS, '<=')) {
|
||
return sprintf('PHP %s bekommt keine Sicherheitsupdates mehr – auf PHP %s umstellen.', $zweig, self::PHP_SICHER['8.4']);
|
||
}
|
||
$sicher = self::PHP_SICHER[$zweig] ?? null;
|
||
if (null !== $sicher && version_compare($fassung, $sicher, '<')) {
|
||
return sprintf('PHP %s hat eine bekannte kritische Sicherheitslücke – mindestens %s nötig.', $fassung, $sicher);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $auftrag
|
||
*
|
||
* @return array{art: string, text: string}
|
||
*/
|
||
private function auftragZustand(array $auftrag): array
|
||
{
|
||
if (null !== $auftrag['erledigt']) {
|
||
return (int) $auftrag['ok'] === 1
|
||
? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])]
|
||
: ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])];
|
||
}
|
||
if (null === $auftrag['abgeholt']) {
|
||
return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])];
|
||
}
|
||
if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) {
|
||
return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])];
|
||
}
|
||
|
||
return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])];
|
||
}
|
||
}
|