fix(blade): garbled header on every signed-in page + private metrics channel (v0.4.1)

Two fixes/hardenings on top of v0.4.0:

1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
   tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
   as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
   every authenticated page (and left the page title empty). R12 missed it: the page still
   returned 200 with no console error and the <title> comes from the Livewire component. Fixed
   the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
   R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.

2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
   v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
   (withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
   anyone holding the bundled app key — including over a stale Caddy host after a domain change.
   The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
   the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
   CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
   channel is private.

Bump 0.4.0 -> 0.4.1; CHANGELOG.

Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.4.1
boban 2026-06-14 08:34:07 +02:00
parent a44e29426a
commit f5f903046d
10 changed files with 110 additions and 9 deletions

View File

@ -13,6 +13,22 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.4.1] - 2026-06-14
### Behoben
- **Verstümmelter Header auf jeder eingeloggten Seite.** Ein Kommentar im `@php`-Block der
topbar enthielt die literalen Tokens `@php`/`@endphp`; Blades Raw-Block-Matcher nahm das
`@endphp` im Kommentar als Block-Ende und gab den Rest (inkl. `$title ??= …`) als Klartext im
`<header>` aus — der Seitentitel blieb zudem leer. Kommentar bereinigt; neue Regel **R17**
(keine Direktiv-Tokens in Blade-Kommentaren; gerenderte Ausgabe prüfen, nicht nur HTTP 200).
### Sicherheit
- **Realtime-Kanäle sind jetzt privat.** Der Live-Metrik-Broadcast (`MetricsTicked`) läuft über
einen `PrivateChannel`, autorisiert über `/broadcasting/auth` (im `web`-Group → PanelScheme +
Session). Damit kann niemand mehr allein mit dem (öffentlich gebündelten) App-Key Infrastruktur-
Metriken abonnieren — auch nicht über einen alten Caddy-Host nach einem Domain-Wechsel. Neue
Konvention (CLAUDE.md §3 / `routes/channels.php`): **jeder** Broadcast-Kanal ist privat.
## [0.4.0] - 2026-06-14 ## [0.4.0] - 2026-06-14
Die Panel-Domain ist wieder aus dem Dashboard änderbar — diesmal sicher: gespeichert wird in der Datenbank, angewendet beim Neustart, das Zertifikat holt Caddy automatisch. Die Panel-Domain ist wieder aus dem Dashboard änderbar — diesmal sicher: gespeichert wird in der Datenbank, angewendet beim Neustart, das Zertifikat holt Caddy automatisch.

View File

@ -67,7 +67,11 @@ dashboard**, connecting **agentless over SSH**. The operator sees and steers the
`Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`. `Provisioner`) orchestrate them; queued long ops go in `app/Jobs/`.
- **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over - **Realtime:** `MetricsPoller` → broadcast events (`app/Events/`, e.g. `MetricsTicked`) over
**Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates **Reverb**; the Dashboard Livewire component receives them (Echo / `wire:stream`) and updates
the chart island. **Mock data first, real SSH after.** the chart island. **Mock data first, real SSH after.** **Convention: every broadcast channel is
PRIVATE** (authenticated via `/broadcasting/auth`) — the app key is bundled into the JS and the
Reverb socket can answer on a stale Caddy host, so a public channel would leak fleet data. Declare
events with `PrivateChannel`/`PresenceChannel` and authorize in `routes/channels.php` (see the
header comment there); per-server channels also scope to records the user may see.
- **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one. - **Open-core:** keep the core clean; Pro features behind separate modules/flags from day one.
--- ---
@ -153,6 +157,8 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
- **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show` - **Naming:** Livewire class `App\Livewire\Servers\Show` ↔ view `livewire.servers.show`
(kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`. (kebab, mirrored path). Components in `app/Livewire/…`, views in `resources/views/livewire/…`.
- **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2) - **Pages:** full-page Livewire components as routes — no page controllers. (R1/R2)
- **Blade:** use block `@php … @endphp` (not inline `@php(...)`); never put the literal tokens
`@php`/`@endphp` in a comment/text — the raw-block matcher eats them. (R17)
- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5) - **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5)
- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer - **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer
PK (`getRouteKeyName(): 'uuid'`). (R11) PK (`getRouteKeyName(): 'uuid'`). (R11)
@ -254,6 +260,7 @@ docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
9. **Reuse** the token set + Blade component kit (R10). 9. **Reuse** the token set + Blade component kit (R10).
10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console 10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console
errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough; errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough;
build + 3 breakpoints; then `git status` for stray secrets, commit on the feature branch. **inspect the rendered DOM** (not just status/console) for leaked `@`/`{{ }}`/`$var`/`group.key`
text — those return 200 too (R17); build + 3 breakpoints; then `git status` for stray secrets, commit.
11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and 11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and
no security issues**. Fix + re-run until clean. The task is not done until Codex passes. no security issues**. Fix + re-run until clean. The task is not done until Codex passes.

