fix: alert mail on queue, SMTP reset, cert/uptime auto-probe, pill heights, XSS

Batch of user-reported fixes + two findings from the nav-split audit.

Bugs:
- Alerts never e-mailed. AppServiceProvider::applyMailSettings() ran only
  at boot; the long-lived QUEUE worker booted once, so SMTP configured
  after worker start (the normal case) meant queued alert/reset mail used
  the stale/log mailer. Re-apply the DB SMTP config before every job
  (Queue::before) so queued mail always sends with the current settings.
- SMTP could not be fully reset. Add Email::clearConfig() (+ a Reset
  button + Setting::forget) that removes every mail_* setting, reverting
  the panel to the safe default (log) mailer.
- New cert endpoint / uptime check showed no validity/status until a
  manual refresh. Certs\Index::addEndpoint + Health\Index::addCheck now
  probe the just-added row immediately.

Audit findings (096ba3c..HEAD):
- HIGH stored/DOM XSS: the per-server Terminal tab rendered the server
  name inside an Alpine x-text JS sink (`x-text="title || '{{ name }}'"`);
  Blade escaping does not protect that context. Move the fallback to a
  data-attribute read as a plain string (server-terminal + the host
  terminal for consistency).
- LOW: /terminal is now admin-only, but the command-palette `g t` leader
  key still sent every role there → 403. Drop it from the static map;
  render it as a role-filtered nav entry (admins only).

UI:
- status-pill is h-8 now (the small-button height) so a status pill and
  the action buttons in the same row line up (Services/Certs/Docker/…).

748 tests (new clearConfig test); R12-verified (uniform pill/button
heights). Marketing docs at ~/clusev-site reworded to remove the
contradictory domain/CLUSEV_DOMAIN install note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 19:46:46 +02:00
parent b4c309bd54
commit 4d349eebb9
14 changed files with 137 additions and 18 deletions

View File

@ -79,7 +79,7 @@ class Index extends Component
$this->ready = true;
}
public function addEndpoint(): void
public function addEndpoint(CertService $certs): void
{
$this->gate();
@ -90,8 +90,19 @@ class Index extends Component
'port' => ['required', 'integer', 'min:1', 'max:65535'],
]);
CertEndpoint::create($data);
$ep = CertEndpoint::create($data);
$this->audit('cert.endpoint_add', $data['host'].':'.$data['port']);
// Probe the new endpoint right away so its validity shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$res = $certs->check($ep->host, $ep->port);
$res['status'] = $res['ok'] ? $certs->status($res['daysLeft'] ?? null) : 'error';
$this->checks[$ep->uuid] = $res;
} catch (Throwable $e) {
$this->checks[$ep->uuid] = ['ok' => false, 'status' => 'error', 'error' => $e->getMessage()];
}
$this->reset('label', 'host');
$this->port = 443;
$this->dispatch('notify', message: __('certs.added', ['host' => $data['host']]));

View File

@ -79,7 +79,7 @@ class Index extends Component
$this->ready = true;
}
public function addCheck(): void
public function addCheck(HealthService $health): void
{
$this->gate();
@ -98,7 +98,7 @@ class Index extends Component
$data = $this->validate($rules);
HealthCheck::create([
$check = HealthCheck::create([
'label' => $data['label'] ?? null,
'type' => $data['type'],
'target' => $data['target'],
@ -106,6 +106,15 @@ class Index extends Component
]);
$this->audit('health.check_add', $data['target']);
// Probe the new check right away so its status/latency shows immediately (not only after a
// manual refresh / the next scheduled scan).
try {
$this->results[$check->uuid] = $health->probe($check);
} catch (Throwable $e) {
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
}
$this->reset('label', 'target', 'port');
$this->type = 'http';
$this->dispatch('notify', message: __('health.added'));

View File

@ -99,6 +99,38 @@ class Email extends Component
$this->dispatch('notify', message: __('mail.notify_saved'));
}
/**
* Fully clear the SMTP configuration removes every mail_* setting (incl. the encrypted
* password) so the panel reverts to the safe default (log) mailer. Reversible: just reconfigure.
*/
public function clearConfig(): void
{
abort_unless(auth()->user()?->can('manage-panel'), 403);
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
Setting::forget($key);
}
$this->mail_host = '';
$this->mail_port = 587;
$this->mail_username = '';
$this->mail_password = '';
$this->mail_encryption = 'tls';
$this->mail_from_address = '';
$this->mail_from_name = '';
$this->passwordSet = false;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()->name,
'action' => 'mail.reset',
'target' => '—',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('mail.notify_reset'));
}
/**
* Send a one-line test mail to the CURRENT user's own address, using a mailer built
* from the saved settings. Never targets anyone else; never leaks the password any

View File

@ -31,4 +31,11 @@ class Setting extends Model
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget("setting:{$key}");
}
/** Remove a setting entirely (so get() falls back to its default). */
public static function forget(string $key): void
{
static::query()->whereKey($key)->delete();
Cache::forget("setting:{$key}");
}
}

