From 8daef8e98faa790accf97ebdff4ead217321fe2c Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Tue, 9 Jun 2026 18:14:41 +0200 Subject: [PATCH] =?UTF-8?q?White-Label=20Phase=205:=20DNS-Automatik=20f?= =?UTF-8?q?=C3=BCr=20Firmen-Subdomains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DnsProvisioner (dependency-frei, cURL) legt pro Reseller *.. A-Record via Hetzner-Cloud-DNS-API an (deckt firma.reseller.portal ab, was der globale *.-Eintrag nicht kann) - ResellerDnsListener (Doctrine postPersist/preRemove), fail-soft, überspringt Plattform-Reseller - Env HCLOUD_DNS_TOKEN/HCLOUD_DNS_ZONE_NAME (leer = aus); Terraform reicht Cloud-Token + Zone an die App-Nodes durch (nur bei manage_dns) - Ziel-IP = APP_EDGE_IP oder DNS-Auflösung der Portal-Domain Co-Authored-By: Claude Opus 4.8 --- backend/.env | 3 + backend/src/Controller/DomainController.php | 4 +- .../src/EventListener/ResellerDnsListener.php | 37 ++++ backend/src/Service/DnsProvisioner.php | 160 ++++++++++++++++++ deploy/terraform/cloud-init-app.yaml.tftpl | 2 + deploy/terraform/main.tf | 3 + 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 backend/src/EventListener/ResellerDnsListener.php create mode 100644 backend/src/Service/DnsProvisioner.php diff --git a/backend/.env b/backend/.env index b73fae4..4dc3a26 100644 --- a/backend/.env +++ b/backend/.env @@ -55,6 +55,9 @@ TRUSTED_PROXIES=127.0.0.1 # Öffentliche IP der Caddy-Edge für Custom-Domain-Verifizierung. Optional: # leer → es wird die DNS-Auflösung von APP_PORTAL_DOMAIN genutzt. APP_EDGE_IP= +# Hetzner-Cloud-DNS-Automatik für Firmen-Subdomains (*.reseller.portal). Leer = aus. +HCLOUD_DNS_TOKEN= +HCLOUD_DNS_ZONE_NAME= ###< App ### ###> S3 / Object Storage (Druck-Assets) ### diff --git a/backend/src/Controller/DomainController.php b/backend/src/Controller/DomainController.php index a13b52e..69cc3f0 100644 --- a/backend/src/Controller/DomainController.php +++ b/backend/src/Controller/DomainController.php @@ -33,8 +33,8 @@ final class DomainController private readonly TenantResolver $resolver, #[Autowire('%env(APP_PORTAL_DOMAIN)%')] private readonly string $portalDomain, - #[Autowire('%env(default::APP_EDGE_IP)%')] - private readonly ?string $edgeIp, + #[Autowire('%env(APP_EDGE_IP)%')] + private readonly string $edgeIp, ) { } diff --git a/backend/src/EventListener/ResellerDnsListener.php b/backend/src/EventListener/ResellerDnsListener.php new file mode 100644 index 0000000..3918e5e --- /dev/null +++ b/backend/src/EventListener/ResellerDnsListener.php @@ -0,0 +1,37 @@ +.) pro Reseller im DNS + * aktuell. Fail-soft: DNS-Fehler dürfen die Reseller-Anlage nicht verhindern. + */ +#[AsEntityListener(event: Events::postPersist, method: 'onPostPersist', entity: Reseller::class)] +#[AsEntityListener(event: Events::preRemove, method: 'onPreRemove', entity: Reseller::class)] +final class ResellerDnsListener +{ + public function __construct(private readonly DnsProvisioner $dns) + { + } + + public function onPostPersist(Reseller $reseller): void + { + if ($reseller->isPlatform()) { + return; // Plattform läuft direkt auf der Portal-Domain + } + $this->dns->ensureResellerWildcard($reseller->getSlug()); + } + + public function onPreRemove(Reseller $reseller): void + { + if ($reseller->isPlatform()) { + return; + } + $this->dns->removeResellerWildcard($reseller->getSlug()); + } +} diff --git a/backend/src/Service/DnsProvisioner.php b/backend/src/Service/DnsProvisioner.php new file mode 100644 index 0000000..8cf9e14 --- /dev/null +++ b/backend/src/Service/DnsProvisioner.php @@ -0,0 +1,160 @@ +. 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 : []]; + } +} diff --git a/deploy/terraform/cloud-init-app.yaml.tftpl b/deploy/terraform/cloud-init-app.yaml.tftpl index de00890..ccb6c26 100644 --- a/deploy/terraform/cloud-init-app.yaml.tftpl +++ b/deploy/terraform/cloud-init-app.yaml.tftpl @@ -11,6 +11,8 @@ write_files: DATABASE_URL="${database_url}" CORS_ALLOW_ORIGIN=${cors_allow_origin} TRUSTED_PROXIES=10.0.0.0/16 + HCLOUD_DNS_TOKEN=${hcloud_dns_token} + HCLOUD_DNS_ZONE_NAME=${dns_zone_name} JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=${jwt_passphrase} diff --git a/deploy/terraform/main.tf b/deploy/terraform/main.tf index 65eb9af..b72413b 100644 --- a/deploy/terraform/main.tf +++ b/deploy/terraform/main.tf @@ -111,6 +111,9 @@ resource "hcloud_server" "app" { s3_bucket = var.s3_bucket s3_key = var.s3_key s3_secret = var.s3_secret + # DNS-Automatik für Firmen-Subdomains (nur wenn wir das DNS verwalten) + hcloud_dns_token = var.manage_dns ? var.hcloud_token : "" + dns_zone_name = var.manage_dns ? var.dns_zone_name : "" }) network {