CluPilotCloud/app/Provisioning/Steps/Customer/ConfigureDnsAndTls.php

154 lines
6.7 KiB
PHP

<?php
namespace App\Provisioning\Steps\Customer;
use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
use App\Support\ProvisioningSettings;
class ConfigureDnsAndTls extends CustomerStep
{
/**
* How long the step keeps looking for the CUSTOM domain's certificate
* before it moves on without it.
*
* Long enough that the ordinary case lands inside the run: the customer has
* already pointed their A record at us, Traefik sees the first request to
* the new hostname and finishes HTTP-01 in seconds. Short enough that it is
* never a wait — the domain may equally be pointed at us tomorrow, or
* never, and the run has no business hanging on that.
*/
private const CUSTOM_DOMAIN_GRACE = 120;
public function __construct(
private HetznerDnsClient $dns,
private TraefikWriter $traefik,
) {}
public function key(): string
{
return 'configure_dns_and_tls';
}
public function maxDuration(): int
{
// Above the 840s cert own-deadline below, so the step's own fail fires first.
return 960;
}
public function execute(ProvisioningRun $run): StepResult
{
$instance = $this->instance($run);
$host = $instance->host;
$fqdn = $instance->subdomain.'.'.ProvisioningSettings::dnsZone();
// The customer's own domain is served ONLY once it is proven to be
// theirs. Everything downstream reads this one flag, so an unverified
// hostname sitting in the column reaches neither the router nor a
// certificate — see Instance::domainIsVerified().
$customDomain = $instance->domainIsVerified() ? (string) $instance->custom_domain : null;
// The platform address first: it is ours, it always works, and it is
// what the instance falls back to the moment a custom domain goes away.
$hostnames = array_values(array_filter([$fqdn, $customDomain]));
// DNS — provider upsert is idempotent; the local row uses firstOrCreate on
// the record id and is written BEFORE the breadcrumb (the short-circuit guard).
// Only OUR zone: the custom domain's A record lives in the customer's
// zone, which we have no access to and never will.
if (! $this->hasResource($run, 'dns_record_id')) {
$recordId = $this->dns->upsertRecord($fqdn, 'A', $host->public_ip);
$instance->dnsRecords()->firstOrCreate(
['record_id' => $recordId],
['provider' => 'hetzner', 'fqdn' => $fqdn, 'type' => 'A', 'value' => $host->public_ip],
);
$this->recordResource($run, $host, 'dns_record_id', $recordId);
}
// Traefik file-provider route, written on the serving host (DNS points at
// it) and pointing at the guest VM.
//
// Guarded on WHAT the router carries, not merely on whether one was ever
// written. `route_written` alone would short-circuit exactly the case
// this step is re-run for — an address that has changed — and the
// customer's domain would be announced but never routed, or stay routed
// after it was withdrawn. Comparing the hostname list keeps the retry
// cheap (a second attempt at the same address writes nothing) without
// making a re-apply a no-op.
if (! $instance->route_written || $instance->routed_hostnames !== $hostnames) {
$trafficHost = $host->wg_ip ?? $host->public_ip;
$backend = $instance->guest_ip ?: $host->public_ip;
$this->traefik->write($trafficHost, $instance->subdomain, $hostnames, $backend);
$instance->update(['route_written' => true, 'routed_hostnames' => $hostnames]);
}
// TLS via HTTP-01 — poll until the certificate is served.
if (! $instance->cert_ok) {
if ($this->traefik->certReachable($fqdn)) {
$instance->update(['cert_ok' => true]);
} elseif ($run->started_at !== null && $run->started_at->copy()->addSeconds(840)->isPast()) {
return StepResult::fail('cert_timeout');
} else {
return StepResult::poll(20, 'waiting for certificate');
}
}
return $this->settleCustomDomainCertificate($run, $instance, $customDomain);
}
/**
* The custom domain's certificate: recorded, never required.
*
* The platform address above is ours — no certificate there means something
* is broken on our side and the run fails so somebody looks. This one is the
* opposite: it can only be issued once the customer has pointed their own A
* record at us, and whether they have is not ours to decide, not ours to
* check (behind a CDN the address is invisible from outside) and possibly
* never going to happen. Failing a run over it would mean a customer who
* typed a domain and went to lunch could not get their cloud built.
*
* So the outcome is written to `domain_cert_ok` and the run advances either
* way. The portal reads that flag to tell "proven" apart from "answering",
* because it is the portal that prints this address as the customer's.
*/
private function settleCustomDomainCertificate(ProvisioningRun $run, Instance $instance, ?string $customDomain): StepResult
{
if ($customDomain === null) {
// No verified domain: whatever was true before is not true now.
if ($instance->domain_cert_ok) {
$instance->update(['domain_cert_ok' => false]);
}
return StepResult::advance();
}
if ($this->traefik->certReachable($customDomain)) {
$instance->update(['domain_cert_ok' => true]);
return StepResult::advance();
}
// A short look, not a wait — see CUSTOM_DOMAIN_GRACE.
if ($run->started_at !== null && ! $run->started_at->copy()->addSeconds(self::CUSTOM_DOMAIN_GRACE)->isPast()) {
return StepResult::poll(15, 'waiting for the custom domain certificate');
}
$instance->update(['domain_cert_ok' => false]);
// Said out loud rather than swallowed: from here the domain is proven,
// routed, and answering nothing, and that is a state an operator
// reading this run should be able to see without going looking.
$run->events()->create([
'step' => $this->key(),
'attempt' => $run->attempt,
'outcome' => 'info',
'message' => "Eigene Domain {$customDomain}: noch kein Zertifikat — zeigt der A-Eintrag des Kunden schon auf uns?",
]);
return StepResult::advance();
}
}