feat(audit): readable action labels + failures surfaced (v0.9.48)
The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.
Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.
Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
v0.9.48
parent
c89b28e1ef
commit
e154f7d92f
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -13,7 +13,19 @@ 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._
|
||||
|
||||
## [0.9.47] - 2026-06-21
|
||||
## [0.9.48] - 2026-06-21
|
||||
|
||||
### Geändert
|
||||
- **Audit-Log lesbar.** Statt der rohen Aktions-Codes (z. B. „Administrator · wg.set-endpoint") zeigt
|
||||
das Audit-Log jetzt verständliche Beschreibungen („Administrator · WireGuard-Endpoint geändert").
|
||||
Alle Aktionen sind übersetzt (DE/EN); unbekannte/dynamische Codes werden sauber aufbereitet. Die
|
||||
Suche findet auch die lesbaren Bezeichnungen.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Fehler im Audit-Log sichtbar.** Fehlgeschlagene oder sicherheitsrelevante Ereignisse (fehlgeschlagene
|
||||
Anmeldung, IP-Sperre, fehlgeschlagene 2FA …) werden jetzt **rot mit Warn-Symbol** hervorgehoben.
|
||||
Zusätzlich landen **fehlgeschlagene WireGuard-Aktionen** mit der Host-Fehlermeldung im Audit-Log
|
||||
(statt nur als kurzer Hinweis), sodass man später nachlesen kann, was schiefging.
|
||||
|
||||
### Geändert
|
||||
- **Endpoint-Feld ist jetzt „nur Host".** Es wirkte, als müsse man den Port zweimal eintragen (einmal
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ class Index extends Component
|
|||
$haystack = mb_strtolower(implode(' ', array_filter([
|
||||
$e->actor,
|
||||
$e->action,
|
||||
$e->action_label,
|
||||
$e->target,
|
||||
$e->server?->name,
|
||||
])));
|
||||
|
|
|
|||
|
|
@ -362,6 +362,8 @@ class Index extends Component
|
|||
$this->resultName = $this->pendingName;
|
||||
} elseif (! $res['ok']) {
|
||||
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
|
||||
// Persist the failure so it's readable later in the audit log, not just a transient toast.
|
||||
$this->audit('wg.action-failed', trim(($this->pendingAction ?? '').': '.$msg));
|
||||
$this->dispatch('notify', message: $msg, level: 'error');
|
||||
} else {
|
||||
$this->dispatch('notify', message: __('wireguard.action_done'));
|
||||
|
|
|
|||
|
|
@ -31,4 +31,35 @@ class AuditEvent extends Model
|
|||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/** Actions that record a failure or security event — surfaced with a warning style. */
|
||||
private const ERROR_ACTIONS = [
|
||||
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
|
||||
'fail2ban.ban', 'wg.action-failed',
|
||||
];
|
||||
|
||||
/**
|
||||
* Human-readable, localized label for the raw action code (e.g. "wg.set-endpoint" ->
|
||||
* "WireGuard-Endpoint geändert"). Falls back to a tidied form for unmapped/dynamic codes
|
||||
* (e.g. the dynamic "harden.*.on|off") so nothing ever shows a bare machine code awkwardly.
|
||||
*/
|
||||
public function getActionLabelAttribute(): string
|
||||
{
|
||||
$labels = (array) trans('audit.actions');
|
||||
|
||||
return $labels[$this->action] ?? $this->prettyAction();
|
||||
}
|
||||
|
||||
/** True when the entry represents a failure / security alert (drives the warning styling). */
|
||||
public function getIsErrorAttribute(): bool
|
||||
{
|
||||
return in_array($this->action, self::ERROR_ACTIONS, true);
|
||||
}
|
||||
|
||||
private function prettyAction(): string
|
||||
{
|
||||
$s = str_replace(['_', '-'], ' ', str_replace('.', ' · ', (string) $this->action));
|
||||
|
||||
return Str::ucfirst(trim($s));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
||||
'version' => '0.9.47',
|
||||
'version' => '0.9.48',
|
||||
|
||||
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
|
||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||
|
|
|
|||
|
|
@ -28,6 +28,49 @@ return [
|
|||
'retention_saved_notify' => 'Aufbewahrungsrichtlinie gespeichert.',
|
||||
'retention_hint' => 'Anzahl der Tage (1–3650). Leer lassen für unbegrenzte Aufbewahrung. Ältere Einträge werden täglich gelöscht.',
|
||||
|
||||
// Human-readable labels for the raw action codes shown in the event list.
|
||||
'actions' => [
|
||||
'audit.retention_set' => 'Aufbewahrung geändert',
|
||||
'auth.login_failed' => 'Fehlgeschlagene Anmeldung',
|
||||
'auth.2fa_failed' => 'Fehlgeschlagene 2FA-Prüfung',
|
||||
'auth.ip_banned' => 'IP-Adresse gesperrt',
|
||||
'auth.ip_unbanned' => 'IP-Sperre aufgehoben',
|
||||
'auth.protection_updated' => 'Anmeldeschutz geändert',
|
||||
'deploy.update_request' => 'Update angefordert',
|
||||
'fail2ban.ban' => 'fail2ban: IP gesperrt',
|
||||
'fail2ban.unban' => 'fail2ban: IP entsperrt',
|
||||
'fail2ban.configure' => 'fail2ban konfiguriert',
|
||||
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
|
||||
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
|
||||
'file.edit' => 'Datei bearbeitet',
|
||||
'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
|
||||
'firewall.rule_delete' => 'Firewall-Regel gelöscht',
|
||||
'mail.configure' => 'E-Mail (SMTP) konfiguriert',
|
||||
'password.change' => 'Passwort geändert',
|
||||
'password.reset' => 'Passwort zurückgesetzt',
|
||||
'profile.update' => 'Profil aktualisiert',
|
||||
'server.create' => 'Server angelegt',
|
||||
'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert',
|
||||
'ssh_key.add' => 'SSH-Schlüssel hinzugefügt',
|
||||
'system.package_upgrade' => 'Paket aktualisiert',
|
||||
'system.settings_updated' => 'Systemeinstellungen geändert',
|
||||
'tls.cert_request' => 'TLS-Zertifikat angefordert',
|
||||
'two_factor.recovery_regenerate' => 'Backup-Codes neu erzeugt',
|
||||
'user.create' => 'Benutzer angelegt',
|
||||
'user.delete' => 'Benutzer gelöscht',
|
||||
'user.logout' => 'Sitzung beendet',
|
||||
'webauthn.register' => 'Sicherheitsschlüssel registriert',
|
||||
'webauthn.remove' => 'Sicherheitsschlüssel entfernt',
|
||||
'wg.setup' => 'WireGuard eingerichtet',
|
||||
'wg.add-peer' => 'WireGuard-Peer angelegt',
|
||||
'wg.remove-peer' => 'WireGuard-Peer entfernt',
|
||||
'wg.set-endpoint' => 'WireGuard-Endpoint geändert',
|
||||
'wg.set-port' => 'WireGuard-Port geändert',
|
||||
'wg.set-subnet' => 'WireGuard-Subnetz geändert',
|
||||
'wg.set-dns' => 'WireGuard-DNS geändert',
|
||||
'wg.action-failed' => 'WireGuard-Aktion fehlgeschlagen',
|
||||
],
|
||||
|
||||
// Page title
|
||||
'title' => 'Audit-Log — Clusev',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,49 @@ return [
|
|||
'retention_saved_notify' => 'Retention policy saved.',
|
||||
'retention_hint' => 'Number of days (1–3650). Leave empty to keep entries forever. Older entries are deleted daily.',
|
||||
|
||||
// Human-readable labels for the raw action codes shown in the event list.
|
||||
'actions' => [
|
||||
'audit.retention_set' => 'Retention changed',
|
||||
'auth.login_failed' => 'Failed sign-in',
|
||||
'auth.2fa_failed' => 'Failed 2FA check',
|
||||
'auth.ip_banned' => 'IP address banned',
|
||||
'auth.ip_unbanned' => 'IP ban lifted',
|
||||
'auth.protection_updated' => 'Login protection changed',
|
||||
'deploy.update_request' => 'Update requested',
|
||||
'fail2ban.ban' => 'fail2ban: IP banned',
|
||||
'fail2ban.unban' => 'fail2ban: IP unbanned',
|
||||
'fail2ban.configure' => 'fail2ban configured',
|
||||
'fail2ban.ignoreip_add' => 'fail2ban: exception added',
|
||||
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
|
||||
'file.edit' => 'File edited',
|
||||
'firewall.rule_add' => 'Firewall rule added',
|
||||
'firewall.rule_delete' => 'Firewall rule deleted',
|
||||
'mail.configure' => 'E-mail (SMTP) configured',
|
||||
'password.change' => 'Password changed',
|
||||
'password.reset' => 'Password reset',
|
||||
'profile.update' => 'Profile updated',
|
||||
'server.create' => 'Server added',
|
||||
'server.credential_updated' => 'SSH credentials updated',
|
||||
'ssh_key.add' => 'SSH key added',
|
||||
'system.package_upgrade' => 'Package upgraded',
|
||||
'system.settings_updated' => 'System settings changed',
|
||||
'tls.cert_request' => 'TLS certificate requested',
|
||||
'two_factor.recovery_regenerate' => 'Backup codes regenerated',
|
||||
'user.create' => 'User created',
|
||||
'user.delete' => 'User deleted',
|
||||
'user.logout' => 'Session ended',
|
||||
'webauthn.register' => 'Security key registered',
|
||||
'webauthn.remove' => 'Security key removed',
|
||||
'wg.setup' => 'WireGuard set up',
|
||||
'wg.add-peer' => 'WireGuard peer added',
|
||||
'wg.remove-peer' => 'WireGuard peer removed',
|
||||
'wg.set-endpoint' => 'WireGuard endpoint changed',
|
||||
'wg.set-port' => 'WireGuard port changed',
|
||||
'wg.set-subnet' => 'WireGuard subnet changed',
|
||||
'wg.set-dns' => 'WireGuard DNS changed',
|
||||
'wg.action-failed' => 'WireGuard action failed',
|
||||
],
|
||||
|
||||
// Page title
|
||||
'title' => 'Audit log — Clusev',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -69,13 +69,19 @@
|
|||
<div class="divide-y divide-line">
|
||||
@forelse ($events as $e)
|
||||
<div class="flex items-start gap-3 px-4 py-3 sm:px-5">
|
||||
<span class="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
|
||||
<x-icon name="audit" class="h-3.5 w-3.5" />
|
||||
<span @class([
|
||||
'mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm',
|
||||
'bg-offline/10 text-offline' => $e->is_error,
|
||||
'bg-raised text-ink-3' => ! $e->is_error,
|
||||
])>
|
||||
<x-icon :name="$e->is_error ? 'alert' : 'audit'" class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-ink">
|
||||
<span class="text-ink-2">{{ $e->actor }}</span> · {{ $e->action }}
|
||||
<span class="text-ink-2">{{ $e->actor }}</span>
|
||||
<span class="text-ink-4">·</span>
|
||||
<span @class(['font-medium', 'text-offline' => $e->is_error, 'text-ink' => ! $e->is_error])>{{ $e->action_label }}</span>
|
||||
</p>
|
||||
@if ($e->target)
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Audit\Index;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuditLogDisplayTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||
}
|
||||
|
||||
public function test_action_label_maps_a_known_code(): void
|
||||
{
|
||||
// Default locale is German; the raw code maps to a readable label.
|
||||
$this->assertSame('WireGuard-Endpoint geändert', (new AuditEvent(['action' => 'wg.set-endpoint']))->action_label);
|
||||
}
|
||||
|
||||
public function test_action_label_falls_back_to_a_tidied_form_for_unknown_codes(): void
|
||||
{
|
||||
// Dynamic harden.* codes are not individually mapped → tidied, never a bare machine code.
|
||||
$e = new AuditEvent(['action' => 'harden.ssh_password.off']);
|
||||
$this->assertSame('Harden · ssh password · off', $e->action_label);
|
||||
}
|
||||
|
||||
public function test_is_error_flags_failure_and_security_events(): void
|
||||
{
|
||||
$this->assertTrue((new AuditEvent(['action' => 'auth.login_failed']))->is_error);
|
||||
$this->assertTrue((new AuditEvent(['action' => 'wg.action-failed']))->is_error);
|
||||
$this->assertFalse((new AuditEvent(['action' => 'wg.set-endpoint']))->is_error);
|
||||
}
|
||||
|
||||
public function test_page_renders_the_readable_label_not_the_raw_code(): void
|
||||
{
|
||||
AuditEvent::create(['actor' => 'Administrator', 'action' => 'wg.set-endpoint', 'target' => '1.2.3.4:51820', 'ip' => '127.0.0.1']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('WireGuard-Endpoint geändert')
|
||||
->assertDontSee('wg.set-endpoint');
|
||||
}
|
||||
|
||||
public function test_search_matches_the_readable_label(): void
|
||||
{
|
||||
AuditEvent::create(['actor' => 'Administrator', 'action' => 'wg.set-endpoint', 'target' => 'x', 'ip' => '127.0.0.1']);
|
||||
AuditEvent::create(['actor' => 'Administrator', 'action' => 'user.create', 'target' => 'y', 'ip' => '127.0.0.1']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('q', 'Endpoint geändert')
|
||||
->assertSee('WireGuard-Endpoint geändert')
|
||||
->assertDontSee('Benutzer angelegt');
|
||||
}
|
||||
}
|
||||
|
|
@ -284,6 +284,20 @@ class WireguardPageTest extends TestCase
|
|||
->assertFileDownloaded('clusev.conf');
|
||||
}
|
||||
|
||||
public function test_poll_result_audits_a_failure_so_it_is_readable_later(): void
|
||||
{
|
||||
$c = Livewire::test(Index::class)->set('newEndpoint', 'vpn.example.com')->call('setEndpoint');
|
||||
$id = $c->get('pendingId');
|
||||
file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => false, 'message' => 'set-endpoint-failed', 'config' => null, 'at' => time()]));
|
||||
|
||||
$c->call('pollResult');
|
||||
|
||||
$e = AuditEvent::where('action', 'wg.action-failed')->first();
|
||||
$this->assertNotNull($e);
|
||||
$this->assertStringContainsString('set-endpoint-failed', (string) $e->target);
|
||||
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
|
||||
}
|
||||
|
||||
public function test_poll_result_times_out_when_the_host_does_not_respond(): void
|
||||
{
|
||||
// A pending request with no host result for >30s must stop spinning, not poll forever.
|
||||
|
|
|
|||
Loading…
Reference in New Issue