vcard4reseller/backend/src/Controller/DomainController.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

197 lines
7.1 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Domain;
use App\Entity\Reseller;
use App\Security\TenantContext;
use App\Service\TenantResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Eigene Domains eines Tenants (KONZEPT §11): jeder Reseller/jede Firma kann
* neben der Standard-Subdomain eigene Domains hinterlegen. Aktivierung per
* CNAME/A auf die Plattform-Edge + DNS-Verifizierung; danach stellt Caddy
* On-Demand automatisch ein TLS-Zertifikat aus.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class DomainController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
private readonly TenantResolver $resolver,
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
#[Autowire('%env(APP_EDGE_IP)%')]
private readonly string $edgeIp,
) {
}
#[Route('/api/my-domains', name: 'my_domains_list', methods: ['GET'])]
public function list(): JsonResponse
{
[$scope, $entity] = $this->target();
$defaultHost = $entity instanceof Company
? $this->resolver->companyHost($entity)
: $this->resolver->resellerHost($entity);
return new JsonResponse([
'scope' => $scope,
'defaultHost' => $defaultHost,
'edgeTarget' => $this->portalDomain,
'edgeIp' => $this->edgeIp(),
'domains' => array_map([$this, 'serialize'], $this->ownDomains($entity)),
]);
}
#[Route('/api/my-domains', name: 'my_domains_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
[, $entity] = $this->target();
$data = json_decode($request->getContent(), true) ?? [];
$hostname = $this->normalizeHostname((string) ($data['hostname'] ?? ''));
if (null === $hostname) {
throw new BadRequestHttpException('Ungültiger Hostname.');
}
if (null !== $this->em->getRepository(Domain::class)->findOneBy(['hostname' => $hostname])) {
throw new BadRequestHttpException('Diese Domain ist bereits hinterlegt.');
}
$domain = (new Domain())
->setHostname($hostname)
->setType(Domain::TYPE_CUSTOM)
->setStatus(Domain::STATUS_PENDING);
if ($entity instanceof Company) {
$domain->setCompany($entity);
} else {
$domain->setReseller($entity);
}
$this->em->persist($domain);
$this->em->flush();
return new JsonResponse($this->serialize($domain), 201);
}
#[Route('/api/my-domains/{id}/verify', name: 'my_domains_verify', methods: ['POST'])]
public function verify(string $id): JsonResponse
{
$domain = $this->ownDomain($id);
$verified = $this->dnsPointsToUs($domain->getHostname());
$domain->setStatus($verified ? Domain::STATUS_VERIFIED : Domain::STATUS_FAILED);
$domain->setVerificationCheckedAt(new \DateTimeImmutable());
$this->em->flush();
return new JsonResponse([
'domain' => $this->serialize($domain),
'ok' => $verified,
'message' => $verified
? 'Domain verifiziert. Das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.'
: 'DNS zeigt noch nicht auf die Plattform. Bitte CNAME/A-Eintrag prüfen (Propagierung kann dauern).',
]);
}
#[Route('/api/my-domains/{id}', name: 'my_domains_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$domain = $this->ownDomain($id);
$this->em->remove($domain);
$this->em->flush();
return new JsonResponse(null, 204);
}
/** @return array{0:string,1:Company|Reseller} */
private function target(): array
{
$company = $this->tenant->getCompany();
if ($company instanceof Company) {
return ['company', $company];
}
$reseller = $this->tenant->getReseller();
if ($reseller instanceof Reseller) {
return ['reseller', $reseller];
}
throw new AccessDeniedHttpException('Kein Domain-Kontext.');
}
/** @return Domain[] */
private function ownDomains(Company|Reseller $entity): array
{
$criteria = $entity instanceof Company ? ['company' => $entity] : ['reseller' => $entity];
return $this->em->getRepository(Domain::class)->findBy($criteria, ['hostname' => 'ASC']);
}
private function ownDomain(string $id): Domain
{
$domain = $this->em->getRepository(Domain::class)->find(Uuid::fromString($id));
if (!$domain instanceof Domain) {
throw new NotFoundHttpException('Domain nicht gefunden.');
}
[, $entity] = $this->target();
$owns = $entity instanceof Company
? $domain->getCompany()?->getId()->equals($entity->getId())
: $domain->getReseller()?->getId()->equals($entity->getId()) && null === $domain->getCompany();
if (true !== $owns) {
throw new AccessDeniedHttpException('Domain gehört nicht zum eigenen Kontext.');
}
return $domain;
}
/** Prüft, ob der Hostname (per A oder via CNAME) auf die Plattform-Edge zeigt. */
private function dnsPointsToUs(string $hostname): bool
{
$hostIps = gethostbynamel($hostname);
if (false === $hostIps || [] === $hostIps) {
return false;
}
$ourIps = null !== $this->edgeIp() ? [$this->edgeIp()] : (gethostbynamel($this->portalDomain) ?: []);
return [] !== array_intersect($hostIps, $ourIps);
}
private function edgeIp(): ?string
{
$ip = trim((string) $this->edgeIp);
return '' !== $ip ? $ip : null;
}
private function normalizeHostname(string $host): ?string
{
$host = strtolower(trim($host));
$host = preg_replace('#^https?://#', '', $host) ?? $host;
$host = rtrim($host, '/.');
if (1 !== preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $host)) {
return null;
}
return $host;
}
/** @return array<string, mixed> */
private function serialize(Domain $domain): array
{
return [
'id' => (string) $domain->getId(),
'hostname' => $domain->getHostname(),
'type' => $domain->getType(),
'status' => $domain->getStatus(),
'tlsStatus' => $domain->getTlsStatus(),
'checkedAt' => $domain->getVerificationCheckedAt()?->format(\DateTimeInterface::ATOM),
];
}
}