diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php new file mode 100644 index 0000000..f580927 --- /dev/null +++ b/app/Http/Controllers/StatusController.php @@ -0,0 +1,194 @@ +portal(), + $this->instances(), + $this->provisioning(), + $this->backups(), + ]; + + // The worst individual state is the state of the whole thing. Averaging + // it would let one outage disappear behind three healthy components. + $overall = match (true) { + in_array('down', array_column($components, 'state'), true) => 'down', + in_array('degraded', array_column($components, 'state'), true) => 'degraded', + in_array('unknown', array_column($components, 'state'), true) => 'unknown', + default => 'operational', + }; + + return view('status', [ + 'components' => $components, + 'overall' => $overall, + 'checkedAt' => Carbon::now(), + ]); + } + + /** + * The portal and the website. + * + * Answering this request is the measurement. There is no honest way for a + * page to report that the server serving it is down. + * + * @return array + */ + private function portal(): array + { + return [ + 'key' => 'portal', + 'state' => 'operational', + 'detail' => null, + ]; + } + + /** + * Customer instances, from what monitoring last saw. + * + * @return array + */ + private function instances(): array + { + $total = Instance::query()->where('status', 'active')->count(); + + if ($total === 0) { + return ['key' => 'instances', 'state' => 'operational', 'detail' => null]; + } + + // Only targets belonging to instances that are actually in service. A + // healthy check on a decommissioned instance says nothing about a live + // one, and counting it would let coverage look complete when it is not. + $onActive = fn ($query) => $query->whereHas( + 'instance', + fn ($instance) => $instance->where('status', 'active'), + ); + + // Only verdicts the sync job has actually refreshed. A row that has + // never been checked, or was last checked long enough ago that the + // answer means nothing, is not evidence of health — and this column was + // for a long time exactly that: written 'up' at provisioning and never + // touched again. + $fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES); + $checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh); + + $watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id'); + $down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id'); + + // Down first, then coverage. An instance nobody is watching is not a + // healthy instance — it is an unanswered question, and reporting the + // absence of a check as the absence of a problem is the one thing this + // page must never do. + return [ + 'key' => 'instances', + 'state' => match (true) { + $down > 0 && $down >= $watched => 'down', + $down > 0 => 'degraded', + $watched < $total => 'unknown', + default => 'operational', + }, + 'detail' => match (true) { + $down > 0 => ['down' => $down, 'total' => $watched], + $watched < $total => ['down' => $total - $watched, 'total' => $total], + default => null, + }, + ]; + } + + /** + * Whether new instances are being delivered. + * + * @return array + */ + private function provisioning(): array + { + $since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS); + + $failed = ProvisioningRun::query() + ->where('status', ProvisioningRun::STATUS_FAILED) + ->where('updated_at', '>=', $since) + ->count(); + + return [ + 'key' => 'provisioning', + 'state' => $failed === 0 ? 'operational' : 'degraded', + 'detail' => $failed > 0 ? ['failed' => $failed] : null, + ]; + } + + /** + * Whether the last backup of every instance is recent. + * + * @return array + */ + private function backups(): array + { + // Counted from the INSTANCES that need protecting, not from the backup + // rows that happen to exist. An active instance with no schedule at all + // has no row — so counting rows would leave it out of the arithmetic + // entirely and report the estate as protected because the backups that + // do exist are fine. + $total = Instance::query()->where('status', 'active')->count(); + + if ($total === 0) { + return ['key' => 'backups', 'state' => 'operational', 'detail' => null]; + } + + $fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS); + + $protected = Instance::query() + ->where('status', 'active') + ->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh)) + ->count(); + + $unprotected = $total - $protected; + + return [ + 'key' => 'backups', + 'state' => match (true) { + $unprotected === 0 => 'operational', + $unprotected >= $total => 'down', + default => 'degraded', + }, + 'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null, + ]; + } +} diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index c9d13a0..a6a1936 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -64,8 +64,11 @@ class RestrictAdminHost abort(404); } - // The portal or the public site, reached through the console's hostname. - if ($onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) { + // The portal or the public site, reached through the console's + // hostname. Only once exclusivity is switched on: a machine that lists + // its own IP in ADMIN_HOSTS so the console is reachable without DNS + // still has to serve the portal from that same address. + if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) { abort(404); } diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 1b570b6..2ab43b9 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -277,8 +277,13 @@ class Overview extends Component // Distinct instances, not targets: one instance can be watched by // several checks, and counting checks would report three outages where - // one machine is down. - $down = MonitoringTarget::query()->where('status', '!=', 'up')->distinct()->count('instance_id'); + // one machine is down. And only verdicts the sync job has refreshed — + // an unchecked row is not a healthy one, nor a failing one. + $down = MonitoringTarget::query() + ->where('status', 'down') + ->where('checked_at', '>=', Carbon::now()->subMinutes(20)) + ->distinct() + ->count('instance_id'); if ($down > 0) { $notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; } diff --git a/app/Models/MonitoringTarget.php b/app/Models/MonitoringTarget.php index 742bd75..de4644f 100644 --- a/app/Models/MonitoringTarget.php +++ b/app/Models/MonitoringTarget.php @@ -7,7 +7,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MonitoringTarget extends Model { - protected $fillable = ['instance_id', 'external_id', 'url', 'status']; + protected $fillable = ['instance_id', 'external_id', 'url', 'status', 'checked_at']; + + protected function casts(): array + { + // Without the cast the freshness comparison happens between a string + // and a Carbon instance, and without checked_at being fillable the sync + // job's update silently drops it — leaving every verdict permanently + // stale, which reads on the status page as "not monitored". + return ['checked_at' => 'datetime']; + } public function instance(): BelongsTo { diff --git a/app/Provisioning/Jobs/SyncMonitoringStatus.php b/app/Provisioning/Jobs/SyncMonitoringStatus.php new file mode 100644 index 0000000..4eb5b29 --- /dev/null +++ b/app/Provisioning/Jobs/SyncMonitoringStatus.php @@ -0,0 +1,74 @@ +whereHas('instance', fn ($instance) => $instance->where('status', 'active')) + ->chunkById(100, function ($targets) use ($monitoring) { + foreach ($targets as $target) { + $this->refresh($target, $monitoring); + } + }); + } + + private function refresh(MonitoringTarget $target, MonitoringClient $monitoring): void + { + try { + // health(), not isHealthy(): the boolean answer folds "no monitoring + // configured" into true and "monitor unreachable" into false, and + // recording either as a measurement publishes a fleet-wide lie. + $healthy = $monitoring->health($target->external_id); + } catch (Throwable $e) { + // The monitoring service being unreachable says nothing about the + // instance. Recording 'down' here would raise an alarm about the + // wrong system; recording 'up' would hide a real one. Leave the + // previous verdict and let it go stale. + Log::warning('Monitoring did not answer for a target', [ + 'external_id' => $target->external_id, + 'exception' => $e->getMessage(), + ]); + + return; + } + + if ($healthy === null) { + // No answer. The previous verdict stays and is allowed to go stale, + // which readers show as "not monitored" rather than as health. + return; + } + + $target->update([ + 'status' => $healthy ? 'up' : 'down', + 'checked_at' => now(), + ]); + } +} diff --git a/app/Provisioning/Steps/Customer/RegisterMonitoring.php b/app/Provisioning/Steps/Customer/RegisterMonitoring.php index ce76e33..bf579a2 100644 --- a/app/Provisioning/Steps/Customer/RegisterMonitoring.php +++ b/app/Provisioning/Steps/Customer/RegisterMonitoring.php @@ -67,7 +67,9 @@ class RegisterMonitoring extends CustomerStep // Local row BEFORE the breadcrumb (the short-circuit guard). $instance->monitoringTargets()->firstOrCreate( ['external_id' => $targetId], - ['url' => $url, 'status' => 'up'], + // 'unknown', not 'up': registering a check is not the same as + // passing one, and the sync job is what turns it into a verdict. + ['url' => $url, 'status' => 'unknown'], ); $this->recordResource($run, $instance->host, 'monitoring_target_id', $targetId); diff --git a/app/Services/Monitoring/FakeMonitoringClient.php b/app/Services/Monitoring/FakeMonitoringClient.php index e86334b..f2e9432 100644 --- a/app/Services/Monitoring/FakeMonitoringClient.php +++ b/app/Services/Monitoring/FakeMonitoringClient.php @@ -36,6 +36,11 @@ class FakeMonitoringClient implements MonitoringClient } public function isHealthy(string $externalId): bool + { + return $this->health($externalId) ?? true; + } + + public function health(string $externalId): ?bool { return $this->healthy; } diff --git a/app/Services/Monitoring/HttpMonitoringClient.php b/app/Services/Monitoring/HttpMonitoringClient.php index 37ce53a..13ccb92 100644 --- a/app/Services/Monitoring/HttpMonitoringClient.php +++ b/app/Services/Monitoring/HttpMonitoringClient.php @@ -35,20 +35,29 @@ class HttpMonitoringClient implements MonitoringClient } public function isHealthy(string $externalId): bool + { + // Unknown counts as healthy HERE, and only here: this is the + // provisioning acceptance check, which must not fail a delivery because + // monitoring is not set up. + return $this->health($externalId) ?? true; + } + + public function health(string $externalId): ?bool { $endpoint = (string) config('services.monitoring.url'); + if (blank($endpoint)) { - return true; // no monitoring service configured + return null; // nothing is watching, which is not the same as healthy } try { $monitor = Http::withToken((string) config('services.monitoring.token')) ->get(rtrim($endpoint, '/').'/monitors/'.$externalId)->throw()->json('monitor', []); - - return ($monitor['status'] ?? '') === 'up'; } catch (\Throwable) { - return false; + return null; // the monitor is unreachable, which says nothing about the instance } + + return ($monitor['status'] ?? '') === 'up'; } public function deregisterTarget(string $externalId): void diff --git a/app/Services/Monitoring/MonitoringClient.php b/app/Services/Monitoring/MonitoringClient.php index b48520c..f693794 100644 --- a/app/Services/Monitoring/MonitoringClient.php +++ b/app/Services/Monitoring/MonitoringClient.php @@ -9,6 +9,23 @@ interface MonitoringClient public function deregisterTarget(string $externalId): void; - /** Current health of a target as reported by the monitoring provider. */ + /** + * Current health of a target as reported by the monitoring provider. + * + * A yes/no answer for the provisioning acceptance check, which wants a + * verdict and treats "cannot tell" as a pass. Anything RECORDING health + * must use health() instead. + */ public function isHealthy(string $externalId): bool; + + /** + * Current health, or null when the provider could not answer. + * + * The three states are genuinely different and collapsing them is how a + * status page lies: with no monitoring configured isHealthy() answers true, + * and with the monitoring service unreachable it answers false — so + * recording that boolean would publish either a fleet-wide all-clear nobody + * measured, or a fleet-wide outage that is really one broken monitor. + */ + public function health(string $externalId): ?bool; } diff --git a/app/Support/AdminArea.php b/app/Support/AdminArea.php index 6354c60..92bc94d 100644 --- a/app/Support/AdminArea.php +++ b/app/Support/AdminArea.php @@ -60,6 +60,24 @@ final class AdminArea ))); } + /** + * May ONLY the console answer on the console's hostname? + * + * Separate from being host-bound, and off by default, because the two are + * not the same thing. A development machine lists its own IP and localhost + * in ADMIN_HOSTS so the console is reachable without DNS — and on that + * machine the portal and the public site have to keep working. Turning + * exclusivity on there would 404 everything that is not the console, which + * is exactly what it did the first time this shipped. + * + * It belongs with the routing change that moves the console to the root of + * its own hostname: until then a console host legitimately serves both. + */ + public static function isExclusive(): bool + { + return self::isHostBound() && (bool) config('admin_access.exclusive', false); + } + /** Whether the console is bound to a hostname at all. */ public static function isHostBound(): bool { @@ -75,7 +93,10 @@ final class AdminArea */ public static function isConsole(Request $request): bool { - return self::isHostBound() + // Keyed on exclusivity, not on merely having a hostname: where the + // console shares its host with the portal it still lives under /admin, + // and "every request to this host is the console" would be false. + return self::isExclusive() ? self::covers($request->getHost()) : $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*'); } @@ -89,12 +110,12 @@ final class AdminArea /** * The path prefix console routes are registered under. * - * Empty when host-bound (the console is at the root of its own hostname), - * `admin` otherwise. + * Empty when the console has its hostname to itself — it is then the root + * of that host. `admin` otherwise. */ public static function prefix(): string { - return self::isHostBound() ? '' : self::FALLBACK_PREFIX; + return self::isExclusive() ? '' : self::FALLBACK_PREFIX; } /** Where the console's own front page lives, as a path. */ diff --git a/config/admin_access.php b/config/admin_access.php index c113118..67bddf6 100644 --- a/config/admin_access.php +++ b/config/admin_access.php @@ -23,6 +23,16 @@ return [ explode(',', (string) env('ADMIN_HOSTS', '')) ))), + /* + | Whether the console's hostname serves ONLY the console. + | + | Off by default, and deliberately separate from having a hostname at all: a + | development machine lists its own IP in ADMIN_HOSTS so the console works + | without DNS, and that same address still has to serve the portal. Switch + | it on only where the console really has a hostname to itself. + */ + 'exclusive' => (bool) env('ADMIN_HOST_EXCLUSIVE', false), + /* | 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/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php b/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php new file mode 100644 index 0000000..87901e3 --- /dev/null +++ b/database/migrations/2026_07_27_040000_record_when_monitoring_last_answered.php @@ -0,0 +1,34 @@ +timestamp('checked_at')->nullable()->after('status'); + }); + + // Every existing row is an unverified claim. Clearing it is the point: + // "we have not looked" must not keep reading as "it is fine". + DB::table('monitoring_targets')->update(['status' => 'unknown', 'checked_at' => null]); + } + + public function down(): void + { + Schema::table('monitoring_targets', fn (Blueprint $table) => $table->dropColumn('checked_at')); + } +}; diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index bc76799..3fe24c4 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -96,4 +96,11 @@ return [ 'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.', 'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.', ], + + 'update_last_run' => 'Letzter Lauf: :state, :when.', + 'update_state' => [ + 'succeeded' => 'erfolgreich', + 'failed' => 'fehlgeschlagen', + 'running' => 'läuft', + ], ]; diff --git a/lang/de/status.php b/lang/de/status.php new file mode 100644 index 0000000..1ef0819 --- /dev/null +++ b/lang/de/status.php @@ -0,0 +1,40 @@ + [ + 'operational' => 'Alle Dienste in Betrieb', + 'degraded' => 'Ein Dienst ist beeinträchtigt', + 'down' => 'Ein Dienst ist gestört', + // Not "in Betrieb": nothing is being watched, and reporting the absence + // of a check as the absence of a problem is the one thing a status page + // must never do. + 'unknown' => 'Nicht vollständig überwacht', + ], + + 'state' => [ + 'operational' => 'In Betrieb', + 'degraded' => 'Beeinträchtigt', + 'down' => 'Gestört', + 'unknown' => 'Unbekannt', + ], + + 'component' => [ + 'portal' => 'Website & Kundenportal', + 'instances' => 'Cloud-Instanzen', + 'provisioning' => 'Bereitstellung neuer Instanzen', + 'backups' => 'Sicherungen', + ], + + 'about' => [ + 'portal' => 'Erreichbarkeit von www.clupilot.com und dem Kundenbereich.', + 'instances' => 'Erreichbarkeit der betriebenen Kundeninstanzen.', + 'provisioning' => 'Neue Bestellungen werden ohne Fehler ausgeliefert.', + 'backups' => 'Jede Instanz hat eine Sicherung aus den letzten 48 Stunden.', + ], + + 'detail' => [ + 'instances' => ':down von :total Instanzen ohne positives Signal.', + 'provisioning' => ':failed fehlgeschlagene Bereitstellung(en) in den letzten 24 Stunden.', + 'backups' => ':stale von :total Instanzen ohne aktuelle Sicherung.', + ], +]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index bb21bac..21d6a59 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -96,4 +96,11 @@ return [ 'detached_no_release' => 'The checkout is on no branch and pinned to no release.', 'update_failed' => 'The update failed (code :code). See the log.', ], + + 'update_last_run' => 'Last run: :state, :when.', + 'update_state' => [ + 'succeeded' => 'succeeded', + 'failed' => 'failed', + 'running' => 'running', + ], ]; diff --git a/lang/en/status.php b/lang/en/status.php new file mode 100644 index 0000000..c13941c --- /dev/null +++ b/lang/en/status.php @@ -0,0 +1,40 @@ + [ + 'operational' => 'All services operational', + 'degraded' => 'One service is degraded', + 'down' => 'One service is down', + // Not "operational": nothing is being watched, and reporting the + // absence of a check as the absence of a problem is the one thing a + // status page must never do. + 'unknown' => 'Not fully monitored', + ], + + 'state' => [ + 'operational' => 'Operational', + 'degraded' => 'Degraded', + 'down' => 'Down', + 'unknown' => 'Unknown', + ], + + 'component' => [ + 'portal' => 'Website & customer portal', + 'instances' => 'Cloud instances', + 'provisioning' => 'Provisioning of new instances', + 'backups' => 'Backups', + ], + + 'about' => [ + 'portal' => 'Availability of www.clupilot.com and the customer area.', + 'instances' => 'Availability of the customer instances we operate.', + 'provisioning' => 'New orders are being delivered without failures.', + 'backups' => 'Every instance has a backup from the last 48 hours.', + ], + + 'detail' => [ + 'instances' => ':down of :total instances without a positive signal.', + 'provisioning' => ':failed failed provisioning run(s) in the last 24 hours.', + 'backups' => ':stale of :total instances without a current backup.', + ], +]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index fb51b03..ba04b09 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -863,7 +863,7 @@ Impressum Datenschutz AGB - Status + Status Anmelden diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 1c75260..bdc66f1 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -6,7 +6,11 @@ {{-- Version & update --}} @if ($canManageSite) -
+ {{-- Polls itself: an operator watching an update run should not have to + reload the page to find out whether it finished. Fast while + something is happening, slow when nothing is. --}} +
@@ -50,13 +54,25 @@
- {{-- Bound, not @disabled(): the directive compiles to inline PHP - inside the component tag, and Blade then no longer - recognises the tag as a component at all. --}} - - {{ __('admin_settings.update_now') }} - + {{-- Offered only when there is something to install. Bound, not + @disabled(): the directive compiles to inline PHP inside the + component tag, and Blade then stops seeing a component. --}} +
+ @if ($update['running']) + + + {{ __('admin_settings.update_running') }} + + @elseif ($update['available']) + + {{ __('admin_settings.update_now') }} + + @else + + {{ $update['behind'] === 0 ? __('admin_settings.update_current') : __('admin_settings.update_unknown') }} + + @endif +
@if (! $update['agent_seen']) @@ -65,8 +81,19 @@ {{ $update['last_error'] }} @endif + @if ($update['last_state'] !== null && ! $update['running']) +

