154 lines
6.7 KiB
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();
|
|
}
|
|
}
|