session()->put('locale', $locale); if ($user = $request->user()) { $user->forceFill(['locale' => $locale])->save(); } } return redirect()->back(); })->name('locale.set'); // Caddy on-demand TLS gate: Caddy calls this before issuing a certificate for a host. // Approve ONLY the configured panel domain, so Caddy can never be tricked into // requesting certs for arbitrary domains pointed at this IP (rate-limit abuse). Route::get('/_caddy/ask', function (Request $request, DeploymentService $deployment) { $asked = strtolower(trim((string) $request->query('domain'))); $domain = $deployment->domain(); abort_unless($domain !== null && $asked !== '' && $asked === $domain && ! $deployment->externalTls(), 403); return response('ok', 200); })->name('caddy.ask'); // Lightweight PUBLIC version probe. The update-progress page polls this to detect when the // rebuilt app is actually live (the running version moved past the pre-update one) — the OLD // container keeps answering /up throughout the long build, so /up alone can't signal completion. Route::get('/version.json', fn () => response()->json(['version' => (string) config('clusev.version')]))->name('version.json'); // Coarse update-progress feed. The host updater (update.sh / install.sh) writes a macro-stage to // run/update-phase.json (bind-mounted to storage/app/restart-signal/); the update-progress page // polls this to show REAL progress (fetch → build → restart → migrate → done) instead of a time // guess. PUBLIC and only ever echoes a whitelisted stage token — never any sensitive data. Route::get('/update-status.json', function (DeploymentService $deployment) { $phase = $deployment->updatePhase(); return response()->json(['stage' => $phase['stage'] ?? null, 'at' => $phase['at'] ?? 0]); })->name('update.status'); // INTERNAL: the terminal sidecar exchanges a single-use token for a connection spec. Authenticated by // the shared secret (VerifyTerminalSidecarSecret) and only routed on the private Docker network — it // returns DECRYPTED SSH credentials, so it must never be publicly reachable. Token is burned on use. Route::post('/_internal/terminal/resolve', function (Request $request) { $token = (string) $request->input('token'); // Burn the token ATOMICALLY: a single conditional UPDATE (used_at IS NULL AND not expired) — the // DB row-locks it, so two racing resolves can never both succeed and open two PTYs from one token. $burned = TerminalSession::where('token', $token) ->whereNull('used_at') ->where('expires_at', '>', now()) ->update(['used_at' => now()]); abort_if($burned !== 1, 404); $session = TerminalSession::where('token', $token)->firstOrFail(); // The "Clusev host" terminal is a real SSH login into the host machine, configured by the operator // (HostCredential). Hand the sidecar a server-shaped SSH spec — the decrypted login goes only over // the internal network, never to the browser. No configured login → 404 (the UI gates this anyway). if ($session->kind === 'host') { $hc = HostCredential::current(); abort_if($hc === null, 404); return response()->json([ 'kind' => 'server', 'host' => $hc->host, 'port' => $hc->port ?: 22, 'username' => $hc->username, 'auth_type' => $hc->auth_type, 'secret' => $hc->secret, // decrypted by the model cast; internal net only 'passphrase' => $hc->passphrase, 'host_key' => null, // the host has no fleet TOFU pin (local machine) ]); } $server = $session->server()->with('credential')->first(); $cred = $server?->credential; abort_if($server === null || $cred === null || $cred->disabled_at !== null, 404); return response()->json([ 'kind' => 'server', 'host' => $server->ip, 'port' => $server->ssh_port ?: 22, 'username' => $cred->username, 'auth_type' => $cred->auth_type, 'secret' => $cred->secret, // 'encrypted' cast → decrypted here; sent only over the internal net 'passphrase' => $cred->passphrase, // decrypted or null // The TOFU-pinned host key (phpseclib getServerPublicHostKey, " "); the sidecar // pins the SSH connection against it. Null when the server was never contacted → unpinned. 'host_key' => $server->ssh_host_key, ]); })->middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve'); // ── Honeypot decoy paths ──────────────────────────────────────────────────────────────────── // Well-known scanner/exploit targets that NO legitimate client ever visits. Every hit is a hostile // probe → HoneypotController::trap logs it, instant-bans the source IP, and serves a convincing fake // body (BruteforceGuard::banNow no-ops for exempt/loopback IPs). Route::any so probes on any verb // (GET/POST/HEAD) are caught. These paths were checked against the real top-level routes above // (/locale, /_caddy/ask, /version.json, /update-status.json, /_internal/*) and the auth groups // below (/login, /dashboard, …) — none collide. The two dotfile decoys (/.env, /.git/config) are // additionally allowed through the nginx dotfile-deny guard (docker/nginx/nginx.conf). // The decoys drop BlockBannedIp so an already-banned prober stays INSIDE the deception (always fed a // fake, never a real 403 that reveals the panel) while the ban still keeps them off the REAL routes. // CSRF is exempt for these paths in bootstrap/app.php (so a POSTed fake login is trapped, not 419'd). // DetectHoneytoken stays on, so a canary submitted into the fake form is still caught. Route::withoutMiddleware([BlockBannedIp::class])->group(function () { Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env'); Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git'); Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login'); Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin'); Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path'); Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc'); Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin'); Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path'); Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator'); Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php'); Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer'); Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json'); Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor'); Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin'); Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr'); Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator'); }); Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup'); }); Route::middleware('auth')->group(function () { // Security onboarding — reachable before the panel is unlocked. Route::get('/password', Auth\PasswordChange::class)->name('password.change'); Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup'); // 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes). // POST (not GET): consuming the one-time grant is a state change, so it must carry a CSRF // token — a cross-site GET cannot burn the victim's grant. Route::post('/two-factor/recovery-codes/download', function () { // One download per fresh generation, and only briefly. The grant is a timestamp put // alongside the codes when they are freshly generated/regenerated. pull() consumes it // (single-use, so re-downloading stored codes by hitting this route directly 403s), // and a grant older than 10 minutes — or any non-timestamp value — is rejected, so an // unused grant cannot linger and be replayed later in the session. ->block() below // serializes racing reads of the grant. $grantedAt = session()->pull('2fa.download_grant'); $age = is_int($grantedAt) ? now()->timestamp - $grantedAt : null; // Valid only within [now-600s, now]: rejects stale, non-timestamp, and future grants. abort_unless($age !== null && $age >= 0 && $age <= 600, 403); $codes = AuthFacade::user()->recoveryCodes(); return response()->streamDownload( fn () => print (implode("\n", $codes)."\n"), 'clusev-recovery-codes.txt', ['Content-Type' => 'text/plain; charset=UTF-8'], ); })->name('two-factor.recovery.download') // Session blocking (Redis-backed atomic lock) serializes concurrent same-session // requests so the one-time `2fa.download_grant` cannot be double-read by a racing // request. True concurrency cannot be unit-tested; lock-capable stores (redis in // prod, array/file in tests) keep the happy path green. ->block(5, 5); Route::post('/logout', function () { AuthFacade::logout(); session()->invalidate(); session()->regenerateToken(); return redirect()->route('login'); })->name('logout'); // The panel — requires completed onboarding (seeded password rotated). Route::middleware(EnsureSecurityOnboarded::class)->group(function () { Route::get('/', Dashboard::class)->name('dashboard'); // Live sidebar badge counts (alerts firing / threats 24h / update available). Polled by the // navBadges Alpine island so the chips update without a page re-render; counts are the same // cached+busted model helpers the sidebar render uses. Route::get('/shell/badges.json', function () { $panel = auth()->user()?->can('manage-panel') ?? false; return response()->json([ 'alerts' => $panel ? AlertIncident::firingCount() : 0, 'threats' => $panel ? AuditEvent::threatLoginCount24h() : 0, 'update' => app(ReleaseChecker::class)->updateAvailable() ? 1 : 0, ]); })->name('shell.badges'); Route::get('/servers', Servers\Index::class)->name('servers.index'); // MUST be registered before /servers/{server}, or "groups" is captured as a server uuid. Route::get('/servers/groups', Servers\Groups::class)->middleware('can:manage-fleet')->name('servers.groups'); Route::get('/servers/{server}', Servers\Show::class)->name('servers.show'); // History-graph data for the Server-Details chart (the Alpine island fetches this per range). Route::get('/servers/{server}/history.json', function (Server $server, Request $request, MetricHistory $history) { $ranges = ['1h' => 3600, '24h' => 86400, '7d' => 604800, '30d' => 2592000]; $window = $ranges[$request->query('range', '24h')] ?? 86400; return response()->json($history->series($server, $window)); })->name('servers.history'); Route::get('/services', Services\Index::class)->name('services.index'); // Host Docker view — admin-only (the control-plane machine; per-server Docker is a tab on the // server-details page). Route guard + Docker\Index::mount() abort_unless both gate it. Route::get('/docker', Docker\Index::class)->middleware('can:manage-fleet')->name('docker'); Route::get('/commands', Commands\Index::class)->middleware('can:operate')->name('commands'); Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); // Honeypot intelligence dashboard — admin-only (defense-in-depth: the route middleware here // AND Threats\Index::mount() abort_unless both gate on manage-panel). Route::get('/threats', Threats\Index::class)->name('threats')->middleware('can:manage-panel'); Route::get('/alerts', Alerts\Index::class)->middleware('can:manage-panel')->name('alerts'); Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture'); Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch'); Route::get('/certs', Certs\Index::class)->middleware('can:operate')->name('certs'); Route::get('/health', Health\Index::class)->middleware('can:operate')->name('health'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); // Host terminal — admin-only (real SSH login into the Clusev machine; per-server shells are a // tab on the server-details page). Terminal\Index::mount() abort_unless gates it too. Route::get('/terminal', Terminal\Index::class)->middleware('can:manage-fleet')->name('terminal'); // Dev-only Release page — first of the gating triple (spec): the route is registered ONLY when // the flag is on, so with it off /release does not exist (uncached route file is re-included // per request, so the flag is honoured). Release\Index::mount() abort_unless(...) is the second // guard (covers a cached route file); the sidebar item is flag-gated too (third). if (config('clusev.release_controls')) { Route::get('/release', Release\Index::class)->name('release'); } Route::get('/wg-status.json', fn (WgStatus $wg) => response()->json($wg->read()))->name('wg.status'); // Plain Blade view — deliberately NOT a Livewire component so it survives the // container teardown that follows an update request (R1/R2 exception, by design). Route::get('/update-progress', function (Request $request, DeploymentService $deployment) { $parsed = parse_url((string) $request->query('return', ''), PHP_URL_PATH); $return = (is_string($parsed) && str_starts_with($parsed, '/') && ! str_starts_with($parsed, '//')) ? $parsed : '/'; // Pre-update version (sanitised to the semver charset). The page finishes only once the // running version has moved past this — see resources/views/update-progress.blade.php. $from = preg_replace('/[^0-9A-Za-z.\-]/', '', (string) $request->query('from', '')); // Server-side update start + current macro-stage so the page is REFRESH-PROOF: the // elapsed timer counts from the real start, and the checklist resumes at the live phase // (not back to phase one). A stage is only trusted when we know when THIS update began // (startedAt, written by requestUpdate) AND the stage was written at/after it — so a // leftover stage from a previous run can never drive the checklist or a premature finish. $startedAt = $deployment->updateStartedAt(); $phase = $deployment->updatePhase(); $stageIndex = ['fetch' => 0, 'build' => 1, 'restart' => 2, 'migrate' => 3, 'done' => 4]; $initialPhase = 0; $hasFeed = false; // a fresh real stage exists → drive the checklist from the feed, not the time guess if ($phase !== null && $startedAt !== null && $phase['at'] >= $startedAt - 5) { $initialPhase = $stageIndex[$phase['stage']] ?? 0; $hasFeed = true; } return view('update-progress', [ 'returnPath' => $return, 'fromVersion' => $from, 'startedAt' => $startedAt, 'initialPhase' => $initialPhase, 'hasFeed' => $hasFeed, ]); })->name('update.progress'); }); });