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:
parent
c542b2f9be
commit
8daef8e98f
@ -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) ###
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
37
backend/src/EventListener/ResellerDnsListener.php
Normal file
37
backend/src/EventListener/ResellerDnsListener.php
Normal 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());
|
||||
}
|
||||
}
|
||||
160
backend/src/Service/DnsProvisioner.php
Normal file
160
backend/src/Service/DnsProvisioner.php
Normal 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 : []];
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user