White-Label Phase 5: DNS-Automatik für Firmen-Subdomains

- DnsProvisioner (dependency-frei, cURL) legt pro Reseller *.<slug>.<portal>
  A-Record via Hetzner-Cloud-DNS-API an (deckt firma.reseller.portal ab,
  was der globale *.<portal>-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 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 18:14:41 +02:00
parent c542b2f9be
commit 8daef8e98f
6 changed files with 207 additions and 2 deletions

View File

@ -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) ###

View File

@ -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,
) {
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\EventListener;
use App\Entity\Reseller;
use App\Service\DnsProvisioner;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
/**
* Hält den Firmen-Subdomain-Wildcard (*.<slug>.<portal>) 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());
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Legt pro Reseller einen Wildcard-DNS-Eintrag *.<slug>.<portal> an, damit die
* Firmen-Subdomains (firma.reseller.portal, zwei Ebenen tief) auflösen der
* globale *.<portal>-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<string, mixed>|null $payload
*
* @return array{0:int,1:array<string,mixed>}
*/
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 : []];
}
}

View File

@ -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}

View File

@ -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 {