View File

@ -11,6 +11,7 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
@ -89,6 +90,13 @@ class AppServiceProvider extends ServiceProvider
}
$this->applyMailSettings();
// The queue worker is a LONG-LIVED process: boot() runs once at startup, so a mail-config
// change (or SMTP first configured after the worker started — the normal case) would never
// reach queued mail, and a fired alert's e-mail would silently use the stale/log mailer.
// Re-apply the DB SMTP settings before every job so queued alert/reset mail always sends
// with the current config.
Queue::before(fn () => $this->applyMailSettings());
}
/**

View File

@ -26,6 +26,7 @@ return [
// Aktionen
'save' => 'Speichern',
'test_send' => 'Testmail senden',
'reset' => 'Zurücksetzen',
// Testmail-Inhalt
'test_subject' => 'Clusev — Testmail',
@ -33,6 +34,7 @@ return [
// Benachrichtigungen
'notify_saved' => 'SMTP-Einstellungen gespeichert.',
'notify_reset' => 'SMTP-Konfiguration zurückgesetzt.',
'notify_test_ok' => 'Testmail an :email gesendet.',
'notify_test_failed' => 'Testmail fehlgeschlagen: :error',
'notify_test_throttled' => 'Zu viele Testmails bitte in :seconds Sekunden erneut.',

View File

@ -26,6 +26,7 @@ return [
// Actions
'save' => 'Save',
'test_send' => 'Send test mail',
'reset' => 'Reset',
// Test mail content
'test_subject' => 'Clusev — test mail',
@ -33,6 +34,7 @@ return [
// Notifications
'notify_saved' => 'SMTP settings saved.',
'notify_reset' => 'SMTP configuration reset.',
'notify_test_ok' => 'Test mail sent to :email.',
'notify_test_failed' => 'Test mail failed: :error',
'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.',

View File

@ -527,7 +527,10 @@ document.addEventListener('alpine:init', () => {
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
// `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go()
// so it can point at the external docs URL instead of an in-app route.
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', t: '/terminal' };
// NOTE: 't' (Terminal) is deliberately NOT here — /terminal is admin-only (manage-fleet), so it
// is a ROLE-FILTERED entry in the server-rendered nav list instead; go() resolves it there only
// when the user actually has it, so lower roles never dead-end on a 403.
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
nav,

View File

@ -31,6 +31,13 @@
['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'],
];
// Terminal is admin-only (manage-fleet) — add its "g t" chord + palette entry ONLY for admins,
// so lower roles neither see it nor dead-end on the now-gated /terminal route.
if (auth()->user()?->can('manage-fleet')) {
$chords[] = ['g t', __('shell.nav_terminal')];
$nav[] = ['label' => __('shell.nav_terminal'), 'href' => '/terminal', 'hint' => 'g t'];
}
$actions = [
['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''],
];

View File

@ -7,7 +7,8 @@
'pending' => 'text-cyan border-cyan/20 bg-cyan/10',
][$status] ?? 'text-ink-3 border-line bg-line';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}>
{{-- h-8 = the small-button height, so a status pill and the action buttons in the same row line up. --}}
<span {{ $attributes->merge(['class' => "inline-flex h-8 items-center gap-1.5 rounded-sm border px-2.5 font-mono text-xs $c"]) }}>
<x-status-dot :status="$status" :ping="in_array($status, ['online', 'pending'], true)" />
{{ $slot }}
</span>

View File

@ -10,7 +10,10 @@
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
</span>
<span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ $server->name }}'"></span>
{{-- Server name via a data-attr, NEVER interpolated into the Alpine expression: Blade
escaping does not protect an x-text JS sink (the browser entity-decodes before
Alpine evals), so a name like x'+alert(1)+' would execute. dataset = plain string. --}}
<span class="truncate font-mono text-[12px] text-ink-2" data-fallback="{{ $server->name }}" x-text="title || $el.dataset.fallback"></span>
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
<span class="h-1.5 w-1.5 rounded-full"
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>

View File

@ -60,16 +60,25 @@
</div>
</div>
<div class="flex flex-wrap justify-end gap-2 border-t border-line pt-4">
<x-btn variant="secondary" type="button" wire:click="sendTest" wire:target="sendTest" wire:loading.attr="disabled"
:disabled="! $this->configured()">
<svg wire:loading wire:target="sendTest" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('mail.test_send') }}
</x-btn>
<x-btn variant="primary" type="submit" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}
</x-btn>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-line pt-4">
<div>
@if ($this->configured())
<x-btn variant="danger-soft" type="button" wire:click="clearConfig" wire:target="clearConfig" wire:loading.attr="disabled">
{{ __('mail.reset') }}
</x-btn>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-btn variant="secondary" type="button" wire:click="sendTest" wire:target="sendTest" wire:loading.attr="disabled"
:disabled="! $this->configured()">
<svg wire:loading wire:target="sendTest" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('mail.test_send') }}
</x-btn>
<x-btn variant="primary" type="submit" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('common.save') }}
</x-btn>
</div>
</div>
</form>
</x-panel>

View File

@ -36,7 +36,7 @@
<span class="h-2.5 w-2.5 rounded-full bg-warning/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-online/70"></span>
</span>
<span class="truncate font-mono text-[12px] text-ink-2" x-text="title || '{{ __('terminal.host_label') }}'"></span>
<span class="truncate font-mono text-[12px] text-ink-2" data-fallback="{{ __('terminal.host_label') }}" x-text="title || $el.dataset.fallback"></span>
<span class="ml-auto flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]">
<span class="h-1.5 w-1.5 rounded-full"
:class="{ 'bg-ink-4': status==='idle', 'bg-warning': status==='connecting', 'bg-online': status==='connected', 'bg-offline': status==='error'||status==='closed' }"></span>

View File

@ -56,6 +56,31 @@ class SmtpConfigTest extends TestCase
$this->assertDatabaseHas('audit_events', ['action' => 'mail.configure', 'target' => 'smtp.example.com']);
}
public function test_clear_config_removes_all_mail_settings(): void
{
$this->actingUser();
Setting::put('mail_host', 'smtp.example.com');
Setting::put('mail_port', '2525');
Setting::put('mail_username', 'panel');
Setting::put('mail_password', Crypt::encryptString('s3cr3t-pw'));
Setting::put('mail_encryption', 'ssl');
Setting::put('mail_from_address', 'panel@example.com');
Setting::put('mail_from_name', 'Clusev');
Livewire::test(Email::class)
->assertSet('mail_host', 'smtp.example.com') // mounted from the stored value
->call('clearConfig')
->assertHasNoErrors()
->assertSet('mail_host', '')
->assertSet('mail_from_address', '')
->assertSet('passwordSet', false);
foreach (['mail_host', 'mail_port', 'mail_username', 'mail_password', 'mail_encryption', 'mail_from_address', 'mail_from_name'] as $key) {
$this->assertNull(Setting::get($key), "$key must be cleared");
}
$this->assertDatabaseHas('audit_events', ['action' => 'mail.reset']);
}
public function test_password_is_not_overwritten_when_left_blank(): void
{
$this->actingUser();