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
parent
a44e29426a
commit
f5f903046d
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
18
rules.md
18
rules.md
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue