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

+15
View File
@@ -315,6 +315,21 @@ final class ArchiveUpdate
return ['ok' => true, 'steps' => $schritte, 'migrate' => true, 'token' => $token, 'fassung' => $ziel];
}
/**
* Kennzeichnet den offenen zweiten Schritt als von der Zentrale angestoßen (ADR 123).
*
* Dann wartet niemand in der Verwaltung, der ihn aufruft; die nächste Anfrage mit dem neuen
* Code schließt ihn ab (@see RemoteUpdateFinishSubscriber).
*/
public function markiereFern(): void
{
$offen = $this->pending();
if (null === $offen) {
return;
}
@file_put_contents($this->root.'/'.self::MARKE, json_encode([...$offen, 'fern' => true], JSON_UNESCAPED_SLASHES));
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Ops;
use App\Content\PageRepository;
use App\Content\SiteSettings;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/**
* Läuft die neue Fassung wirklich? Datenbank, Startseite in der Datenbank und die Startseite als
* Seite - als Unteranfrage, die an der Wartungsseite vorbeigeht.
*
* Der Abschluss eines Updates ruft sie nach den Migrationen auf, aus der Verwaltung und nach
* einem Auftrag der Zentrale. Sie braucht eine laufende Hauptanfrage: Die Unteranfrage hängt sich
* an deren Request-Stack.
*/
class Funktionspruefung
{
public function __construct(private PageRepository $pages, private SiteSettings $settings, private HttpKernelInterface $kernel)
{
}
/** null heißt: alles gut. Sonst der Grund, warum die Fassung nicht läuft. */
public function __invoke(): ?string
{
$home = $this->settings->homePath();
if (null === $this->pages->findPublished($home)) {
return sprintf('Die Startseite /%s ist nicht lesbar.', $home);
}
$antwort = $this->kernel->handle(Request::create('/'), HttpKernelInterface::SUB_REQUEST, false);
if ($antwort->getStatusCode() >= 400) {
return sprintf('Die Startseite antwortet mit %d.', $antwort->getStatusCode());
}
return null;
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Ops\Tasks;
use App\Ops\ArchiveUpdate;
use App\Ops\Updater;
use App\Ops\Zentrale;
/**
* Meldet sich bei der Zentrale und führt einen dort angestoßenen Auftrag aus (ADR 123).
*
* Der einzige Auftrag ist „Update auf Fassung X“. Er läuft über denselben Weg wie der Knopf in
* der Verwaltung: Sicherung, Archiv mit Prüfsumme, Dateien tauschen. Den zweiten Schritt - die
* Migrationen mit dem neuen Code - erledigt die nächste Anfrage (@see RemoteUpdateFinishSubscriber);
* diese Aufgabe stößt sie gleich selbst an, damit die Wartungsseite nicht bis zum nächsten
* Besucher stehen bleibt.
*/
final class ZentraleTask implements Task
{
/** Zeitgrenze für den Anstoß des zweiten Schritts; die Migrationen laufen in dieser Anfrage. */
private const ABSCHLUSS_SEKUNDEN = 300;
/** @param (\Closure(string): void)|null $anstossen ruft die Website einmal auf; Tests setzen ihn */
public function __construct(
private Zentrale $zentrale,
private ArchiveUpdate $archive,
private Updater $updater,
private string $siteOrigin,
private string $projectDir,
private ?\Closure $anstossen = null,
) {
}
public function name(): string
{
return 'zentrale';
}
public function label(): string
{
return 'Bei der Zentrale melden';
}
public function explanation(): string
{
return 'Meldet Adresse und Fassung an sites.kant-it.de und holt dort angestoßene Updates ab. Ohne sie fehlt die Website in der Übersicht, und ein Update aus der Ferne kommt nicht an.';
}
public function interval(): int
{
return 900;
}
public function run(): string
{
$grund = $this->zentrale->grundInaktiv();
if (null !== $grund) {
return $grund;
}
$this->ergebnisNachtragen();
$antwort = $this->zentrale->melden();
$auftrag = $antwort['auftrag'];
if (null === $auftrag) {
return sprintf('Gemeldet: Fassung %s.', $this->updater->version());
}
return $this->ausfuehren($auftrag['id'], $auftrag['aktion'], $auftrag['fassung']);
}
/**
* Ein Auftrag, dessen zweiter Schritt in einer anderen Anfrage lief: Was ist daraus geworden?
* Steht nichts mehr aus, verrät es der Fortschritt des Updates.
*/
private function ergebnisNachtragen(): void
{
$auftrag = $this->zentrale->offenerAuftrag();
if (null === $auftrag || array_key_exists('ok', $auftrag) || null !== $this->archive->pending()) {
return;
}
$status = $this->archive->status() ?? [];
if ($status['laeuft'] ?? false) {
return;
}
$phase = (string) ($status['phase'] ?? '');
$fassung = (string) ($auftrag['fassung'] ?? '');
$ok = 'fertig' === $phase || version_compare($this->updater->version(), $fassung, '>=');
$this->zentrale->auftragErledigt($ok, (string) ($status['meldung'] ?? ($ok ? 'Fassung '.$this->updater->version().' läuft.' : 'Ohne Ergebnis beendet.')));
}
private function ausfuehren(int $id, string $aktion, string $fassung): string
{
$this->zentrale->auftragAngenommen($id, $fassung);
$ablehnen = function (string $grund): string {
$this->zentrale->auftragErledigt(false, $grund);
$this->zentrale->melden();
return 'Auftrag abgelehnt: '.$grund;
};
if ('update' !== $aktion) {
return $ablehnen(sprintf('Unbekannter Auftrag „%s“.', $aktion));
}
if (1 !== preg_match('/^\d{1,4}\.\d{1,5}\.\d{1,6}$/D', $fassung)) {
return $ablehnen('Die Fassung hat nicht die Form 1.2.3.');
}
if (version_compare($fassung, $this->updater->version(), '<=')) {
$this->zentrale->auftragErledigt(true, sprintf('Bereits auf Fassung %s.', $this->updater->version()));
$this->zentrale->melden();
return 'Auftrag erledigt: Diese Installation ist schon auf dem Stand.';
}
if (is_dir($this->projectDir.'/.git')) {
return $ablehnen('Diese Installation ist eine Git-Arbeitskopie; sie wird über Git aktualisiert, nicht aus der Ferne.');
}
if (null !== $this->archive->pending() || is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE)) {
return $ablehnen('Hier läuft gerade ein Update oder die Wartungsseite ist an.');
}
// Ein Update dauert länger als eine gewöhnliche Aufgabe. Die Antwort an den Besucher ist
// da schon unterwegs; abbrechen darf der Lauf jetzt nicht mehr.
@ignore_user_abort(true);
@set_time_limit(0);
$ergebnis = $this->archive->run($fassung);
if (!($ergebnis['migrate'] ?? false)) {
$letzter = end($ergebnis['steps']) ?: ['detail' => ''];
$this->zentrale->auftragErledigt($ergebnis['ok'], (string) $letzter['detail']);
$this->zentrale->melden();
return ($ergebnis['ok'] ? 'Auftrag erledigt: ' : 'Update gescheitert: ').$letzter['detail'];
}
// Die neuen Dateien liegen; die Migrationen gehören in eine Anfrage mit dem neuen Code.
$this->archive->markiereFern();
$this->anstossen();
$this->ergebnisNachtragen();
try {
$this->zentrale->melden();
} catch (\Throwable) {
// Das Ergebnis bleibt in var/zentrale.json und geht mit der nächsten Meldung raus.
}
return null === $this->archive->pending()
? sprintf('Update auf %s aus der Ferne ausgeführt.', $fassung)
: sprintf('Dateien für %s getauscht; die Migrationen laufen mit der nächsten Anfrage.', $fassung);
}
/** Ruft die Website einmal auf. Gelingt das nicht, schließt der nächste Besucher ab. */
private function anstossen(): void
{
$adresse = rtrim($this->siteOrigin, '/').'/';
if (null !== $this->anstossen) {
($this->anstossen)($adresse);
return;
}
if (function_exists('curl_init')) {
$curl = curl_init($adresse);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 2,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => self::ABSCHLUSS_SEKUNDEN,
CURLOPT_USERAGENT => 'KantSites-Abschluss',
]);
curl_exec($curl);
curl_close($curl);
return;
}
$kontext = stream_context_create(['http' => ['timeout' => self::ABSCHLUSS_SEKUNDEN, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Abschluss\r\n"]]);
@file_get_contents($adresse, false, $kontext);
}
}
+287
View File
@@ -0,0 +1,287 @@
<?php
namespace App\Ops;
/**
* Die Meldung an die Zentrale (ADR 123): welche Domain, welche Fassung - und ob ein Auftrag wartet.
*
* Jede Installation meldet sich regelmäßig bei sites.kant-it.de. Dort steht die Liste aller
* Installationen; dort lässt sich ein Update anstoßen. Die Zentrale verbindet sich nie selbst mit
* einer Website: Sie merkt sich den Auftrag, und die Website holt ihn mit der nächsten Meldung ab.
* Damit braucht keine Installation eine Tür, die sich von außen öffnen lässt.
*
* Was gemeldet wird, steht in payload(): Adresse, Fassung, PHP, Datenbankart, Updatestand. Keine
* Inhalte, keine Besucher, keine Zugangsdaten.
*
* Die Echtheit: Beim ersten Kontakt schickt die Installation einen zufälligen Schlüssel mit (über
* HTTPS). Danach trägt jede Meldung eine HMAC-Unterschrift damit, und jede Antwort der Zentrale
* ebenso. Wer die Kennung einer Installation kennt, kann ohne den Schlüssel weder für sie melden
* noch ihr einen Auftrag unterschieben. Ein Auftrag kann ohnehin nur eine veröffentlichte Fassung
* mit passender Prüfsumme einspielen (@see ArchiveUpdate).
*/
class Zentrale
{
/** Kennung, Schlüssel und offener Auftrag dieser Installation. Bleibt bei Updates erhalten. */
public const DATEI = 'var/zentrale.json';
private const PFAD = '/api/melden';
/** @var (\Closure(string, string, array<string, string>): array{status: int, body: string, signatur: string})|null */
private ?\Closure $senden;
public function __construct(
private string $root,
private Updater $updater,
private ArchiveUpdate $archive,
private string $url,
private string $siteOrigin,
private string $databaseUrl,
?\Closure $senden = null,
) {
$this->senden = $senden;
}
/**
* Meldet diese Installation? Nein, wenn die Zentrale abgeschaltet ist (ZENTRALE_URL=aus) oder
* die Website nur auf diesem Rechner läuft - Entwicklungs- und Messinstallationen gehören
* nicht in die Liste.
*/
public function aktiv(): bool
{
return null === $this->grundInaktiv();
}
public function grundInaktiv(): ?string
{
if (!str_starts_with($this->url, 'https://')) {
return 'Abgeschaltet (ZENTRALE_URL ist nicht gesetzt oder keine HTTPS-Adresse).';
}
$host = strtolower((string) parse_url($this->siteOrigin, PHP_URL_HOST));
if ('' === $host || in_array($host, ['localhost', '127.0.0.1', '[::1]', '::1'], true) || str_ends_with($host, '.localhost') || str_ends_with($host, '.test') || str_ends_with($host, '.invalid')) {
return sprintf('Keine Meldung: Die Website läuft lokal (%s).', '' === $host ? 'ohne SITE_ORIGIN' : $host);
}
return null;
}
/**
* Schickt die Meldung und gibt den Auftrag zurück, falls einer wartet.
*
* @return array{auftrag: ?array{id: int, aktion: string, fassung: string}, registriert: bool}
*
* @throws \RuntimeException wenn die Zentrale nicht oder nicht echt antwortet
*/
public function melden(): array
{
$ich = $this->identitaet();
$erneut = false;
while (true) {
$daten = $this->payload($ich);
$body = json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$antwort = $this->post(rtrim($this->url, '/').self::PFAD, $body, [
'Content-Type' => 'application/json',
'X-KantSites-Signatur' => hash_hmac('sha256', $body, $ich['schluessel']),
]);
// Die Zentrale kennt diese Kennung nicht (mehr) - etwa nach einem Neuaufsetzen dort.
// Dann einmal mit Schlüssel neu anmelden.
if (409 === $antwort['status'] && !$erneut) {
$ich['registriert'] = false;
$erneut = true;
continue;
}
break;
}
if (200 !== $antwort['status']) {
throw new \RuntimeException(sprintf('Die Zentrale antwortet mit %d. %s', $antwort['status'], $this->fehlertext($antwort['body'])));
}
if (!hash_equals(hash_hmac('sha256', $antwort['body'], $ich['schluessel']), $antwort['signatur'])) {
throw new \RuntimeException('Die Antwort der Zentrale trägt keine gültige Unterschrift und wird verworfen.');
}
$inhalt = json_decode($antwort['body'], true);
if (!is_array($inhalt) || true !== ($inhalt['ok'] ?? null)) {
throw new \RuntimeException('Die Antwort der Zentrale ist unverständlich.');
}
$ich['registriert'] = true;
$ich['gemeldet'] = gmdate('c');
// Ein gemeldetes Ergebnis ist angekommen; der Auftrag ist damit erledigt.
if (isset($daten['auftrag']['ok'])) {
unset($ich['auftrag']);
}
$this->speichern($ich);
$auftrag = $inhalt['auftrag'] ?? null;
if (!is_array($auftrag) || !is_int($auftrag['id'] ?? null) || !is_string($auftrag['aktion'] ?? null) || !is_string($auftrag['fassung'] ?? null)) {
$auftrag = null;
}
return ['auftrag' => null === $auftrag ? null : ['id' => $auftrag['id'], 'aktion' => $auftrag['aktion'], 'fassung' => $auftrag['fassung']], 'registriert' => true];
}
/** Merkt sich einen angenommenen Auftrag, bis sein Ergebnis gemeldet ist. */
public function auftragAngenommen(int $id, string $fassung): void
{
$ich = $this->identitaet();
$ich['auftrag'] = ['id' => $id, 'fassung' => $fassung, 'seit' => gmdate('c')];
$this->speichern($ich);
}
/** Hält das Ergebnis eines Auftrags fest; die nächste Meldung trägt es zur Zentrale. */
public function auftragErledigt(bool $ok, string $meldung): void
{
$ich = $this->identitaet();
if (!isset($ich['auftrag'])) {
return;
}
$ich['auftrag']['ok'] = $ok;
$ich['auftrag']['meldung'] = mb_substr($meldung, 0, 500);
$this->speichern($ich);
}
/** @return array{id?: int, fassung?: string, seit?: string, ok?: bool, meldung?: string}|null */
public function offenerAuftrag(): ?array
{
return $this->identitaet()['auftrag'] ?? null;
}
/**
* Was gemeldet wird.
*
* @param array<string, mixed> $ich
*
* @return array<string, mixed>
*/
private function payload(array $ich): array
{
$status = $this->archive->status();
$daten = [
'id' => $ich['id'],
'adresse' => rtrim($this->siteOrigin, '/'),
'fassung' => $this->updater->version(),
'php' => PHP_VERSION,
'datenbank' => strtolower((string) strtok($this->databaseUrl, ':')),
// Über Git aktualisierte Installationen nimmt die Zentrale nicht in Auftrag.
'art' => is_dir($this->root.'/.git') ? 'git' : 'archiv',
'wartung' => is_file($this->root.'/'.Updater::MAINTENANCE_FILE),
'update' => null === $status ? null : [
'phase' => (string) ($status['phase'] ?? ''),
'meldung' => mb_substr((string) ($status['meldung'] ?? ''), 0, 300),
'zeit' => (string) ($status['aktualisiert'] ?? ''),
],
];
if (!($ich['registriert'] ?? false)) {
$daten['schluessel'] = $ich['schluessel'];
}
$auftrag = $ich['auftrag'] ?? null;
if (is_array($auftrag)) {
$daten['auftrag'] = array_key_exists('ok', $auftrag)
? ['id' => $auftrag['id'], 'ok' => $auftrag['ok'], 'meldung' => $auftrag['meldung'] ?? '']
: ['id' => $auftrag['id']];
}
return $daten;
}
/** @return array<string, mixed> */
private function identitaet(): array
{
$daten = json_decode((string) @file_get_contents($this->root.'/'.self::DATEI), true);
if (is_array($daten) && is_string($daten['id'] ?? null) && is_string($daten['schluessel'] ?? null)) {
return $daten;
}
$daten = ['id' => bin2hex(random_bytes(16)), 'schluessel' => bin2hex(random_bytes(32)), 'registriert' => false];
$this->speichern($daten);
return $daten;
}
/** @param array<string, mixed> $daten */
private function speichern(array $daten): void
{
$datei = $this->root.'/'.self::DATEI;
@mkdir(dirname($datei), 0o775, true);
$zwischen = $datei.'.'.bin2hex(random_bytes(4)).'.tmp';
if (false === @file_put_contents($zwischen, json_encode($daten, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) || !@rename($zwischen, $datei)) {
@unlink($zwischen);
throw new \RuntimeException(self::DATEI.' ist nicht schreibbar.');
}
}
private function fehlertext(string $body): string
{
$daten = json_decode($body, true);
return is_array($daten) && is_string($daten['fehler'] ?? null) ? mb_substr($daten['fehler'], 0, 200) : '';
}
/**
* POST über HTTPS, ohne Weiterleitungen: Eine Meldung, die woanders hin umgelenkt wird, soll
* scheitern und nicht ankommen.
*
* @param array<string, string> $kopf
*
* @return array{status: int, body: string, signatur: string}
*/
private function post(string $url, string $body, array $kopf): array
{
if (null !== $this->senden) {
return ($this->senden)($url, $body, $kopf);
}
$zeilen = ['User-Agent: KantSites/'.$this->updater->version()];
foreach ($kopf as $name => $wert) {
$zeilen[] = $name.': '.$wert;
}
$signatur = '';
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $zeilen,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 20,
CURLOPT_HEADERFUNCTION => static function ($curl, string $zeile) use (&$signatur): int {
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $treffer)) {
$signatur = strtolower($treffer[1]);
}
return strlen($zeile);
},
]);
$antwort = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_error($curl);
curl_close($curl);
if (!is_string($antwort)) {
throw new \RuntimeException('Die Zentrale ist nicht erreichbar: '.$fehler);
}
return ['status' => $status, 'body' => $antwort, 'signatur' => $signatur];
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
throw new \RuntimeException('Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.');
}
$kontext = stream_context_create([
'http' => ['method' => 'POST', 'header' => implode("\r\n", $zeilen), 'content' => $body, 'timeout' => 20, 'follow_location' => 0, 'ignore_errors' => true],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$antwort = @file_get_contents($url, false, $kontext);
$kopfzeilen = $http_response_header ?? [];
if (!is_string($antwort) || [] === $kopfzeilen) {
throw new \RuntimeException('Die Zentrale ist nicht erreichbar.');
}
preg_match('/^HTTP\/\S+\s+(\d{3})/', (string) $kopfzeilen[0], $treffer);
foreach ($kopfzeilen as $zeile) {
if (1 === preg_match('/^X-KantSites-Signatur:\s*([a-f0-9]{64})\s*$/i', $zeile, $s)) {
$signatur = strtolower($s[1]);
}
}
return ['status' => (int) ($treffer[1] ?? 0), 'body' => $antwort, 'signatur' => $signatur];
}
}