Der Installer schreibt die Hostnamen, nach denen er fragt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 15:00:42 +02:00
parent 4200056697
commit 0d7950c464
3 changed files with 112 additions and 19 deletions

View File

@ -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

View File

@ -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:-}"

View File

@ -0,0 +1,65 @@
<?php
use Symfony\Component\Process\Process;
/**
* Der Installer fragte nach den Hostnamen und schrieb sie nicht.
*
* Und er rief `optional_env` dreizehn Zeilen vor ihrer Definition auf. In bash
* ist eine Funktion erst ab ihrer Definition bekannt; unter `set -euo pipefail`
* ist der Aufruf davor ein Exit 127, der die Installation beendet. Das ist der
* Fehler, der hier festgehalten wird nicht die Schreibweise.
*/
function installerSource(): string
{
return file_get_contents(base_path('deploy/install.sh'));
}
it('ist syntaktisch gültiges bash', function () {
$process = new Process(['bash', '-n', base_path('deploy/install.sh')]);
$process->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.",
);
}
});