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 !== '')); + } +}