From ba4996f316a059bc59e7a21afbb89cb742be75a8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 21:04:08 +0200 Subject: [PATCH] Keep the platform on .com and the customers on .cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner caught me putting the archive on clupilot.cloud. That is the customer zone, and looking into it turned up the same confusion already sitting in the code — in the one place that matters most. RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The validation comment in Datacenters says it. ServicesTest writes it out verbatim. The step itself built the name from config('provisioning.dns.zone') — the CUSTOMER zone — so on this installation a host was actually called fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing another. OfficialDomains explains why that matters and is worth not weakening: two registrable domains by design, the company's for site, portal and console, the instance zone for customer workloads. A Nextcloud is third-party software that strangers sign into, and on the same registrable domain as the portal it shares cookie scope with it. A host name in that zone does not break the separation, but it puts it in question, and the next slip is more expensive. So there is now a platform_zone, derived from APP_URL when unset, and RegisterHostDns uses it. My own archiveUrl was broken for a second reason. It fell back to url() when APP_HOST is empty — and APP_HOST is empty on most installations, because empty means "the portal answers on any hostname" and that is the default. Called from the console, url() would have produced the CONSOLE hostname, and the line would have 404'd on a machine that is not allowed to reach the admin area at all. It takes the host from APP_URL now. The script no longer guesses its own name. It used reverse DNS, then the tunnel address, then a hard-coded clupilot.net — a third domain that appears nowhere else in this project and was simply invented. PrepareBaseSystem has the same invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in /etc/hosts, in every log line and in every certificate request the machine ever makes. CluPilot knows the name because it just assigned it, so it passes --fqdn and the script refuses without it. The value now also survives the reboot in the arguments file, which it would not have. The ACME contact moved to .com for the same reason it was wrong: the operator does not live in the customer zone. Open, and NOT decided here: the owner also wants the host to get a public DNS record and a certificate on the .com name. RegisterHostDns deliberately writes host names only into the tunnel's dnsmasq, and says why — publishing them hands every scanner the internal subnet and roughly how many hosts sit behind it. Nothing in the current design needs a public certificate for a host's own name; Traefik serves customer domains, not this one. Reversing that is a security decision and belongs to the owner, not to this commit. 1992 tests pass. Co-Authored-By: Claude Opus 5 --- .../Steps/Host/RegisterHostDns.php | 12 ++++- app/Support/HostTakeoverCommand.php | 40 +++++++++++++-- config/provisioning.php | 27 ++++++++++ deploy/bootstrap/clupilot-bootstrap.sh | 51 +++++++++++-------- deploy/bootstrap/lib/proxmox.sh | 2 + ...-07-30-host-uebernahme-bootstrap-skript.md | 2 +- tests/Feature/Admin/HostTakeoverGuideTest.php | 7 +-- tests/Feature/Admin/HostTakeoverPageTest.php | 34 +++++++++++-- 8 files changed, 142 insertions(+), 33 deletions(-) diff --git a/app/Provisioning/Steps/Host/RegisterHostDns.php b/app/Provisioning/Steps/Host/RegisterHostDns.php index 1b844c2..bb61403 100644 --- a/app/Provisioning/Steps/Host/RegisterHostDns.php +++ b/app/Provisioning/Steps/Host/RegisterHostDns.php @@ -45,7 +45,17 @@ class RegisterHostDns extends HostStep // lock with only a candidate in hand let two concurrent onboardings pick // the same name and overwrite each other's record. $name = $host->dns_name ?: $this->reserveName($host); - $fqdn = $name.'.node.'.config('provisioning.dns.zone', 'clupilot.com'); + // Die PLATTFORM-Zone, nicht die Kundenzone. Diese Zeile las bis hierher + // `provisioning.dns.zone` — die Zone der Kundeninstanzen —, und auf + // dieser Installation hieß ein Host damit `fsn-01.node.clupilot.cloud`. + // Der Kopfkommentar oben, die Prüfregel in Datacenters und der Test in + // ServicesTest sagen alle drei `.com`; nur diese Zeile sagte etwas + // anderes. Die zwei Zonen sind laut OfficialDomains ausdrücklich + // getrennt — eine Nextcloud ist fremde Software, bei der sich Fremde + // anmelden, und sie teilt sich deshalb keinen Cookie-Geltungsbereich mit + // dem Portal. Ein Hostname in dieser Zone hebt die Trennung nicht auf, + // aber er stellt sie in Frage, und der nächste Griff daneben ist teurer. + $fqdn = $name.'.node.'.config('provisioning.dns.platform_zone'); try { $this->dns->write($name, $fqdn, $host->wg_ip); diff --git a/app/Support/HostTakeoverCommand.php b/app/Support/HostTakeoverCommand.php index 6272ac8..153661b 100644 --- a/app/Support/HostTakeoverCommand.php +++ b/app/Support/HostTakeoverCommand.php @@ -41,6 +41,7 @@ final class HostTakeoverCommand "--hub-pubkey '".config('provisioning.wireguard.hub_public_key')."'", '--hub-endpoint '.config('provisioning.wireguard.endpoint'), '--api http://'.config('provisioning.wireguard.hub_address'), + '--fqdn '.self::fqdnFor($host), ]); } @@ -55,13 +56,44 @@ final class HostTakeoverCommand */ public static function archiveUrl(): string { - $appHost = (string) config('admin_access.app_host'); + $host = (string) config('admin_access.app_host'); - if ($appHost === '') { - return url('/bootstrap.tar.gz'); + // APP_HOST ist auf den meisten Installationen NICHT gesetzt — leer heißt + // dort „das Portal antwortet auf jedem Hostnamen", und das ist die + // Vorgabe. Der Rückfall auf `url()` war deshalb falsch: aufgerufen wird + // diese Methode aus der Konsole, also hätte `url()` den KONSOLEN-Namen + // geliefert, und die Zeile liefe auf einer Maschine, die den + // Adminbereich nicht erreichen darf, in eine 404. + // + // Also aus APP_URL, denn das ist die Adresse der Plattform und auf jeder + // Installation gesetzt. + if ($host === '') { + $host = (string) (parse_url((string) config('app.url'), PHP_URL_HOST) ?: ''); } - return 'https://'.$appHost.'/bootstrap.tar.gz'; + return $host === '' + ? url('/bootstrap.tar.gz') + : 'https://'.$host.'/bootstrap.tar.gz'; + } + + /** + * Der Name, den CluPilot für diesen Host führt: `-.node.`. + * + * Mitgegeben statt vom Skript geraten. Die erste Fassung leitete ihn auf der + * Maschine aus der Rückwärtsauflösung ab und fiel notfalls auf einen fest + * verdrahteten Namen zurück — der Installer verlangt einen FQDN, und ein + * geratener steht danach in `/etc/hosts`, im Zertifikatsantrag und in jeder + * Protokollzeile des Hosts. + * + * Die PLATTFORM-Zone, nicht die Kundenzone: `clupilot.com`, nicht + * `clupilot.cloud`. Die zwei sind laut OfficialDomains getrennt, und ein + * Host gehört auf die Seite der Plattform. + */ + public static function fqdnFor(Host $host): string + { + $name = $host->dns_name ?: $host->name; + + return $name.'.node.'.config('provisioning.dns.platform_zone'); } /** Die Präfixlänge des Tunnel-Subnetzes, damit `--wg-ip` vollständig ist. */ diff --git a/config/provisioning.php b/config/provisioning.php index 76a8350..77d450c 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -354,6 +354,33 @@ return [ // googleusercontent.com or vercel.app. 'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'), + /* + | Die Zone der PLATTFORM — Website, Portal, Konsole und die Namen der + | Hosts selbst. Nicht die Kundenzone darüber. + | + | Sie musste eigens entstehen, weil genau diese Trennung im Code schon + | überall behauptet und an einer Stelle gebrochen wurde: der Kopfkommentar + | von RegisterHostDns nennt `fsn-01.node.clupilot.com`, die Prüfregel in + | Datacenters nennt denselben Namen, und der Test in ServicesTest schreibt + | ihn wörtlich — der Schritt selbst baute den Namen aber aus `zone` oben, + | und die steht auf der KUNDENZONE. Auf dieser Installation hieß ein Host + | damit `fsn-01.node.clupilot.cloud`: in der Zone, die absichtlich von der + | Plattform getrennt ist, mit derselben Begründung, die in + | OfficialDomains steht. + | + | Ohne Angabe aus APP_URL abgeleitet, letzte zwei Labels. Das trifft + | `app.dev.clupilot.com` → `clupilot.com` und liegt bei mehrteiligen + | Endungen wie `co.uk` daneben — dort wird CLUPILOT_PLATFORM_ZONE gesetzt. + */ + 'platform_zone' => env('CLUPILOT_PLATFORM_ZONE') ?: (function () { + $host = parse_url((string) env('APP_URL', ''), PHP_URL_HOST) ?: ''; + $labels = array_filter(explode('.', $host)); + + return count($labels) >= 2 + ? implode('.', array_slice($labels, -2)) + : 'clupilot.com'; + })(), + // Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns // container's dnsmasq --hostsdir. NOT the public Hetzner zone above: // a host's tunnel address must never be resolvable outside the tunnel, diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 2f4f5ee..d91f543 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -28,7 +28,7 @@ CLUPILOT_ROOT_SSH_KEY='' # 1, wenn der `[first-boot]`-Hook uns nach dem Neustart wieder aufgenommen hat. CLUPILOT_RESUMING=0 -# Wird in `derive_fqdn` gesetzt; der Installer verlangt einen. +# Kommt aus `--fqdn`. CluPilot vergibt den Namen; diese Maschine rät ihn nicht. CLUPILOT_FQDN='' # Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas @@ -88,7 +88,11 @@ CLUPILOT_INSTALL_TIMEOUT="${CLUPILOT_INSTALL_TIMEOUT:-3600}" # Kontaktadresse für Let's Encrypt. Sie taucht in keinem Zertifikat auf, aber # an sie gehen die Ablaufwarnungen — eine Adresse, die niemand liest, ist die # Vorstufe zu abgelaufenen Kundenzertifikaten. -CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.cloud}" +# +# Auf der PLATTFORM-Domain, nicht auf der Kundenzone: `.cloud` gehört den +# Instanzen der Kunden, der Betreiber sitzt auf `.com`. Hier stand zuerst +# `ssl@clupilot.cloud` — dieselbe Verwechslung, die auch RegisterHostDns hatte. +CLUPILOT_ACME_EMAIL="${CLUPILOT_ACME_EMAIL:-ssl@clupilot.com}" usage() { cat <<'TEXT' @@ -102,6 +106,8 @@ fertigen Proxmox-Host. --hub-endpoint Adresse des Hubs --api CluPilot über die TUNNEL-Adresse, ohne Schrägstrich am Ende, z. B. http://10.66.0.1 + --fqdn der Name, den CluPilot für diesen Host führt, + z. B. fsn-01.node.clupilot.com Freiwillig: @@ -139,6 +145,7 @@ parse_arguments() { --hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;; --hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;; --api) CLUPILOT_API="${2:-}"; shift 2 ;; + --fqdn) CLUPILOT_FQDN="${2:-}"; shift 2 ;; --root-ssh-key) CLUPILOT_ROOT_SSH_KEY="${2:-}"; shift 2 ;; --resume) CLUPILOT_RESUMING=1; shift ;; -h|--help) usage; exit 0 ;; @@ -165,6 +172,7 @@ parse_arguments() { [ -n "$CLUPILOT_HUB_PUBKEY" ] || die '--hub-pubkey fehlt' [ -n "$CLUPILOT_HUB_ENDPOINT" ] || die '--hub-endpoint fehlt' [ -n "$CLUPILOT_API" ] || die '--api fehlt' + [ -n "$CLUPILOT_FQDN" ] || die '--fqdn fehlt' # Ein Schrägstrich am Ende ergäbe `…//host/progress`. Manche Router # beantworten das, manche nicht — und der Unterschied fiele erst beim ersten @@ -214,27 +222,28 @@ running_in_rescue_system() { return 1 } -# Der Installer verlangt einen FQDN, und die Befehlszeile bringt keinen mit. +# Der Installer verlangt einen FQDN. Er kommt aus der Befehlszeile. # -# Erst die Rückwärtsauflösung der öffentlichen Adresse — das ist der Name, den -# der Anbieter für diese Maschine führt. Sonst ein Name aus der Tunneladresse, -# der eindeutig ist und niemanden in die Irre führt. In Task 9 ersetzt CluPilot -# ihn ohnehin durch den, den es selbst führt. -derive_fqdn() { - _ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')" - - if [ -n "$_ip" ] && command -v getent >/dev/null 2>&1; then - _name="$(getent hosts "$_ip" 2>/dev/null | awk '{ print $2; exit }')" - case "$_name" in - *.*.*) - CLUPILOT_FQDN="$_name" - return 0 - ;; - esac +# CluPilot kennt den Namen dieses Hosts — es hat ihn gerade vergeben —, also +# gibt CluPilot ihn mit. Die erste Fassung leitete ihn hier ab: erst aus der +# Rückwärtsauflösung, sonst aus der Tunneladresse, notfalls gegen eine fest +# verdrahtete Domain. Alle drei waren falsch. +# +# Ein geratener Name bleibt nämlich stehen. Er landet in `/etc/hostname`, in +# `/etc/hosts`, in jeder Protokollzeile dieser Maschine und in jedem +# Zertifikatsantrag, den sie später stellt. Und die fest verdrahtete Domain war +# `clupilot.net` — eine, die in diesem Projekt nirgends sonst vorkommt: die +# Plattform liegt auf `.com`, die Kundeninstanzen auf `.cloud`, und `.net` war +# schlicht erfunden. `PrepareBaseSystem.php` macht denselben Fehler. +# +# Der Rückfall bleibt, aber er rät nicht mehr über die Domain: ohne `--fqdn` +# wird abgebrochen. +require_fqdn() { + if [ -n "$CLUPILOT_FQDN" ]; then + return 0 fi - _suffix="$(printf '%s' "$CLUPILOT_WG_IP" | sed -e 's#/.*##' -e 's/\./-/g')" - CLUPILOT_FQDN="host-${_suffix}.clupilot.net" + die '--fqdn fehlt. Der Name dieses Hosts wird von CluPilot vergeben und nicht auf der Maschine erraten — ein geratener Name bleibt in /etc/hosts und in jedem späteren Zertifikatsantrag stehen.' } # --------------------------------------------------------------------------- @@ -461,7 +470,7 @@ section_debian_installed() { _raid="$(zfs_raid_for "$_count")" _boot_mode="$(detect_boot_mode)" - derive_fqdn + require_fqdn log "Ziel: ${CLUPILOT_FQDN}, ${_boot_mode}, ZFS ${_raid} über ${_count} Platte(n): ${_disks}" if ! ensure_autoinstall_assistant; then diff --git a/deploy/bootstrap/lib/proxmox.sh b/deploy/bootstrap/lib/proxmox.sh index 05ab203..d9680bb 100644 --- a/deploy/bootstrap/lib/proxmox.sh +++ b/deploy/bootstrap/lib/proxmox.sh @@ -359,6 +359,8 @@ CLUPILOT_WG_IP='${CLUPILOT_WG_IP}' CLUPILOT_HUB_PUBKEY='${CLUPILOT_HUB_PUBKEY}' CLUPILOT_HUB_ENDPOINT='${CLUPILOT_HUB_ENDPOINT}' CLUPILOT_API='${CLUPILOT_API}' +CLUPILOT_FQDN='${CLUPILOT_FQDN}' +CLUPILOT_ROOT_SSH_KEY='${CLUPILOT_ROOT_SSH_KEY}' EOF chmod 600 "$_args_file" diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md index e0c0343..50b1f32 100644 --- a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md @@ -66,7 +66,7 @@ aber entschieden): als **Archiv**. Die kopierte Zeile aus dem Adminbereich holt dort — ungefähr so: ```sh -curl -fsSL https://clupilot.cloud/bootstrap.tar.gz | tar xz -C /opt/clupilot \ +curl -fsSL https://app.clupilot.com/bootstrap.tar.gz | tar xz -C /opt/clupilot \ && /opt/clupilot/bootstrap/clupilot-bootstrap.sh --code … --wg-private … … ``` diff --git a/tests/Feature/Admin/HostTakeoverGuideTest.php b/tests/Feature/Admin/HostTakeoverGuideTest.php index ff1ba60..3524cca 100644 --- a/tests/Feature/Admin/HostTakeoverGuideTest.php +++ b/tests/Feature/Admin/HostTakeoverGuideTest.php @@ -7,7 +7,8 @@ use Livewire\Livewire; beforeEach(function () { fakeServices(); - config()->set('admin_access.app_host', 'clupilot.cloud'); + config()->set('admin_access.app_host', 'app.clupilot.com'); + config()->set('provisioning.dns.platform_zone', 'clupilot.com'); }); function guidePage(string $component): \Livewire\Features\SupportTesting\Testable @@ -60,8 +61,8 @@ it('marks the two provider steps as things to do first', function () { * und der Unterschied fiele auf einem Server auf, der schon bestellt ist. */ it('names the same archive address the command will use', function () { - guidePage(HostCreate::class)->assertSee('clupilot.cloud/bootstrap.tar.gz'); - guidePage(Hosts::class)->assertSee('clupilot.cloud/bootstrap.tar.gz'); + guidePage(HostCreate::class)->assertSee('app.clupilot.com/bootstrap.tar.gz'); + guidePage(Hosts::class)->assertSee('app.clupilot.com/bootstrap.tar.gz'); }); /** diff --git a/tests/Feature/Admin/HostTakeoverPageTest.php b/tests/Feature/Admin/HostTakeoverPageTest.php index 085565c..74ab282 100644 --- a/tests/Feature/Admin/HostTakeoverPageTest.php +++ b/tests/Feature/Admin/HostTakeoverPageTest.php @@ -14,7 +14,8 @@ beforeEach(function () { config()->set('provisioning.wireguard.endpoint', 'hub.clupilot.cloud:51820'); config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); - config()->set('admin_access.app_host', 'clupilot.cloud'); + config()->set('admin_access.app_host', 'app.clupilot.com'); + config()->set('provisioning.dns.platform_zone', 'clupilot.com'); // firstOrCreate: die Migrationen legen Rechenzentren schon an, und ein // zweites `fsn` verletzt den eindeutigen Index. @@ -61,7 +62,7 @@ it('shows the command exactly once, right after creating the host', function () it('carries every value the script needs before the tunnel', function () { $command = createHostAs()->get('command'); - foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api'] as $argument) { + foreach (['--code', '--wg-private', '--wg-ip', '--hub-pubkey', '--hub-endpoint', '--api', '--fqdn'] as $argument) { expect($command)->toContain($argument); } @@ -69,6 +70,33 @@ it('carries every value the script needs before the tunnel', function () { ->toContain('hub.clupilot.cloud:51820') ->toContain('--api http://10.66.0.1') ->toContain('/opt/clupilot'); + + // Der Name des Hosts liegt auf der PLATTFORM-Zone. `.cloud` gehört den + // Kundeninstanzen und ist laut OfficialDomains bewusst eine andere + // registrierbare Domain — geprüft wird deshalb der --fqdn-Wert selbst und + // nicht die ganze Zeile: der Hub-Endpunkt ist eine eigene Einstellung und + // darf heißen, wie er will. + preg_match('/--fqdn (\S+)/', $command, $fqdn); + expect($fqdn[1] ?? '')->toEndWith('.node.clupilot.com'); +}); + +/** + * Dieselbe Trennung an der Stelle, an der sie schon einmal gebrochen war. + * + * `RegisterHostDns` nennt im eigenen Kopfkommentar `fsn-01.node.clupilot.com`, + * die Prüfregel in `Datacenters` nennt denselben Namen, und `ServicesTest` + * schreibt ihn wörtlich — der Schritt baute den Namen aber aus + * `provisioning.dns.zone`, und das ist die KUNDENZONE. Auf dieser Installation + * hieß ein Host damit `fsn-01.node.clupilot.cloud`. + */ +it('keeps host names out of the customer zone', function () { + config()->set('provisioning.dns.zone', 'clupilot.cloud'); + config()->set('provisioning.dns.platform_zone', 'clupilot.com'); + + $host = Host::factory()->create(['dns_name' => 'fsn-01']); + + expect(App\Support\HostTakeoverCommand::fqdnFor($host)) + ->toBe('fsn-01.node.clupilot.com'); }); /** @@ -80,7 +108,7 @@ it('carries every value the script needs before the tunnel', function () { */ it('fetches the installer over the public hostname', function () { expect(createHostAs()->get('command')) - ->toContain('https://clupilot.cloud/bootstrap.tar.gz') + ->toContain('https://app.clupilot.com/bootstrap.tar.gz') ->not->toContain('admin.'); });