+ {{ __('admin_settings.update_last_run', [ + 'state' => __('admin_settings.update_state.'.$update['last_state']), + 'when' => $update['last_finished_at']?->diffForHumans() ?? '—', + ]) }} +

+ @endif + + {{-- Open while it runs: "läuft gerade" on its own tells an operator + nothing about whether it is progressing or wedged. --}} @if ($updateLog) -
+
{{ __('admin_settings.update_log') }}
{{ $updateLog }}
diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php new file mode 100644 index 0000000..f78d545 --- /dev/null +++ b/resources/views/status.blade.php @@ -0,0 +1,134 @@ + + + + + +Status — CluPilot Cloud + + + + + +@verbatim + +@endverbatim + + +
+
+ CluPilot Cloud +

Betriebsstatus

+ +
+ + {{ __('status.overall.'.$overall) }} +
+
+ +
+ @foreach ($components as $c) +
+
+ {{ __('status.component.'.$c['key']) }} + + @if ($c['detail'] !== null) + {{ __('status.detail.'.$c['key'], $c['detail']) }} + @else + {{ __('status.about.'.$c['key']) }} + @endif + +
+ {{ __('status.state.'.$c['state']) }} +
+ @endforeach +
+ +

Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr

+ +

+ Diese Seite wird bei jedem Aufruf neu ermittelt — aus Überwachung, Sicherungsprotokoll und + Bereitstellungsläufen. Wo nichts überwacht wird, steht das auch so da und nicht „in Betrieb“. + Eine Störung, die Sie hier nicht sehen, melden Sie bitte an + office@clupilot.com — Antwort am selben Werktag. +