View File

@ -2,8 +2,8 @@
namespace App\Events; namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@ -22,9 +22,11 @@ class MetricsTicked implements ShouldBroadcast
public string $server = 'web-01', public string $server = 'web-01',
) {} ) {}
public function broadcastOn(): Channel public function broadcastOn(): PrivateChannel
{ {
return new Channel('metrics'); // PRIVATE: requires /broadcasting/auth (see routes/channels.php). Infrastructure
// metrics must not be subscribable by anyone holding the public app key.
return new PrivateChannel('metrics');
} }
public function broadcastAs(): string public function broadcastAs(): string

View File

@ -31,4 +31,14 @@ class User extends Authenticatable
{ {
return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret); return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret);
} }
/**
* Completed the security onboarding (rotated the seeded password + enrolled 2FA)
* the same gate EnsureSecurityOnboarded enforces before the panel is reachable.
* Authorization (e.g. broadcast channels) must require this, not mere authentication.
*/
public function securityOnboarded(): bool
{
return ! $this->must_change_password && $this->hasTwoFactorEnabled();
}
} }

View File

@ -14,6 +14,12 @@ return Application::configure(basePath: dirname(__DIR__))
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
// Registers the `/broadcasting/auth` endpoint (under the `web` group, so PanelScheme
// guards it) and the private-channel authorization callbacks in routes/channels.php.
// Without this, private/presence channels can never be subscribed to. See R-note in
// routes/channels.php: every broadcast channel is PRIVATE (authenticated) — metrics are
// fleet infrastructure data and must not be reachable on a stale Caddy host via Reverb.
->withBroadcasting(__DIR__.'/../routes/channels.php')
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Trust Caddy's X-Forwarded-* ONLY in production, where the app has no host port // Trust Caddy's X-Forwarded-* ONLY in production, where the app has no host port
// (docker-compose.prod.yml exposes app:80 on the internal net only) so Caddy is // (docker-compose.prod.yml exposes app:80 on the internal net only) so Caddy is

View File

@ -3,7 +3,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash // First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index). // is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.4.0', 'version' => '0.4.1',
// Default user channel. Only 'stable' and 'beta' are ever offered to users. // Default user channel. Only 'stable' and 'beta' are ever offered to users.
'channel' => 'stable', 'channel' => 'stable',

View File

