Files
KantSites/scripts/zentrale-check.php
T
kantarciandClaude Opus 5.5 968d19b611
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
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

142 lines
9.1 KiB
PHP

<?php
/**
* Prüft, ob CMS und Zentrale dieselbe Sprache sprechen (ADR 123).
*
* Die Meldung kommt aus App\Ops\Zentrale, die Antwort aus Zentrale\Api - beide echt, nur ohne
* Netz dazwischen: Registrierung mit Schlüssel, unterschriebene Folgemeldung, Auftrag abholen,
* Ergebnis zurückmelden, fremde Unterschrift abweisen, zweite Installation mit fremder Kennung.
*
* Aufruf: php scripts/zentrale-check.php
*/
declare(strict_types=1);
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Updater;
use App\Ops\Zentrale as CmsZentrale;
use Doctrine\DBAL\DriverManager;
require dirname(__DIR__).'/vendor/autoload.php';
spl_autoload_register(static function (string $klasse): void {
if (str_starts_with($klasse, 'Zentrale\\')) {
require dirname(__DIR__).'/zentrale/app/'.substr($klasse, 9).'.php';
}
});
$tmp = str_replace('\\', '/', sys_get_temp_dir()).'/zentrale-check-'.bin2hex(random_bytes(4));
mkdir($tmp.'/site/var', 0o775, true);
file_put_contents($tmp.'/site/VERSION', "1.35.1\n");
$pdo = Zentrale\Datenbank::oeffnen($tmp.'/zentrale.db');
$api = new Zentrale\Api($pdo);
$fehler = 0;
$pruefe = static function (bool $ok, string $was) use (&$fehler): void {
echo ($ok ? 'ok ' : 'FEHLER ').$was."\n";
$fehler += $ok ? 0 : 1;
};
$ueberApi = static function (string $url, string $body, array $kopf) use ($api): array {
$antwort = $api->melden($body, $kopf['X-KantSites-Signatur'] ?? '', '203.0.113.7');
return ['status' => $antwort['status'], 'body' => $antwort['body'], 'signatur' => (string) $antwort['signatur']];
};
$installation = static function (string $wurzel, string $adresse, \Closure $senden): CmsZentrale {
$db = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
$updater = new Updater($wurzel, 'sqlite:///:memory:', new CommandRunner(), $db);
return new CmsZentrale($wurzel, $updater, new ArchiveUpdate($wurzel, $updater, new Backup($wurzel, 'sqlite:///:memory:', $wurzel.'/var/media', $db, new CommandRunner()), new Download()), 'https://sites.example', $adresse, 'sqlite:///x', $senden);
};
$site = $installation($tmp.'/site', 'https://kunde-eins.de', $ueberApi);
$erste = $site->melden();
$pruefe(null === $erste['auftrag'], 'Erste Meldung wird angenommen, kein Auftrag');
$zeile = $pdo->query('SELECT * FROM installation')->fetch();
$pruefe('kunde-eins.de' === ($zeile['domain'] ?? null) && '1.35.1' === $zeile['fassung'], 'Domain und Fassung stehen in der Zentrale');
$ich = json_decode((string) file_get_contents($tmp.'/site/var/zentrale.json'), true);
$pruefe(true === $ich['registriert'], 'Installation gilt danach als angemeldet');
$bestand = new Zentrale\Bestand($pdo);
$pruefe(null === $bestand->beauftragen($zeile['id'], '1.36.0'), 'Update auf 1.36.0 lässt sich beauftragen');
$zweite = $site->melden();
$pruefe('1.36.0' === ($zweite['auftrag']['fassung'] ?? null) && 'update' === $zweite['auftrag']['aktion'], 'Folgemeldung holt den Auftrag ab');
$pruefe(null === $site->melden()['auftrag'], 'Ein abgeholter Auftrag kommt nicht zweimal');
$pruefe(null !== $bestand->beauftragen($zeile['id'], '1.36.0'), 'Während ein Auftrag läuft, gibt es keinen zweiten');
$site->auftragAngenommen($zweite['auftrag']['id'], '1.36.0');
file_put_contents($tmp.'/site/VERSION', "1.36.0\n");
$site->auftragErledigt(true, 'Fassung 1.36.0 läuft.');
$site->melden();
$auftrag = $pdo->query('SELECT * FROM auftrag')->fetch();
$pruefe(1 === (int) $auftrag['ok'] && null !== $auftrag['erledigt'], 'Ergebnis kommt in der Zentrale an');
$pruefe('1.36.0' === $pdo->query('SELECT fassung FROM installation')->fetchColumn(), 'Neue Fassung ist gemeldet');
$pruefe(null === $site->offenerAuftrag(), 'Die Installation vergisst den quittierten Auftrag');
// Eine fremde Antwort: anderer Schlüssel unterschreibt.
$gefaelscht = $installation($tmp.'/site', 'https://kunde-eins.de', static function (string $url, string $body) use ($ueberApi): array {
$echt = $ueberApi($url, $body, []);
return [...$echt, 'signatur' => hash_hmac('sha256', $echt['body'], str_repeat('b', 64))];
});
try {
$gefaelscht->melden();
$pruefe(false, 'Unsignierte Meldung wird abgewiesen');
} catch (\RuntimeException $e) {
$pruefe(str_contains($e->getMessage(), '403'), 'Meldung ohne gültige Unterschrift weist die Zentrale mit 403 ab');
}
// Jemand übernimmt die Kennung mit eigenem Schlüssel.
$body = json_encode(['id' => $zeile['id'], 'adresse' => 'https://fremd.de', 'fassung' => '1.36.0', 'schluessel' => str_repeat('c', 64)]);
$fremd = $api->melden($body, hash_hmac('sha256', $body, str_repeat('c', 64)), '198.51.100.1');
$pruefe(403 === $fremd['status'], 'Fremder Schlüssel für eine bekannte Kennung wird abgewiesen');
// Die Zentrale hat die Installation vergessen: Sie meldet sich mit Schlüssel neu an.
$pdo->exec('DELETE FROM installation');
$site->melden();
$pruefe(1 === (int) $pdo->query('SELECT COUNT(*) FROM installation')->fetchColumn(), 'Nach Verlust in der Zentrale meldet sich die Installation neu an');
// Umzug: dieselbe Kennung, andere Domain.
$umzug = $installation($tmp.'/site', 'https://www.kunde-zwei.de', $ueberApi);
$umzug->melden();
$neu = $pdo->query('SELECT domain, domain_vorher FROM installation')->fetch();
$pruefe('www.kunde-zwei.de' === $neu['domain'] && 'kunde-eins.de' === $neu['domain_vorher'], 'Domainwechsel ist sichtbar');
// PHP-Fassungen mit bekannter Lücke fallen auf; gepatchte, unbekannte und Vorabfassungen nicht.
$pruefe(null !== Zentrale\Bestand::phpLuecke('8.4.22') && null !== Zentrale\Bestand::phpLuecke('8.1.30'), 'Ungepatchtes und veraltetes PHP gilt als unsicher');
$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);