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.
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
use App\Support\CheckToken;
|
||||
use App\Support\IpRange;
|
||||
use App\Support\ShortDomain;
|
||||
|
||||
/**
|
||||
* Prueft, ob eine eingetragene Domain tatsaechlich bei dieser
|
||||
* Installation ankommt.
|
||||
*
|
||||
* Es wird bewusst nicht nur der DNS-Eintrag angesehen: ein korrekter
|
||||
* A-Record sagt nichts darueber aus, ob der Webserver die Domain auch
|
||||
* dem richtigen Verzeichnis zuordnet. Der eigentliche Nachweis ist
|
||||
* deshalb ein Aufruf der Domain von aussen auf einen signierten
|
||||
* Endpunkt - antwortet der wie erwartet, stimmt die gesamte Kette.
|
||||
*/
|
||||
final class DomainChecker
|
||||
{
|
||||
public const PATH = '/.well-known/urlshort-check';
|
||||
|
||||
public const OK = 'ok';
|
||||
public const WARNING = 'warning';
|
||||
public const ERROR = 'error';
|
||||
|
||||
private const CONNECT_TIMEOUT = 5;
|
||||
private const TOTAL_TIMEOUT = 10;
|
||||
private const MAX_RESPONSE_BYTES = 65536;
|
||||
|
||||
public function __construct(private readonly SettingRepository $settings)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $domain
|
||||
* @return array{status:string,summary:string,checks:list<array{label:string,status:string,text:string}>,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<string>
|
||||
*/
|
||||
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<string,mixed>|null $data */
|
||||
$data = json_decode($body, true);
|
||||
|
||||
return is_array($data) && ($data['ok'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{label:string,status:string,text:string}> $checks
|
||||
* @return array{status:string,summary:string,checks:list<array{label:string,status:string,text:string}>,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Kurzlebiges, signiertes Token fuer die Domain-Pruefung.
|
||||
*
|
||||
* Die Pruefung ruft die zu testende Domain von aussen auf. Der dafuer
|
||||
* noetige Endpunkt muss ohne Login erreichbar sein - deshalb ist er nur
|
||||
* mit einem gueltigen Token ansprechbar und antwortet sonst mit 404.
|
||||
*
|
||||
* Das Token ist zustandslos (HMAC ueber den Zeitstempel), es muss also
|
||||
* nichts zwischengespeichert werden. Kommt der Aufruf mit einem
|
||||
* gueltigen Token an, ist damit zugleich bewiesen, dass er bei DIESER
|
||||
* Installation gelandet ist - nur sie kennt das Geheimnis.
|
||||
*/
|
||||
final class CheckToken
|
||||
{
|
||||
/** Gueltigkeitsdauer in Sekunden. */
|
||||
public const LIFETIME = 180;
|
||||
|
||||
public static function create(string $secret, ?int $now = null): string
|
||||
{
|
||||
$timestamp = (string) ($now ?? time());
|
||||
|
||||
return $timestamp . '.' . hash_hmac('sha256', $timestamp, $secret);
|
||||
}
|
||||
|
||||
public static function isValid(string $token, string $secret, ?int $now = null): bool
|
||||
{
|
||||
$parts = explode('.', $token, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$timestamp, $signature] = $parts;
|
||||
|
||||
if ($timestamp === '' || !ctype_digit($timestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (abs(($now ?? time()) - (int) $timestamp) > self::LIFETIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(hash_hmac('sha256', $timestamp, $secret), $signature);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Vergleich von IP-Adressen mit einzelnen Adressen und CIDR-Bereichen.
|
||||
* Wird fuer die Liste vertrauenswuerdiger Proxys und fuer den Schutz
|
||||
* interner Netze verwendet.
|
||||
*/
|
||||
final class IpRange
|
||||
{
|
||||
/**
|
||||
* Bereiche, die niemals eine oeffentliche Kurzlink-Domain sein
|
||||
* koennen und deshalb bei der Domain-Pruefung nicht abgerufen
|
||||
* werden. Besonders wichtig: 169.254.169.254 - ueber diese Adresse
|
||||
* liefern Cloud-Anbieter Zugangsdaten aus.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const LINK_LOCAL = [
|
||||
'169.254.0.0/16',
|
||||
'fe80::/10',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
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<string> $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<string>
|
||||
*/
|
||||
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 !== ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user