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.'); });