Mark sites reporting a PHP release without the php2shell fixes (CVE-2026-109618/-109619; patched in 8.2.35, 8.3.36, 8.4.27, 8.5.12) or an end-of-life branch (8.1 and older). The overview gets a counter tile that doubles as a filter, a status pill per row and PHP in the search; the detail page explains that the hoster has to update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
7.5 KiB
PHP
186 lines
7.5 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)
|
||
{
|
||
}
|
||
|
||
/** @return list<array<string, mixed>> */
|
||
public function alle(?string $neueste): array
|
||
{
|
||
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->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);
|
||
}
|
||
|
||
/** @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);
|
||
|
||
$grund = match (true) {
|
||
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' => self::phpLuecke((string) $zeile['php']),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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'])];
|
||
}
|
||
}
|