Add the central site registry and remote updates

Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-14 10:43:13 +02:00
1 parent 32cb77b72e
commit 723680b7de
39 files changed
+2355 -27

No files matched your search

+112
View File
@@ -8,16 +8,23 @@ use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\EventSubscriber\RemoteUpdateFinishSubscriber;
use App\Infrastructure\ConnectionFactory;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Funktionspruefung;
use App\Ops\Installation;
use App\Ops\Tasks\ZentraleTask;
use App\Ops\Updater;
use App\Ops\Zentrale;
use App\Security\CmsUser;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
@@ -337,6 +344,111 @@ final class ArchiveUpdateTest extends TestCase
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
}
public function testARemoteOrderUpdatesTheSiteAndTheNextRequestFinishesIt(): void
{
// ADR 123: Die Zentrale merkt sich „Update auf 1.1.0“, die Website holt es mit ihrer
// Meldung ab, tauscht die Dateien, und die nächste Anfrage führt die Migrationen aus.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$server = $this->zentraleServer(['id' => 7, 'aktion' => 'update', 'fassung' => '1.1.0']);
$zentrale = $this->zentrale($server['senden']);
$task = new ZentraleTask($zentrale, $this->updater(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db), 'https://kunde.example.de', $this->root, function (string $adresse): void {
self::assertSame('https://kunde.example.de/', $adresse);
$this->naechsteAnfrage();
});
$meldung = $task->run();
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), $meldung);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die nächste Anfrage hat abgeschlossen und die Website freigegeben.');
self::assertNull($this->updater()->pending());
$meldungen = $server['meldungen']();
self::assertArrayHasKey('schluessel', $meldungen[0], 'Beim ersten Kontakt geht der Schlüssel mit.');
self::assertArrayNotHasKey('schluessel', $meldungen[1], 'Danach nur noch die Unterschrift.');
self::assertSame('1.0.0', $meldungen[0]['fassung']);
$letzte = end($meldungen);
self::assertSame('1.1.0', $letzte['fassung']);
self::assertSame(['id' => 7, 'ok' => true], array_intersect_key($letzte['auftrag'] ?? [], ['id' => 1, 'ok' => 1]), 'Das Ergebnis geht an die Zentrale zurück.');
self::assertNull($zentrale->offenerAuftrag(), 'Nach der Quittung ist der Auftrag erledigt.');
}
public function testAnAnswerWithoutAValidSignatureChangesNothing(): void
{
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$zentrale = $this->zentrale(static fn (): array => [
'status' => 200,
'body' => '{"ok":true,"auftrag":{"id":1,"aktion":"update","fassung":"1.1.0"}}',
'signatur' => str_repeat('0', 64),
]);
try {
$zentrale->melden();
self::fail('Eine Antwort ohne gültige Unterschrift wurde angenommen.');
} catch (\RuntimeException $fehler) {
self::assertStringContainsString('Unterschrift', $fehler->getMessage());
}
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
}
public function testALocalInstallationDoesNotReport(): void
{
$gesendet = false;
$senden = static function () use (&$gesendet): array {
$gesendet = true;
return ['status' => 500, 'body' => '', 'signatur' => ''];
};
foreach (['http://127.0.0.1:8106', 'http://localhost', 'https://kunde.test'] as $adresse) {
$zentrale = new Zentrale($this->root, new Updater($this->root, 'sqlite:///x', new CommandRunner(), $this->db), $this->updater(), 'https://sites.example.invalid', $adresse, 'sqlite:///x', $senden);
self::assertFalse($zentrale->aktiv(), $adresse);
}
self::assertFalse($this->zentrale(null, 'aus')->aktiv(), 'ZENTRALE_URL=aus schaltet ab.');
self::assertFalse($gesendet);
}
/** Die Anfrage nach dem Tausch: startet mit dem neuen Code und schließt ab. */
private function naechsteAnfrage(): void
{
$pruefung = $this->createStub(Funktionspruefung::class);
$pruefung->method('__invoke')->willReturn(null);
$runner = new CommandRunner();
$subscriber = new RemoteUpdateFinishSubscriber($this->updater(), $this->installation(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', $runner, $this->db), $pruefung, $this->root);
$subscriber->onRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), Request::create('/'), HttpKernelInterface::MAIN_REQUEST));
}
private function zentrale(?\Closure $senden, string $url = 'https://sites.example.invalid'): Zentrale
{
$url_db = 'sqlite:///'.$this->root.'/var/cms.db';
return new Zentrale($this->root, new Updater($this->root, $url_db, new CommandRunner(), $this->db), $this->updater(), $url, 'https://kunde.example.de', $url_db, $senden);
}
/**
* Eine Zentrale im Speicher: lernt den Schlüssel beim ersten Kontakt, prüft danach jede
* Unterschrift, gibt den Auftrag einmal heraus und unterschreibt jede Antwort.
*
* @return array{senden: \Closure, meldungen: \Closure}
*/
private function zentraleServer(?array $auftrag): array
{
$schluessel = null;
$meldungen = [];
$senden = function (string $url, string $body, array $kopf) use (&$schluessel, &$meldungen, &$auftrag): array {
$daten = json_decode($body, true);
$meldungen[] = $daten;
$schluessel ??= $daten['schluessel'] ?? null;
self::assertIsString($schluessel);
self::assertTrue(hash_equals(hash_hmac('sha256', $body, $schluessel), $kopf['X-KantSites-Signatur']), 'Jede Meldung ist unterschrieben.');
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag]);
$auftrag = null;
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
};
return ['senden' => $senden, 'meldungen' => static function () use (&$meldungen): array {
return $meldungen;
}];
}
private function installation(): Installation
{
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
+2 -2
View File
@@ -70,7 +70,7 @@ final class TaskScreenTest extends WebTestCase
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) {
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup', 'zentrale'] as $task) {
self::assertStringContainsString($task, $html);
}
// Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie
@@ -78,7 +78,7 @@ final class TaskScreenTest extends WebTestCase
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
}
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void