vcard4reseller/backend/src/Service/DnsProvisioner.php
Thomas Peterson 8daef8e98f 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>
2026-06-09 18:14:41 +02:00

161 lines
4.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 : []];
}
}