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
parent
b4c309bd54
commit
4d349eebb9
|
|
@ -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']]));
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' => ''],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2 border-t border-line pt-4">
|
||||
<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>
|
||||
|
|
@ -71,6 +79,7 @@
|
|||
{{ __('common.save') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue