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>
288 lines
12 KiB
PHP
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];
|
|
}
|
|
}
|