. an, damit die * Firmen-Subdomains (firma.reseller.portal, zwei Ebenen tief) auflösen – der * globale *.-Eintrag deckt nur eine Ebene ab (KONZEPT §11). * * Nutzt die in die Hetzner-Cloud-API integrierte DNS-API (gleicher Cloud-Token * wie Terraform, siehe dns.tf). Ohne Konfiguration ein No-Op (Dev/Tests). * Fehler brechen die Reseller-Anlage nie ab (fail-soft). */ final class DnsProvisioner { private const API = 'https://api.hetzner.cloud/v1'; private ?string $zoneId = null; public function __construct( #[Autowire('%env(HCLOUD_DNS_TOKEN)%')] private readonly string $token, #[Autowire('%env(HCLOUD_DNS_ZONE_NAME)%')] private readonly string $zoneName, #[Autowire('%env(APP_PORTAL_DOMAIN)%')] private readonly string $portalDomain, #[Autowire('%env(APP_EDGE_IP)%')] private readonly string $edgeIp, private readonly LoggerInterface $logger, ) { } public function isConfigured(): bool { return '' !== trim($this->token) && '' !== trim($this->zoneName) && null !== $this->targetIp(); } public function ensureResellerWildcard(string $slug): bool { if (!$this->isConfigured()) { return false; } $zone = $this->zoneId(); if (null === $zone) { return false; } [$status, $body] = $this->request('POST', "/zones/$zone/rrsets", [ 'name' => $this->rrsetName($slug), 'type' => 'A', 'ttl' => 300, 'records' => [['value' => $this->targetIp()]], ]); if ($status >= 200 && $status < 300) { return true; } if (409 === $status || 422 === $status) { return true; // existiert bereits } $this->logger->warning('DNS-Wildcard anlegen fehlgeschlagen', ['slug' => $slug, 'status' => $status, 'body' => $body]); return false; } public function removeResellerWildcard(string $slug): bool { if (!$this->isConfigured()) { return false; } $zone = $this->zoneId(); if (null === $zone) { return false; } [$status] = $this->request('DELETE', sprintf('/zones/%s/rrsets/%s/A', $zone, rawurlencode($this->rrsetName($slug)))); return $status >= 200 && $status < 300; } /** rrset-Name relativ zur Zone, z. B. "*.demo" (Portal == Zone) bzw. "*.demo.sub". */ private function rrsetName(string $slug): string { $full = '*.'.$slug.'.'.$this->portalDomain; $zone = trim($this->zoneName, '.'); if ($full === $zone) { return '@'; } if (str_ends_with($full, '.'.$zone)) { return substr($full, 0, -\strlen('.'.$zone)); } return $full; } private function targetIp(): ?string { $ip = trim($this->edgeIp); if ('' !== $ip) { return $ip; } $resolved = gethostbynamel($this->portalDomain) ?: []; return $resolved[0] ?? null; } private function zoneId(): ?string { if (null !== $this->zoneId) { return $this->zoneId; } [$status, $body] = $this->request('GET', '/zones?name='.rawurlencode(trim($this->zoneName, '.'))); if ($status >= 200 && $status < 300) { $id = $body['zones'][0]['id'] ?? null; if (null !== $id) { return $this->zoneId = (string) $id; } } $this->logger->warning('DNS-Zone nicht gefunden', ['zone' => $this->zoneName, 'status' => $status]); return null; } /** * @param array|null $payload * * @return array{0:int,1:array} */ private function request(string $method, string $path, ?array $payload = null): array { $ch = curl_init(self::API.$path); $headers = ['Authorization: Bearer '.$this->token, 'Content-Type: application/json']; curl_setopt_array($ch, [ \CURLOPT_CUSTOMREQUEST => $method, \CURLOPT_RETURNTRANSFER => true, \CURLOPT_HTTPHEADER => $headers, \CURLOPT_TIMEOUT => 8, \CURLOPT_CONNECTTIMEOUT => 4, ]); if (null !== $payload) { curl_setopt($ch, \CURLOPT_POSTFIELDS, json_encode($payload)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, \CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if (false === $raw) { $this->logger->warning('DNS-API nicht erreichbar', ['path' => $path, 'error' => $err]); return [0, []]; } $decoded = json_decode((string) $raw, true); return [$status, \is_array($decoded) ? $decoded : []]; } }