+ + +
+ + diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..65a0bb2 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,39 @@ +name('overview'); +Route::get('/customers', Admin\Customers::class)->name('customers'); +Route::get('/instances', Admin\Instances::class)->name('instances'); +Route::get('/hosts', Admin\Hosts::class)->name('hosts'); +Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); +Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); +Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); +Route::get('/plans', Admin\Plans::class)->name('plans'); +Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); +// POST so the identity change is CSRF-protected (a GET could be forced cross-site). +Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); +Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); +Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); +Route::get('/vpn', Admin\Vpn::class)->name('vpn'); +Route::get('/revenue', Admin\Revenue::class)->name('revenue'); +Route::get('/settings', Admin\Settings::class)->name('settings'); diff --git a/routes/console.php b/routes/console.php index f76c9d9..1ee7ae2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -24,6 +24,12 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) // Sample network counters and enforce the monthly allowance. Runs on the // provisioning queue, which is where the Proxmox credentials are usable. +// Turns the monitoring column from a claim into a measurement. Without it the +// console and the public status page report every instance healthy forever. +Schedule::job(new \App\Provisioning\Jobs\SyncMonitoringStatus) + ->everyFiveMinutes() + ->withoutOverlapping(); + Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) ->everyFifteenMinutes() ->name('traffic-collect'); diff --git a/routes/web.php b/routes/web.php index cbc4cd8..f2aabb1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\ImpersonationController; use App\Http\Controllers\LandingController; +use App\Http\Controllers\StatusController; use App\Http\Controllers\StripeWebhookController; use App\Livewire\Admin; use App\Livewire\Billing; @@ -13,8 +14,56 @@ use App\Livewire\Dashboard; use App\Livewire\Invoices; use App\Livewire\Support; use App\Livewire\Users; +use App\Support\AdminArea; use Illuminate\Support\Facades\Route; +/* +| The operator console, registered BEFORE anything else in this file. +| +| Once the console has a hostname to itself it sits at the ROOT of that host — +| where `/`, `/settings` and `/customers` also exist further down for the +| public site and the customer portal. Laravel takes the first matching route, +| so the order of these two blocks is the separation. +| +| Where it mounts is AdminArea's call: `/` on the console hostname where it has +| one to itself, `/admin` on any host otherwise. The names stay `admin.*` in +| both modes, so nothing that generates a URL has to know which mode it is in. +*/ +if (AdminArea::isExclusive()) { + // Once per accepted hostname, and the canonical one LAST. + // + // ADMIN_HOSTS may list alternates — a bare IP, a second name — and those + // are the recovery paths someone locked out reaches for. Binding only the + // first would make the console 404 through every one of them. Registering + // the canonical host last means route() generates URLs for it, while all of + // them still match. + $hosts = AdminArea::hosts(); + foreach (array_reverse($hosts) as $host) { + Route::domain($host) + ->middleware(['admin.host', 'auth', 'admin']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin.php')); + } +} else { + // Shared with the portal: no hostname binding, and the /admin prefix keeps + // the two apart by path. + Route::middleware(['admin.host', 'auth', 'admin']) + ->prefix(AdminArea::prefix()) + ->name('admin.') + ->group(base_path('routes/admin.php')); +} + +// Old console addresses, once the console has moved off /admin. Permanent, +// because it really has moved, and only on the console's own host so this +// never hands a stranger a redirect that confirms a console exists. +if (AdminArea::isExclusive()) { + Route::domain(AdminArea::host()) + ->get('/admin/{rest?}', fn (?string $rest = null) => redirect('/'.($rest ?? ''), 301)) + ->where('rest', '.*') + ->name('admin.legacy'); +} + Route::get('/', LandingController::class)->name('home'); // Generated, not a static file: while the site is hidden this has to say so, @@ -30,12 +79,19 @@ Route::get('/robots.txt', function () { // Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed). Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe'); +// 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'); + // Public legal pages (placeholders — replace with real content before launch). Route::prefix('legal')->name('legal.')->group(function () { Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum'); Route::get('/datenschutz', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz'); Route::get('/agb', fn () => view('legal', ['title' => 'AGB']))->name('agb'); - Route::get('/status', fn () => view('legal', ['title' => 'Status']))->name('status'); + // Kept so existing links and bookmarks do not 404 — permanently, because + // the page has genuinely moved. + Route::permanentRedirect('/status', '/status')->name('status'); }); // Guest auth pages — full-page class-based Livewire components (R1/R2). Fortify @@ -67,27 +123,3 @@ Route::middleware(['auth', 'customer.active'])->group(function () { // POST so Laravel's CSRF middleware protects the identity change. Route::post('/impersonate/leave', [ImpersonationController::class, 'leave'])->name('impersonate.leave'); }); - -// Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). -// RestrictAdminHost is BOTH prepended to the `web` group (deterministic 404 on -// direct hits, even for guests) AND listed here so Livewire records it on the -// component snapshot and re-applies it to /livewire/update requests — otherwise -// admin actions could be invoked through a public hostname. -Route::middleware(['admin.host', 'auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { - Route::get('/', Admin\Overview::class)->name('overview'); - Route::get('/customers', Admin\Customers::class)->name('customers'); - Route::get('/instances', Admin\Instances::class)->name('instances'); - Route::get('/hosts', Admin\Hosts::class)->name('hosts'); - Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); - Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); - Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); - Route::get('/plans', Admin\Plans::class)->name('plans'); - Route::get('/plans/{uuid}', Admin\PlanVersions::class)->name('plans.versions'); - // POST so the identity change is CSRF-protected (a GET could be forced cross-site). - Route::post('/impersonate/{customer}', [ImpersonationController::class, 'start'])->name('impersonate'); - Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); - Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); - Route::get('/vpn', Admin\Vpn::class)->name('vpn'); - Route::get('/revenue', Admin\Revenue::class)->name('revenue'); - Route::get('/settings', Admin\Settings::class)->name('settings'); -}); diff --git a/tests/Feature/Admin/ConsoleHostSeparationTest.php b/tests/Feature/Admin/ConsoleHostSeparationTest.php new file mode 100644 index 0000000..87246f1 --- /dev/null +++ b/tests/Feature/Admin/ConsoleHostSeparationTest.php @@ -0,0 +1,122 @@ +setRouteResolver(function () use ($routeName, $path) { + $route = new RoutingRoute(['GET'], $path, fn () => null); + + return $routeName !== null ? $route->name($routeName) : $route; + }); + + try { + (new RestrictAdminHost)->handle($request, fn () => response('through')); + + return 'through'; + } catch (NotFoundHttpException) { + return '404'; + } +} + +describe('shared host', function () { + beforeEach(function () { + config()->set('admin_access.hosts', ['10.10.90.185', 'localhost']); + config()->set('admin_access.exclusive', false); + }); + + it('keeps the console under /admin', function () { + expect(AdminArea::prefix())->toBe('admin') + ->and(AdminArea::isConsole(Request::create('http://localhost/admin/customers')))->toBeTrue() + ->and(AdminArea::isConsole(Request::create('http://localhost/cloud')))->toBeFalse(); + }); + + it('still serves the portal and the public site from that host', function () { + // The regression: a machine reached by IP has that IP in ADMIN_HOSTS, + // and 404ing everything else there removes the product from it. + expect(guard('localhost', '/cloud', 'cloud'))->toBe('through') + ->and(guard('localhost', '/', 'home'))->toBe('through') + ->and(guard('localhost', '/status', 'status'))->toBe('through'); + }); + + it('keeps the console off a host that is not listed', function () { + expect(guard('www.example.test', '/admin', 'admin.overview'))->toBe('404'); + }); +}); + +describe('exclusive host', function () { + beforeEach(function () { + config()->set('admin_access.hosts', ['admin.example.test']); + config()->set('admin_access.exclusive', true); + }); + + it('puts the console at the root of its own host', function () { + expect(AdminArea::prefix())->toBe('') + ->and(AdminArea::home())->toBe('/') + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/customers')))->toBeTrue() + ->and(AdminArea::isConsole(Request::create('http://app.example.test/customers')))->toBeFalse(); + }); + + it('answers nothing but the console on that host', function () { + expect(guard('admin.example.test', '/', 'admin.overview'))->toBe('through') + ->and(guard('admin.example.test', '/customers', 'admin.customers'))->toBe('through') + // The portal and the public site are gone from this hostname. + ->and(guard('admin.example.test', '/cloud', 'cloud'))->toBe('404') + ->and(guard('admin.example.test', '/status', 'status'))->toBe('404') + ->and(guard('admin.example.test', '/dashboard', 'dashboard'))->toBe('404'); + }); + + it('lets the shared authentication endpoints through on both hosts', function () { + // Sign-in posts and Livewire's own endpoints are used by both sides. If + // this list is wrong the symptom is "the console is broken", so it is + // written out rather than inferred. + foreach (['/login', '/logout', '/livewire/update', '/livewire/livewire.min.js', '/up'] as $path) { + expect(guard('admin.example.test', $path, null))->toBe('through', $path); + } + }); + + it('keeps the console off the public hosts', function () { + expect(guard('app.example.test', '/', 'admin.overview'))->toBe('404') + ->and(guard('www.example.test', '/customers', 'admin.customers'))->toBe('404'); + }); + + it('still serves the public site on its own host', function () { + expect(guard('www.example.test', '/', 'home'))->toBe('through') + ->and(guard('app.example.test', '/dashboard', 'dashboard'))->toBe('through'); + }); +}); + +it('never treats a request as the console just because a hostname is configured', function () { + // isConsole drives the public-site switch and the network allowlist. Getting + // it wrong in this direction hands the console's exemptions to the portal. + config()->set('admin_access.hosts', ['admin.example.test']); + config()->set('admin_access.exclusive', false); + + expect(AdminArea::isHostBound())->toBeTrue() + ->and(AdminArea::isExclusive())->toBeFalse() + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/cloud')))->toBeFalse() + ->and(AdminArea::isConsole(Request::create('http://admin.example.test/admin')))->toBeTrue(); +}); diff --git a/tests/Feature/MonitoringStatusSyncTest.php b/tests/Feature/MonitoringStatusSyncTest.php new file mode 100644 index 0000000..e238d11 --- /dev/null +++ b/tests/Feature/MonitoringStatusSyncTest.php @@ -0,0 +1,107 @@ +create(['status' => 'active']); + + return MonitoringTarget::query()->create(array_merge([ + 'instance_id' => $instance->id, + 'external_id' => $externalId, + 'url' => 'https://example.test', + 'status' => 'unknown', + ], $attributes)); +} + +it('records what monitoring actually reports', function () { + $up = target('mon-up'); + $down = target('mon-down'); + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->with('mon-up')->andReturnTrue(); + $mock->shouldReceive('health')->with('mon-down')->andReturnFalse(); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($up->refresh()->status)->toBe('up') + ->and($up->checked_at)->not->toBeNull() + ->and($down->refresh()->status)->toBe('down'); +}); + +it('does not blame the instance when the monitoring service is the one that is down', function () { + // "The monitor did not answer" says nothing about the instance. Writing + // 'down' would raise an alarm about the wrong system; writing 'up' would + // hide a real one. + $target = target('mon-x', ['status' => 'up', 'checked_at' => now()->subMinutes(3)]); + $wasCheckedAt = $target->checked_at; + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->andThrow(new RuntimeException('connection refused')); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($target->refresh()->status)->toBe('up') + // Not advanced — so the verdict is allowed to go stale and stop counting. + ->and($target->checked_at->timestamp)->toBe($wasCheckedAt->timestamp); +}); + +it('does not treat a never-checked target as healthy on the public page', function () { + target('mon-fresh'); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')) + ->assertDontSee(__('status.overall.operational')); +}); + +it('stops trusting a verdict nobody has refreshed', function () { + target('mon-old', ['status' => 'up', 'checked_at' => now()->subHours(2)]); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')); +}); + +it('records nothing when the monitor cannot answer at all', function () { + // The boolean contract folded "no monitoring configured" into true and + // "monitor unreachable" into false. Recording either as a measurement + // publishes a fleet-wide all-clear nobody took, or a fleet-wide outage that + // is really one broken monitor. + $target = target('mon-quiet', ['status' => 'up', 'checked_at' => now()->subMinutes(4)]); + $wasCheckedAt = $target->checked_at; + + $this->mock(MonitoringClient::class, function ($mock) { + $mock->shouldReceive('health')->andReturnNull(); + }); + + (new SyncMonitoringStatus)->handle(app(MonitoringClient::class)); + + expect($target->refresh()->status)->toBe('up') + ->and($target->checked_at->timestamp)->toBe($wasCheckedAt->timestamp); +}); + +it('keeps the acceptance check passing when monitoring is not set up', function () { + // isHealthy() still folds "cannot tell" into a pass — deliberately, and + // only there: a delivery must not fail because monitoring is unconfigured. + config()->set('services.monitoring.url', ''); + + $client = new App\Services\Monitoring\HttpMonitoringClient; + + expect($client->health('anything'))->toBeNull() + ->and($client->isHealthy('anything'))->toBeTrue(); +}); diff --git a/tests/Feature/WelcomeTest.php b/tests/Feature/WelcomeTest.php index 24e154e..49f5528 100644 --- a/tests/Feature/WelcomeTest.php +++ b/tests/Feature/WelcomeTest.php @@ -9,4 +9,43 @@ it('renders the public landing page with a sign-in link', function () { it('renders the legal placeholder pages', function (string $route) { $this->get(route($route))->assertOk()->assertSee('CluPilot'); -})->with(['legal.impressum', 'legal.datenschutz', 'legal.agb', 'legal.status']); +})->with(['legal.impressum', 'legal.datenschutz', 'legal.agb']); + +it('serves the status page at its own address, not under legal', function () { + // It used to sit beside the imprint and the terms. Nothing about the + // current health of the platform is a legal document. + $this->get('/status') + ->assertOk() + ->assertSee(__('status.component.instances')) + ->assertSee(__('status.component.backups')); + + // The old address still works, permanently, for anything already linking it. + $this->get('/legal/status')->assertRedirect('/status'); +}); + +it('reports a component it cannot see as unknown, never as operational', function () { + // A status page that reports green because nothing is being watched is + // worse than no status page: someone will believe it. + App\Models\Instance::factory()->create(['status' => 'active']); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.state.unknown')) + ->assertDontSee(__('status.overall.operational')); +}); + +it('does not call the estate protected when an instance has no backup at all', function () { + // Counting backup ROWS leaves an instance with no schedule out of the + // arithmetic entirely, and the page reports green because the backups that + // do exist happen to be fine. + $withBackup = App\Models\Instance::factory()->create(['status' => 'active']); + App\Models\Backup::query()->create([ + 'instance_id' => $withBackup->id, 'external_job_id' => 'job-1', + 'schedule' => 'daily', 'status' => 'ok', 'last_ok_at' => now()->subHour(), + ]); + App\Models\Instance::factory()->create(['status' => 'active']); + + $this->get('/status') + ->assertOk() + ->assertSee(__('status.detail.backups', ['stale' => 1, 'total' => 2])); +});