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); } $anmeldung = $this->anmeldung($daten, $body, $signatur, 'kantsites'); if (isset($anmeldung['status'])) { return $anmeldung; } ['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung; $jetzt = Datenbank::jetzt(); $update = is_array($daten['update'] ?? null) ? $daten['update'] : []; $werte = [ 'adresse' => $daten['adresse'], 'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)), '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 { $this->speichern($daten['id'], $schluessel, $installation, $werte); $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)]; } /** * Die Meldung einer KantTresor-Instanz: Stand, Mandantenzahl, gestörte Dienste, Speicher. * Die Antwort enthält nie einen Auftrag. * * @return array{status: int, body: string, signatur: ?string} */ public function meldenTresor(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); } $anmeldung = $this->anmeldung($daten, $body, $signatur, 'kanttresor'); if (isset($anmeldung['status'])) { return $anmeldung; } ['installation' => $installation, 'schluessel' => $schluessel] = $anmeldung; $stoerungen = is_array($daten['stoerungen'] ?? null) ? array_slice(array_values(array_filter($daten['stoerungen'], 'is_string')), 0, 10) : []; $mandanten = $daten['mandanten'] ?? null; $speicher = $daten['speicher'] ?? null; $werte = [ 'produkt' => 'kanttresor', 'adresse' => $daten['adresse'], 'domain' => strtolower((string) parse_url($daten['adresse'], PHP_URL_HOST)), 'fassung' => $daten['fassung'], 'laufzeit' => mb_substr((string) ($daten['laufzeit'] ?? ''), 0, 60), 'datenbank' => mb_substr((string) ($daten['datenbank'] ?? ''), 0, 20), 'mandanten' => is_int($mandanten) && $mandanten >= 0 && $mandanten <= 100000 ? $mandanten : null, 'stoerungen' => mb_substr(implode(', ', array_map(static fn (string $s): string => mb_substr(trim($s), 0, 60), $stoerungen)), 0, 300), 'speicher' => is_int($speicher) && $speicher >= 0 && $speicher <= 100 ? $speicher : null, 'ip' => mb_substr($ip, 0, 45), 'letzte_meldung' => Datenbank::jetzt(), ]; $this->speichern($daten['id'], $schluessel, $installation, $werte); $antwort = '{"ok":true}'; return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)]; } /** * Wer meldet sich? Beim ersten Kontakt mit Schlüssel, danach nur mit gültiger Unterschrift. * * @param array $daten * * @return array{installation: ?array, schluessel: string}|array{status: int, body: string, signatur: null} */ private function anmeldung(array $daten, string $body, string $signatur, string $produkt): array { $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.'); } if (null !== $installation && $produkt !== $installation['produkt']) { return $this->fehler(403, 'Diese Kennung gehört zu einem anderen Produkt.'); } return ['installation' => $installation, 'schluessel' => $schluessel]; } /** * @param array|null $installation * @param array $werte mit letzte_meldung */ private function speichern(string $id, string $schluessel, ?array $installation, array $werte): void { 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([$id, $schluessel, $werte['letzte_meldung'], ...array_values($werte)]); return; } // Meldet sich dieselbe Kennung unter einer anderen Domain, wurde die Installation // kopiert oder umgezogen. Beides will man sehen. if ($installation['domain'] !== $werte['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), $id]); } /** @param array $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]; } }