diff --git a/config/admin_access.php b/config/admin_access.php index 67bddf6..9b033c5 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh index 12a4309..f7bb276 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 diff --git a/routes/web.php b/routes/web.php index 3c818e8..c34034c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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