184 lines
9.7 KiB
PHP
184 lines
9.7 KiB
PHP
<?php
|
|
|
|
use App\Http\Middleware\EnsureSecurityOnboarded;
|
|
use App\Http\Middleware\SetLocale;
|
|
use App\Livewire\Audit;
|
|
use App\Livewire\Auth;
|
|
use App\Livewire\Dashboard;
|
|
use App\Livewire\Files;
|
|
use App\Livewire\Help;
|
|
use App\Livewire\Release;
|
|
use App\Livewire\Servers;
|
|
use App\Livewire\Services;
|
|
use App\Livewire\Settings;
|
|
use App\Livewire\System;
|
|
use App\Livewire\Versions;
|
|
use App\Livewire\Wireguard;
|
|
use App\Models\Server;
|
|
use App\Services\DeploymentService;
|
|
use App\Services\MetricHistory;
|
|
use App\Services\WgStatus;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth as AuthFacade;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
// Language switch (public — also usable from the login page). Persists to the signed-in
|
|
// user and the session; only known locales (R16) are accepted, then we return back.
|
|
Route::get('/locale/{locale}', function (Request $request, string $locale) {
|
|
if (in_array($locale, SetLocale::SUPPORTED, true)) {
|
|
$request->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');
|
|
|
|
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');
|
|
|
|
Route::get('/servers', Servers\Index::class)->name('servers.index');
|
|
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');
|
|
Route::get('/files', Files\Index::class)->name('files.index');
|
|
Route::get('/audit', Audit\Index::class)->name('audit.index');
|
|
|
|
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('/help', Help\Index::class)->name('help');
|
|
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
|
|
|
|
// 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');
|
|
});
|
|
});
|