- 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>
197 lines
7.1 KiB
PHP
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),
|
|
];
|
|
}
|
|
}
|