Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details

STATUS_HOST binds /status to a single hostname. Every other host redirects
there rather than 404ing: a status page is the one address people keep in a
bookmark and reach for when something is already wrong, so breaking old links
to prove a point about separation would be exactly backwards. Unset, nothing
changes.

The /legal/status redirect goes through route() instead of a literal path. A
relative redirect lands on whatever host the visitor is already on — which,
once the page has a hostname of its own, is precisely where it no longer
answers.

The installer told the operator to point Stripe's webhook at the customer
portal. Stripe posts server-to-server and never sees the portal; the endpoint
is the api hostname, which the installer never even asked for. It asks now, and
prints the address that actually receives the events. Getting this wrong does
not fail loudly — payments simply stop being recorded.

It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the
console onto its own hostname before the DNS for it exists makes the console
unreachable under any other name, and that is not a thing to have on by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design tested-20260727-0625-6082164
Claude 2026-07-27 08:08:57 +02:00
parent 935c9ae6ac
commit 6082164f8b
3 changed files with 36 additions and 4 deletions

View File

@ -33,6 +33,16 @@ return [
*/
'exclusive' => (bool) env('ADMIN_HOST_EXCLUSIVE', false),
/*
| Hostname the public status page lives on, e.g. status.clupilot.com.
|
| Empty means it stays on every host, as it does today. Set, it becomes the
| ONLY place /status answers everywhere else redirects there, so a link
| that already exists keeps working instead of hitting the 404 that strict
| separation would otherwise produce.
*/
'status_host' => strtolower(trim((string) env('STATUS_HOST', ''))),
/*
| Key for VPN configs stored at rest (32 bytes, base64). Separate from
| APP_KEY on purpose see App\Services\Wireguard\ConfigVault. Empty means

View File

@ -112,6 +112,10 @@ ask APP_DOMAIN "Public domain for the customer portal" "app.clupilot.com"
ask WWW_DOMAIN "Domain for the marketing site" "www.clupilot.com"
ask ADMIN_DOMAIN "Private domain for the operator console" "admin.clupilot.com"
ask WS_DOMAIN "Domain for the websocket server" "ws.clupilot.com"
# Stripe posts server-to-server and never sees the portal, so the webhook has
# 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 ADMIN_EMAIL "Your login address"
ask ADMIN_NAME "Your display name" "Administrator"
ask_secret ADMIN_PASSWORD "Your password (min. 12 characters)"
@ -283,7 +287,12 @@ else
# The console answers only on its own hostname; everything else 404s.
set_env ADMIN_HOSTS "${ADMIN_DOMAIN},127.0.0.1,localhost"
# And that hostname answers only the console — off until the DNS for it is
# actually in place, because switching it on before then makes the console
# unreachable under any other name.
set_env ADMIN_HOST_EXCLUSIVE false
set_env TRUSTED_RANGES "10.66.0.0/24,127.0.0.1"
optional_env STATUS_HOST "$STATUS_DOMAIN"
set_env CLUPILOT_WG_ENDPOINT "$(curl -fsS4 https://ifconfig.co 2>/dev/null || echo 'SET-ME'):51820"
@ -441,7 +450,7 @@ Still to do, in this order:
Caddy or nginx). ${ADMIN_DOMAIN} must NOT be publicly resolvable.
2. Enter STRIPE_SECRET and STRIPE_WEBHOOK_SECRET in $INSTALL_DIR/.env —
Stripe dashboard → Developers → Webhooks →
https://${APP_DOMAIN}/webhooks/stripe, subscribing all six events:
https://${API_DOMAIN}/webhooks/stripe, subscribing all six events:
checkout.session.completed checkout.session.async_payment_succeeded
invoice.paid invoice.payment_failed
customer.subscription.updated customer.subscription.deleted

View File

@ -93,7 +93,18 @@ Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.
// The service status page. Its own address: it used to sit under /legal beside
// the imprint and the terms, and nothing about the current health of the
// platform is a legal document.
Route::get('/status', StatusController::class)->name('status');
//
// Bound to its own hostname when one is configured, and every other host
// redirects there rather than 404ing — a status page is the one address people
// keep in a bookmark and reach for when something is already wrong.
$statusHost = (string) config('admin_access.status_host');
if ($statusHost !== '') {
Route::domain($statusHost)->get('/status', StatusController::class)->name('status');
Route::get('/status', fn () => redirect()->away('https://'.$statusHost.'/status', 301))->name('status.elsewhere');
} else {
Route::get('/status', StatusController::class)->name('status');
}
// Public legal pages (placeholders — replace with real content before launch).
Route::prefix('legal')->name('legal.')->group(function () {
@ -101,8 +112,10 @@ Route::prefix('legal')->name('legal.')->group(function () {
Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb');
// Kept so existing links and bookmarks do not 404 — permanently, because
// the page has genuinely moved.
Route::permanentRedirect('/status', '/status')->name('status');
// the page has genuinely moved. Through route() rather than a literal
// path: once the page has its own hostname, a relative redirect would land
// on the host the visitor is already on, where it no longer answers.
Route::get('/status', fn () => redirect()->to(route('status'), 301))->name('status');
});
// Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify