): 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 $ich * * @return array */ 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 */ 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 $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 $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]; } }