Files
KantSites/zentrale/app/Bestand.php
T
kantarciandClaude Opus 5.5 968d19b611
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
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>
2026-10-27 10:02:07 +01:00

227 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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'])];
}
}