From 7238e7a937e6bbf396cddf6a4080a07dbf3e10fb Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] feat(domains): Pruefung von DNS, Zuordnung und Zertifikat Eine reine DNS-Pruefung sagt nichts darueber aus, ob der Webserver die Domain auch dem richtigen Verzeichnis zuordnet - genau das ist der haeufigste Fehler. Die Pruefung ruft die Domain deshalb von aussen auf einen signierten Endpunkt auf. Kommt der Aufruf mit gueltigem Token an, ist die ganze Kette bewiesen, denn nur diese Installation kann ein solches Token erzeugen. Erkannt und benannt werden: fehlender DNS-Eintrag, Domain-Weiterleitung des Registrars, falsches Verzeichnis, ungueltiges Zertifikat. Link-lokale Adressen werden nicht abgerufen - dort liegen bei Cloud- Anbietern die Metadaten-Dienste. --- src/Service/DomainChecker.php | 468 ++++++++++++++++++++++++++++++++++ src/Support/CheckToken.php | 50 ++++ src/Support/IpRange.php | 137 ++++++++++ 3 files changed, 655 insertions(+) create mode 100644 src/Service/DomainChecker.php create mode 100644 src/Support/CheckToken.php create mode 100644 src/Support/IpRange.php diff --git a/src/Service/DomainChecker.php b/src/Service/DomainChecker.php new file mode 100644 index 0000000..60fa644 --- /dev/null +++ b/src/Service/DomainChecker.php @@ -0,0 +1,468 @@ + $domain + * @return array{status:string,summary:string,checks:list,hint:?string} + */ + public function check(array $domain): array + { + $baseUrl = (string) $domain['base_url']; + $host = ShortDomain::hostOf($baseUrl); + $hostname = self::hostnameOf($host); + + $checks = []; + + // --- 1. DNS --------------------------------------------------- + $dns = $this->checkDns($hostname); + $checks[] = $dns; + + // Kein Abruf von Link-Local-Adressen: dort liegen bei Cloud- + // Anbietern die Metadaten-Dienste (169.254.169.254), ueber die + // sich Zugangsdaten abgreifen lassen. Als Kurzlink-Domain ergibt + // ein solcher Bereich ohnehin nie Sinn. + if ($this->resolvesToLinkLocal($hostname)) { + $checks[] = [ + 'label' => 'Zuordnung', + 'status' => self::ERROR, + 'text' => 'Diese Adresse liegt in einem link-lokalen Bereich (169.254.0.0/16 bzw. fe80::/10) ' + . 'und wird aus Sicherheitsgründen nicht abgerufen.', + ]; + + return [ + 'status' => self::ERROR, + 'summary' => 'Die Domain kann nicht geprüft werden.', + 'checks' => $checks, + 'hint' => null, + ]; + } + + // --- 2. Erreichbarkeit und Zuordnung -------------------------- + $reachability = $this->checkReachability($baseUrl); + $checks[] = $reachability['check']; + + if ($reachability['tls'] !== null) { + $checks[] = $reachability['tls']; + } + + return $this->summarize($checks, $reachability['confirmed'], $hostname); + } + + /** + * @return list + */ + private function resolve(string $hostname): array + { + $bare = trim($hostname, '[]'); + + if (filter_var($bare, FILTER_VALIDATE_IP) !== false) { + return [$bare]; + } + + if (!function_exists('gethostbynamel')) { + return []; + } + + $addresses = @gethostbynamel($hostname) ?: []; + + if (function_exists('dns_get_record')) { + foreach (@dns_get_record($hostname, DNS_AAAA) ?: [] as $record) { + if (isset($record['ipv6'])) { + $addresses[] = (string) $record['ipv6']; + } + } + } + + return array_values(array_unique($addresses)); + } + + private function resolvesToLinkLocal(string $hostname): bool + { + foreach ($this->resolve($hostname) as $address) { + if (IpRange::isLinkLocal($address)) { + return true; + } + } + + return false; + } + + /** + * @return array{label:string,status:string,text:string} + */ + private function checkDns(string $hostname): array + { + if (filter_var(trim($hostname, '[]'), FILTER_VALIDATE_IP) !== false) { + return [ + 'label' => 'DNS', + 'status' => self::OK, + 'text' => 'Die Domain ist eine IP-Adresse – es ist kein DNS-Eintrag nötig.', + ]; + } + + if (!function_exists('gethostbynamel')) { + return [ + 'label' => 'DNS', + 'status' => self::WARNING, + 'text' => 'Die DNS-Auflösung kann auf diesem Server nicht geprüft werden.', + ]; + } + + $addresses = $this->resolve($hostname); + + if ($addresses === []) { + return [ + 'label' => 'DNS', + 'status' => self::ERROR, + 'text' => sprintf( + '"%s" lässt sich nicht auflösen. Der A-/AAAA- bzw. CNAME-Eintrag fehlt oder ist noch nicht verteilt.', + $hostname, + ), + ]; + } + + return [ + 'label' => 'DNS', + 'status' => self::OK, + 'text' => sprintf('%s löst auf %s auf.', $hostname, implode(', ', $addresses)), + ]; + } + + /** + * Ruft die Domain von aussen auf und prueft, ob die Antwort von + * dieser Installation stammt. + * + * @return array{check:array{label:string,status:string,text:string},tls:?array{label:string,status:string,text:string},confirmed:bool} + */ + private function checkReachability(string $baseUrl): array + { + $token = CheckToken::create($this->settings->secret()); + $url = rtrim($baseUrl, '/') . self::PATH . '?token=' . rawurlencode($token); + + $response = $this->request($url, true); + + // TLS-Problem? Zur Unterscheidung ein zweiter Versuch ohne + // Zertifikatspruefung - nur zur Diagnose, nie als "in Ordnung". + $tls = null; + if ($response['error'] === 'tls') { + $insecure = $this->request($url, false); + + if ($insecure['error'] === null) { + $tls = [ + 'label' => 'HTTPS', + 'status' => self::ERROR, + 'text' => 'Das TLS-Zertifikat gilt nicht für diese Domain oder ist abgelaufen. ' + . 'Bitte ein Zertifikat für die Domain ausstellen (Schritt 3 der Anleitung).', + ]; + $response = $insecure; + } else { + return [ + 'check' => [ + 'label' => 'Erreichbarkeit', + 'status' => self::ERROR, + 'text' => 'Die Domain ist über HTTPS nicht erreichbar: ' . $response['message'], + ], + 'tls' => null, + 'confirmed' => false, + ]; + } + } + + if ($response['error'] !== null) { + return [ + 'check' => [ + 'label' => 'Erreichbarkeit', + 'status' => self::ERROR, + 'text' => 'Die Domain konnte nicht abgerufen werden: ' . $response['message'], + ], + 'tls' => $tls, + 'confirmed' => false, + ]; + } + + $status = $response['status']; + + // Weiterleitung -> typischerweise eine Domain-Weiterleitung + // beim Anbieter statt einer echten Zuordnung. + if ($status >= 300 && $status < 400) { + return [ + 'check' => [ + 'label' => 'Zuordnung', + 'status' => self::ERROR, + 'text' => sprintf( + 'Die Domain antwortet mit einer Weiterleitung (HTTP %d%s). ' + . 'Vermutlich ist beim Anbieter eine "Domain-Weiterleitung" eingerichtet – ' + . 'gebraucht wird stattdessen eine Zuordnung auf dasselbe Verzeichnis.', + $status, + $response['location'] !== '' ? ' nach ' . $response['location'] : '', + ), + ], + 'tls' => $tls, + 'confirmed' => false, + ]; + } + + if ($status === 200 && $this->isOwnResponse($response['body'])) { + return [ + 'check' => [ + 'label' => 'Zuordnung', + 'status' => self::OK, + 'text' => 'Die Domain landet bei dieser Installation – Webserver und Verzeichnis stimmen.', + ], + 'tls' => $tls, + 'confirmed' => true, + ]; + } + + return [ + 'check' => [ + 'label' => 'Zuordnung', + 'status' => self::ERROR, + 'text' => sprintf( + 'Die Domain ist erreichbar (HTTP %d), die Antwort kommt aber nicht von dieser Installation. ' + . 'Vermutlich zeigt sie auf ein anderes Verzeichnis oder eine zweite Installation.', + $status, + ), + ], + 'tls' => $tls, + 'confirmed' => false, + ]; + } + + private function isOwnResponse(string $body): bool + { + /** @var array|null $data */ + $data = json_decode($body, true); + + return is_array($data) && ($data['ok'] ?? false) === true; + } + + /** + * @param list $checks + * @return array{status:string,summary:string,checks:list,hint:?string} + */ + private function summarize(array $checks, bool $confirmed, string $hostname): array + { + if ($confirmed) { + $hasWarning = false; + foreach ($checks as $check) { + if ($check['status'] !== self::OK) { + $hasWarning = true; + } + } + + return [ + 'status' => $hasWarning ? self::WARNING : self::OK, + 'summary' => $hasWarning + ? 'Die Domain kommt hier an, es gibt aber noch etwas zu tun.' + : 'Alles in Ordnung – die Domain ist korrekt eingerichtet.', + 'checks' => $checks, + 'hint' => null, + ]; + } + + $dnsFailed = ($checks[0]['status'] ?? self::OK) === self::ERROR; + + return [ + 'status' => self::ERROR, + 'summary' => $dnsFailed + ? 'Die Domain ist noch nicht eingerichtet.' + : 'Die Domain zeigt noch nicht auf diese Installation.', + 'checks' => $checks, + 'hint' => 'Hinweis: Manche Server können ihren eigenen öffentlichen Hostnamen nicht aufrufen ' + . '(fehlendes NAT-Hairpinning). Wenn ' . $hostname . ' von außen – etwa vom Handy über ' + . 'das Mobilfunknetz – funktioniert, ist alles in Ordnung und diese Meldung kann ' + . 'ignoriert werden.', + ]; + } + + /** + * Fuehrt genau eine GET-Anfrage aus, ohne Weiterleitungen zu folgen. + * + * @return array{error:?string,message:string,status:int,body:string,location:string} + */ + private function request(string $url, bool $verifyTls): array + { + if (function_exists('curl_init')) { + return $this->requestWithCurl($url, $verifyTls); + } + + return $this->requestWithStream($url, $verifyTls); + } + + /** + * @return array{error:?string,message:string,status:int,body:string,location:string} + */ + private function requestWithCurl(string $url, bool $verifyTls): array + { + $handle = curl_init(); + $body = ''; + + curl_setopt_array($handle, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, + CURLOPT_TIMEOUT => self::TOTAL_TIMEOUT, + CURLOPT_SSL_VERIFYPEER => $verifyTls, + CURLOPT_SSL_VERIFYHOST => $verifyTls ? 2 : 0, + CURLOPT_USERAGENT => 'urlshort-qr-gen/domain-check', + CURLOPT_ACCEPT_ENCODING => '', + // Antwort begrenzen: der Endpunkt liefert nur wenige Bytes. + CURLOPT_WRITEFUNCTION => static function ($_, string $chunk) use (&$body): int { + $body .= $chunk; + + return strlen($body) > self::MAX_RESPONSE_BYTES ? 0 : strlen($chunk); + }, + ]); + + $ok = curl_exec($handle); + $errorNumber = curl_errno($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $location = (string) (curl_getinfo($handle, CURLINFO_REDIRECT_URL) ?: ''); + $message = curl_error($handle); + curl_close($handle); + + // Abbruch durch die Groessenbegrenzung ist kein echter Fehler. + if ($errorNumber === CURLE_WRITE_ERROR && $status > 0) { + $errorNumber = 0; + } + + if ($ok === false && $errorNumber !== 0) { + $tlsErrors = [CURLE_SSL_CACERT, CURLE_SSL_PEER_CERTIFICATE, CURLE_SSL_CONNECT_ERROR, 60, 51]; + + return [ + 'error' => in_array($errorNumber, $tlsErrors, true) ? 'tls' : 'connection', + 'message' => $message !== '' ? $message : 'Fehler ' . $errorNumber, + 'status' => 0, + 'body' => '', + 'location' => '', + ]; + } + + return [ + 'error' => null, + 'message' => '', + 'status' => $status, + 'body' => $body, + 'location' => $location, + ]; + } + + /** + * Rueckfallweg ohne ext-curl. + * + * @return array{error:?string,message:string,status:int,body:string,location:string} + */ + private function requestWithStream(string $url, bool $verifyTls): array + { + if (!ini_get('allow_url_fopen')) { + return [ + 'error' => 'connection', + 'message' => 'Auf diesem Server sind weder cURL noch allow_url_fopen verfügbar, ' + . 'die Domain kann daher nicht automatisch geprüft werden.', + 'status' => 0, + 'body' => '', + 'location' => '', + ]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => self::TOTAL_TIMEOUT, + 'follow_location' => 0, + 'ignore_errors' => true, + 'user_agent' => 'urlshort-qr-gen/domain-check', + ], + 'ssl' => [ + 'verify_peer' => $verifyTls, + 'verify_peer_name' => $verifyTls, + ], + ]); + + $body = @file_get_contents($url, false, $context, 0, self::MAX_RESPONSE_BYTES); + + if ($body === false) { + $error = error_get_last()['message'] ?? 'unbekannter Fehler'; + $isTls = stripos($error, 'ssl') !== false || stripos($error, 'certificate') !== false; + + return [ + 'error' => $isTls ? 'tls' : 'connection', + 'message' => $error, + 'status' => 0, + 'body' => '', + 'location' => '', + ]; + } + + $status = 0; + $location = ''; + foreach ($http_response_header ?? [] as $header) { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $matches) === 1) { + $status = (int) $matches[1]; + } + if (stripos($header, 'location:') === 0) { + $location = trim(substr($header, 9)); + } + } + + return [ + 'error' => null, + 'message' => '', + 'status' => $status, + 'body' => $body, + 'location' => $location, + ]; + } + + /** + * Hostname ohne Portangabe. + */ + private static function hostnameOf(string $host): string + { + if (str_starts_with($host, '[')) { + $end = strpos($host, ']'); + + return $end === false ? $host : substr($host, 0, $end + 1); + } + + $colon = strrpos($host, ':'); + + return $colon === false ? $host : substr($host, 0, $colon); + } +} diff --git a/src/Support/CheckToken.php b/src/Support/CheckToken.php new file mode 100644 index 0000000..ce9de40 --- /dev/null +++ b/src/Support/CheckToken.php @@ -0,0 +1,50 @@ + self::LIFETIME) { + return false; + } + + return hash_equals(hash_hmac('sha256', $timestamp, $secret), $signature); + } +} diff --git a/src/Support/IpRange.php b/src/Support/IpRange.php new file mode 100644 index 0000000..9fc51ca --- /dev/null +++ b/src/Support/IpRange.php @@ -0,0 +1,137 @@ + + */ + public const LINK_LOCAL = [ + '169.254.0.0/16', + 'fe80::/10', + ]; + + /** + * @var list + */ + public const PRIVATE_RANGES = [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '100.64.0.0/10', + '127.0.0.0/8', + '0.0.0.0/8', + '::1/128', + 'fc00::/7', + ]; + + /** + * Prueft eine Adresse gegen eine Liste aus Einzeladressen und + * CIDR-Bereichen. + * + * @param list $ranges + */ + public static function matchesAny(string $ip, array $ranges): bool + { + foreach ($ranges as $range) { + if (self::matches($ip, $range)) { + return true; + } + } + + return false; + } + + public static function matches(string $ip, string $range): bool + { + $ip = trim($ip); + $range = trim($range); + + if ($ip === '' || $range === '') { + return false; + } + + $binaryIp = @inet_pton($ip); + if ($binaryIp === false) { + return false; + } + + if (!str_contains($range, '/')) { + $binaryRange = @inet_pton($range); + + return $binaryRange !== false && hash_equals($binaryRange, $binaryIp); + } + + [$subnet, $bits] = explode('/', $range, 2); + + $binarySubnet = @inet_pton($subnet); + if ($binarySubnet === false || !ctype_digit($bits)) { + return false; + } + + // IPv4 und IPv6 nicht miteinander vergleichen. + if (strlen($binarySubnet) !== strlen($binaryIp)) { + return false; + } + + $prefix = (int) $bits; + $maxBits = strlen($binaryIp) * 8; + + if ($prefix < 0 || $prefix > $maxBits) { + return false; + } + + $wholeBytes = intdiv($prefix, 8); + $remainingBits = $prefix % 8; + + if ($wholeBytes > 0 && !hash_equals( + substr($binarySubnet, 0, $wholeBytes), + substr($binaryIp, 0, $wholeBytes), + )) { + return false; + } + + if ($remainingBits === 0) { + return true; + } + + $mask = 0xFF << (8 - $remainingBits) & 0xFF; + + return (ord($binaryIp[$wholeBytes]) & $mask) === (ord($binarySubnet[$wholeBytes]) & $mask); + } + + public static function isLinkLocal(string $ip): bool + { + return self::matchesAny($ip, self::LINK_LOCAL); + } + + public static function isPrivate(string $ip): bool + { + return self::matchesAny($ip, self::PRIVATE_RANGES); + } + + /** + * Zerlegt eine kommaseparierte Konfigurationsangabe. + * + * @return list + */ + public static function parseList(string $value): array + { + $parts = array_map('trim', explode(',', $value)); + + return array_values(array_filter($parts, static fn (string $part): bool => $part !== '')); + } +}