Files
KantSites/src/Ops/Zentrale.php
T
kantarciandClaude Opus 5.5 723680b7de 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>
2026-10-14 10:43:13 +02:00

288 lines
12 KiB
PHP

<?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];
}
}