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>
143 lines
6.9 KiB
PHP
143 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Zentrale;
|
|
|
|
/**
|
|
* POST /api/melden - die Meldung einer KantSites-Installation.
|
|
*
|
|
* Gegenstück zu App\Ops\Zentrale im CMS. Beim ersten Kontakt bringt die Installation ihren
|
|
* Schlüssel mit; danach muss jede Meldung damit unterschrieben sein (HMAC-SHA256 über den
|
|
* Rohtext, Kopfzeile X-KantSites-Signatur). Jede Antwort ist ebenso unterschrieben, damit die
|
|
* Installation nur Aufträge von hier annimmt.
|
|
*/
|
|
final class Api
|
|
{
|
|
private const GROESSTE_MELDUNG = 16384;
|
|
|
|
public function __construct(private \PDO $db)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* @return array{status: int, body: string, signatur: ?string}
|
|
*/
|
|
public function melden(string $body, string $signatur, string $ip): array
|
|
{
|
|
if (strlen($body) > self::GROESSTE_MELDUNG) {
|
|
return $this->fehler(413, 'Die Meldung ist zu groß.');
|
|
}
|
|
$daten = json_decode($body, true);
|
|
if (!is_array($daten)) {
|
|
return $this->fehler(400, 'Die Meldung ist kein JSON.');
|
|
}
|
|
$pruef = $this->pruefen($daten);
|
|
if (is_string($pruef)) {
|
|
return $this->fehler(400, $pruef);
|
|
}
|
|
$signatur = strtolower(trim($signatur));
|
|
|
|
$gespeichert = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
|
|
$gespeichert->execute([$daten['id']]);
|
|
$installation = $gespeichert->fetch() ?: null;
|
|
$mitgebracht = is_string($daten['schluessel'] ?? null) && 1 === preg_match('/^[a-f0-9]{64}$/D', $daten['schluessel']) ? $daten['schluessel'] : null;
|
|
|
|
if (null === $installation) {
|
|
if (null === $mitgebracht) {
|
|
// Die Installation hält sich für angemeldet, hier ist sie unbekannt: Sie schickt
|
|
// daraufhin ihren Schlüssel noch einmal.
|
|
return $this->fehler(409, 'Diese Installation ist hier nicht bekannt.');
|
|
}
|
|
$schluessel = $mitgebracht;
|
|
} else {
|
|
$schluessel = (string) $installation['schluessel'];
|
|
if (null !== $mitgebracht && !hash_equals($schluessel, $mitgebracht)) {
|
|
return $this->fehler(403, 'Diese Kennung gehört zu einer anderen Installation.');
|
|
}
|
|
}
|
|
if (!hash_equals(hash_hmac('sha256', $body, $schluessel), $signatur)) {
|
|
return $this->fehler(403, 'Die Unterschrift stimmt nicht.');
|
|
}
|
|
|
|
$jetzt = Datenbank::jetzt();
|
|
$domain = strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST));
|
|
$update = is_array($daten['update'] ?? null) ? $daten['update'] : [];
|
|
$werte = [
|
|
'adresse' => $daten['adresse'],
|
|
'domain' => $domain,
|
|
'fassung' => $daten['fassung'],
|
|
'php' => mb_substr((string) ($daten['php'] ?? ''), 0, 20),
|
|
'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20),
|
|
'art' => 'git' === ($daten['art'] ?? '') ? 'git' : 'archiv',
|
|
'wartung' => true === ($daten['wartung'] ?? false) ? 1 : 0,
|
|
'update_phase' => isset($update['phase']) ? mb_substr((string) $update['phase'], 0, 30) : null,
|
|
'update_meldung' => isset($update['meldung']) ? mb_substr((string) $update['meldung'], 0, 300) : null,
|
|
'update_zeit' => isset($update['zeit']) ? mb_substr((string) $update['zeit'], 0, 40) : null,
|
|
'ip' => mb_substr($ip, 0, 45),
|
|
'letzte_meldung' => $jetzt,
|
|
];
|
|
|
|
$this->db->beginTransaction();
|
|
try {
|
|
if (null === $installation) {
|
|
$sql = 'INSERT INTO installation (id, schluessel, erste_meldung, '.implode(', ', array_keys($werte)).') VALUES (?, ?, ?, '.implode(', ', array_fill(0, count($werte), '?')).')';
|
|
$this->db->prepare($sql)->execute([$daten['id'], $schluessel, $jetzt, ...array_values($werte)]);
|
|
} else {
|
|
// Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation
|
|
// kopiert oder umgezogen. Beides will man sehen.
|
|
if ($installation['domain'] !== $domain) {
|
|
$werte['domain_vorher'] = $installation['domain'];
|
|
}
|
|
$sql = 'UPDATE installation SET '.implode(', ', array_map(static fn (string $spalte): string => $spalte.' = ?', array_keys($werte))).' WHERE id = ?';
|
|
$this->db->prepare($sql)->execute([...array_values($werte), $daten['id']]);
|
|
}
|
|
|
|
$ergebnis = $daten['auftrag'] ?? null;
|
|
if (is_array($ergebnis) && is_int($ergebnis['id'] ?? null) && is_bool($ergebnis['ok'] ?? null)) {
|
|
$this->db->prepare('UPDATE auftrag SET erledigt = ?, ok = ?, meldung = ?, abgeholt = COALESCE(abgeholt, ?) WHERE id = ? AND installation = ? AND erledigt IS NULL')
|
|
->execute([$jetzt, $ergebnis['ok'] ? 1 : 0, mb_substr((string) ($ergebnis['meldung'] ?? ''), 0, 500), $jetzt, $ergebnis['id'], $daten['id']]);
|
|
}
|
|
|
|
$offen = $this->db->prepare('SELECT id, aktion, fassung FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL ORDER BY id DESC LIMIT 1');
|
|
$offen->execute([$daten['id']]);
|
|
$auftrag = $offen->fetch() ?: null;
|
|
if (null !== $auftrag) {
|
|
$this->db->prepare('UPDATE auftrag SET abgeholt = ? WHERE id = ?')->execute([$jetzt, $auftrag['id']]);
|
|
$auftrag = ['id' => (int) $auftrag['id'], 'aktion' => (string) $auftrag['aktion'], 'fassung' => (string) $auftrag['fassung']];
|
|
}
|
|
$this->db->commit();
|
|
} catch (\Throwable $fehler) {
|
|
$this->db->rollBack();
|
|
throw $fehler;
|
|
}
|
|
|
|
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
|
|
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
|
|
}
|
|
|
|
/** @param array<string, mixed> $daten */
|
|
private function pruefen(array $daten): ?string
|
|
{
|
|
if (!is_string($daten['id'] ?? null) || 1 !== preg_match('/^[a-f0-9]{32}$/D', $daten['id'])) {
|
|
return 'Die Kennung fehlt oder hat die falsche Form.';
|
|
}
|
|
$adresse = $daten['adresse'] ?? null;
|
|
if (!is_string($adresse) || strlen($adresse) > 200 || 1 !== preg_match('#^https?://[a-z0-9.-]+(:\d{1,5})?(/[^\s]*)?$#iD', $adresse)) {
|
|
return 'Die Adresse fehlt oder ist keine Webadresse.';
|
|
}
|
|
if (!is_string($daten['fassung'] ?? null) || 1 !== preg_match('/^(\d{1,4}\.\d{1,5}\.\d{1,6}|unbekannt)$/D', $daten['fassung'])) {
|
|
return 'Die Fassung hat nicht die Form 1.2.3.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @return array{status: int, body: string, signatur: null} */
|
|
private function fehler(int $status, string $text): array
|
|
{
|
|
return ['status' => $status, 'body' => (string) json_encode(['ok' => false, 'fehler' => $text], JSON_UNESCAPED_UNICODE), 'signatur' => null];
|
|
}
|
|
}
|