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 what was written keeps the retry // cheap (a second attempt at the same address writes nothing) without // making a re-apply a no-op. // // BOTH halves of the file are compared, because a router names a backend // as well as a set of hostnames, and the backend is a DHCP address. The // hostname list on its own said "nothing has changed" for a machine that // had moved: cloud-init gives every guest `ip=dhcp`, a lease expiry or a // cold boot can put it somewhere else, and the file kept pointing at where // it used to be — a 502 on the customer's own cloud that nothing would // ever rewrite. ConfigureNetwork re-reads that address on a restart; this // is what makes the router follow it. $trafficHost = $host->wg_ip ?? $host->public_ip; $backend = $instance->guest_ip ?: $host->public_ip; if (! $instance->route_written || $instance->routed_hostnames !== $hostnames || $instance->routed_backend !== $backend) { $this->traefik->write($trafficHost, $instance->subdomain, $hostnames, $backend); $instance->update([ 'route_written' => true, 'routed_hostnames' => $hostnames, 'routed_backend' => $backend, ]); } // 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(); } }