@ -9,6 +9,9 @@ window.Pusher = Pusher;
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {}; const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https'; const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80)); const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80));
// Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session)
// before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
window.Echo = new Echo({ window.Echo = new Echo({
broadcaster: 'reverb', broadcaster: 'reverb',
key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY, key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY,
@ -17,6 +20,7 @@ window.Echo = new Echo({
wssPort: reverbPort, wssPort: reverbPort,
forceTLS: reverbScheme === 'https', forceTLS: reverbScheme === 'https',
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
}); });
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
@ -34,7 +38,8 @@ document.addEventListener('alpine:init', () => {
this.connected = conn?.state === 'connected'; this.connected = conn?.state === 'connected';
this._onState = (s) => { this.connected = s.current === 'connected'; }; this._onState = (s) => { this.connected = s.current === 'connected'; };
conn?.bind('state_change', this._onState); conn?.bind('state_change', this._onState);
this._channel = window.Echo?.channel('metrics'); // PRIVATE channel: authorized via /broadcasting/auth (see routes/channels.php).
this._channel = window.Echo?.private('metrics');
this._channel?.listen('.tick', (e) => { this._channel?.listen('.tick', (e) => {
// only react to the server this chart is showing // only react to the server this chart is showing
if (this.server && e.server && e.server !== this.server) return; if (this.server && e.server && e.server !== this.server) return;
@ -44,6 +49,8 @@ document.addEventListener('alpine:init', () => {
}, },
destroy() { destroy() {
// Echo.leave() tears down the public, private- and presence- variants of the
// name, so this correctly unsubscribes the private-metrics channel.
window.Echo?.leave('metrics'); window.Echo?.leave('metrics');
window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState); window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState);
}, },

View File

@ -1,7 +1,8 @@
@props(['title' => null]) @props(['title' => null])
@php @php
// Block form (not inline @php(...)): an inline @php here would be swallowed by // Default the title in a block (never an inline directive on this line, and never
// Blade's raw-block precompiler up to the @endphp further down this file. // the literal directive tokens in a comment — Blade's raw-block matcher eats them;
// see rules.md R17).
$title ??= __('shell.title_default'); $title ??= __('shell.title_default');
@endphp @endphp
<header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8"> <header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8">

34
routes/channels.php Normal file
View File

@ -0,0 +1,34 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
/*
|--------------------------------------------------------------------------
| Broadcast Channels CONVENTION: every channel is PRIVATE
|--------------------------------------------------------------------------
|
| Clusev is a control plane for a whole fleet; broadcast payloads (live CPU/MEM,
| service state, future per-server streams) are infrastructure data and must NEVER
| ride a PUBLIC channel. A public channel is subscribable by anyone who can reach the
| Reverb endpoint and knows the app key and that key is bundled into the JS
| (VITE_REVERB_APP_KEY), so it is effectively public. With on-demand TLS the `/app/*`
| Reverb socket also answers on any host Caddy still holds a cert for (e.g. an old
| domain after a domain change), so a public channel would leak metrics on a host that
| PanelScheme otherwise refuses.
|
| RULE for new channels: declare every event with a PrivateChannel (or PresenceChannel)
| and register an authorization callback here. The `/broadcasting/auth` endpoint runs in
| the `web` group, so PanelScheme + the session guard gate it a subscriber must be an
| authenticated panel user on the active domain. Per-server channels should additionally
| scope to records the user may see, e.g.:
|
| Broadcast::channel('server.{uuid}', fn (User $u, string $uuid) =>
| Server::where('uuid', $uuid)->exists()); // tighten when RBAC lands
|
*/
// Fleet-wide metrics. Only a panel user who has completed security onboarding (rotated the
// seeded password + enrolled 2FA) may watch — mere authentication is NOT enough; this mirrors
// the EnsureSecurityOnboarded gate on panel routes (metrics are not per-user).
Broadcast::channel('metrics', fn (User $user) => $user->securityOnboarded());

View File

@ -499,6 +499,24 @@ Adding a string in only one language, or omitting a key from `en`/`de`, is a rul
--- ---
## R17 — Blade: block `@php`; never the literal directive tokens in a comment
Blade's raw-block precompiler matches `@php … @endphp` **non-greedily on the FIRST `@endphp`**.
- **Never** write the literal tokens `@php`/`@endphp` (or `@verbatim`/`@endverbatim`) inside a
Blade **comment or text** in a file that also has a real `@php` block — the matcher takes the
token in your comment as the block end and dumps the remainder as raw text. (This shipped a
garbled `<header>` on every signed-in page.) To mention a directive, break it (`@php`) or word
it ("the php directive").
- Use the **block** `@php … @endphp`, not **inline** `@php(...)`: inline silently mis-compiles
when the expression nests parens/calls (e.g. `@php($x ??= __('a.b'))`) and collides with any
later block in the same file.
- **Verify the rendered DOM, not just HTTP 200** (extends R12): leaked template text and missing
translations both return 200 with no console error. R12 must inspect the actual visible markup
(`<header>`/body) for stray `@`, `{{ }}`, `$var`, or `group.key` literals.
---
## Appendix — Secret hygiene ## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in