From 0d7950c464e2f369df8393e73144f195d35f376d Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:00:42 +0200 Subject: [PATCH] Der Installer schreibt die Hostnamen, nach denen er fragt Co-Authored-By: Claude Opus 5 --- .env.example | 28 +++++++--- deploy/install.sh | 38 +++++++++---- tests/Feature/InstallerWritesHostsTest.php | 65 ++++++++++++++++++++++ 3 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 tests/Feature/InstallerWritesHostsTest.php diff --git a/.env.example b/.env.example index 27cb0b5..cfa8ddc 100644 --- a/.env.example +++ b/.env.example @@ -218,26 +218,38 @@ CLUPILOT_TAX_PERCENT=20 # APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist # JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst — # www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf. +# Das schliesst die Anmeldung ein: Fortify bindet seine eigenen Routen ueber +# denselben Wert (config/fortify.php). # Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber # eine blanke IP braucht). -# Hostname fuer Dateien zum Herunterladen: AGB, AV, TOM (oeffentlich) und das -# Bootstrap-Archiv (nur mit gueltigem Einmal-Code). Leer = das Archiv bleibt -# auf dem Portal-Hostnamen. -FILES_HOST= - APP_HOST= # SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die # Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt # unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall, +# auch auf dem Portal-Hostnamen. # Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus, # alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten): # SITE_HOST=www.clupilot.com,clupilot.com -# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite, -# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum') -# auch aus einer Mail heraus eine www-Adresse. +# Gebunden sind Startseite, robots.txt, Kontakt und die Legal-Seiten — dadurch +# erzeugt route('legal.impressum') auch aus einer Mail heraus eine www-Adresse, +# wo es keine Anfrage gibt, aus der man einen Hostnamen nehmen koennte. SITE_HOST= +# STATUS_HOST: Hostname der Statusseite, z. B. status.clupilot.com. Gesetzt, +# liegt sie an der WURZEL dieses Namens, und /status leitet von jedem anderen +# Host dorthin um statt zu 404en — eine Statusseite ist die Adresse, die in +# einem Lesezeichen steht, wenn ohnehin schon etwas kaputt ist. +# Leer = /status antwortet auf jedem Host. +STATUS_HOST= + +# FILES_HOST: Hostname fuer Dateien zum Herunterladen — AGB, AV, TOM +# (oeffentlich, dauerhaft zitierbar, weil diese Adressen in Vertraegen stehen) +# und das Bootstrap-Archiv (nur mit gueltigem Einmal-Code). +# Leer = das Archiv bleibt auf dem Portal-Hostnamen, damit keine bereits +# ausgegebene Befehlszeile bricht, solange der DNS-Eintrag noch nicht steht. +FILES_HOST= + ADMIN_HOSTS=admin.dev.clupilot.com ADMIN_HOST_EXCLUSIVE=true diff --git a/deploy/install.sh b/deploy/install.sh index a6a9762..b6f7972 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -116,6 +116,7 @@ ask WS_DOMAIN "Domain for the websocket server" "ws.clupilot.com" # its own name. Naming the portal here was wrong in the printed instructions. ask API_DOMAIN "Domain Stripe posts its webhooks to" "api.clupilot.com" ask STATUS_DOMAIN "Public status page (blank to keep it on every host)" "status.clupilot.com" +ask FILES_DOMAIN "Domain for downloads: terms, DPA, bootstrap archive (blank to keep them on the portal)" "files.clupilot.com" ask ADMIN_EMAIL "Your login address" ask ADMIN_NAME "Your display name" "Administrator" ask_secret ADMIN_PASSWORD "Your password (min. 12 characters)" @@ -266,6 +267,23 @@ else fi } + # An `if`, not `[[ … ]] && …`: the && form makes the function return 1 for + # every value that is absent, and under `set -e` that ends the install. + # Absent is the NORMAL case here — these are the optional ones — so the + # script killed itself on the first installation that did not happen to + # supply a Hetzner token. + # + # Und die Definition steht VOR dem ersten Aufruf. Sie stand einmal dreizehn + # Zeilen dahinter: in bash ist eine Funktion erst ab ihrer Definition + # bekannt, der Aufruf davor ist ein Exit 127, und `set -euo pipefail` macht + # daraus das Ende der Installation. Jede Neuinstallation starb an + # `optional_env STATUS_HOST`. + optional_env() { + if [[ -n "${2:-}" ]]; then + set_env "$1" "$2" + fi + } + set_env APP_ENV production set_env APP_DEBUG false set_env APP_URL "https://${APP_DOMAIN}" @@ -292,7 +310,16 @@ else # unreachable under any other name. set_env ADMIN_HOST_EXCLUSIVE false set_env TRUSTED_RANGES "10.66.0.0/24,127.0.0.1" + # Die Antworten von oben landen HIER, und das ist der ganze Unterschied + # zwischen einer getrennten Installation und einer, auf der Website und + # Portal einander bedienen. Ohne diese vier Zeilen laufen beide + # Route::domain()-Gruppen host-unabhängig und antworten überall — der + # Betreiber erreicht dann die Website unter app. und die Anmeldung unter + # www., und nichts an der Konfiguration deutet darauf hin, warum. + set_env APP_HOST "$APP_DOMAIN" + set_env SITE_HOST "$WWW_DOMAIN" optional_env STATUS_HOST "$STATUS_DOMAIN" + optional_env FILES_HOST "$FILES_DOMAIN" set_env CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820" @@ -300,17 +327,6 @@ else # inside the container belong to it and not to root. # Supplied through --env-file; skipped silently when absent, so a first # install can happen before the Stripe account exists. - # An `if`, not `[[ … ]] && …`: the && form makes the function return 1 for - # every value that is absent, and under `set -e` that ends the install. - # Absent is the NORMAL case here — these are the optional ones — so the - # script killed itself on the first installation that did not happen to - # supply a Hetzner token. - optional_env() { - if [[ -n "${2:-}" ]]; then - set_env "$1" "$2" - fi - } - optional_env HETZNER_DNS_TOKEN "${HETZNER_DNS_TOKEN:-}" optional_env CLUPILOT_DNS_ZONE "${CLUPILOT_DNS_ZONE:-}" optional_env STRIPE_KEY "${STRIPE_KEY:-}" diff --git a/tests/Feature/InstallerWritesHostsTest.php b/tests/Feature/InstallerWritesHostsTest.php new file mode 100644 index 0000000..694c02a --- /dev/null +++ b/tests/Feature/InstallerWritesHostsTest.php @@ -0,0 +1,65 @@ +run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()); +}); + +it('definiert jede Hilfsfunktion, bevor sie gebraucht wird', function () { + $source = installerSource(); + + foreach (['set_env', 'optional_env'] as $function) { + $definedAt = strpos($source, " {$function}() {"); + expect($definedAt)->not->toBeFalse("{$function}() wird nicht mehr definiert."); + + // Der erste Aufruf ist der erste Treffer, der KEINE Definition ist. + $calledAt = preg_match( + '/^\s+'.preg_quote($function, '/').' [A-Z]/m', + $source, + $matches, + PREG_OFFSET_CAPTURE, + ) ? $matches[0][1] : false; + + expect($calledAt)->not->toBeFalse("{$function} wird nirgends aufgerufen."); + expect($definedAt)->toBeLessThan( + $calledAt, + "{$function}() wird vor seiner Definition aufgerufen — unter set -e ist das Exit 127.", + ); + } +}); + +it('schreibt jeden Hostnamen, nach dem es fragt', function () { + $source = installerSource(); + + // Gefragt wird nach allen fünf … + foreach (['APP_DOMAIN', 'WWW_DOMAIN', 'STATUS_DOMAIN', 'FILES_DOMAIN', 'ADMIN_DOMAIN'] as $asked) { + expect($source)->toMatch('/^ask\s+'.$asked.'\s/m', "Es wird nicht mehr nach {$asked} gefragt."); + } + + // … und jede Antwort landet auch in der .env. Genau das fehlte: die Fragen + // standen da, die Werte wurden nie geschrieben, und die Trennung war + // deshalb auf keiner Installation eingeschaltet. + foreach (['APP_HOST', 'SITE_HOST', 'STATUS_HOST', 'FILES_HOST'] as $written) { + expect($source)->toMatch( + '/^\s+(set_env|optional_env)\s+'.$written.'\s/m', + "{$written} wird nicht in die .env geschrieben.", + ); + } +});