- 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>
161 lines
4.9 KiB
PHP
161 lines
4.9 KiB
PHP
<?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 : []];
|
||
}
|
||
}
|