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:
1 parent
32cb77b72e
commit
723680b7de
39 files changed
+2355
-27
No files matched your search
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user