diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df1c34..45b6606 100644 --- a/CHANGELOG.md +++ b/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 (fehlge­schlagene + 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 diff --git a/app/Livewire/Audit/Index.php b/app/Livewire/Audit/Index.php index 51ff6ef..498c049 100644 --- a/app/Livewire/Audit/Index.php +++ b/app/Livewire/Audit/Index.php @@ -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, ]))); diff --git a/app/Livewire/Wireguard/Index.php b/app/Livewire/Wireguard/Index.php index b25ea2e..49db603 100644 --- a/app/Livewire/Wireguard/Index.php +++ b/app/Livewire/Wireguard/Index.php @@ -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')); diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index c83f862..b501fe4 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -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)); + } } diff --git a/config/clusev.php b/config/clusev.php index 771a0a9..1697ebe 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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 diff --git a/lang/de/audit.php b/lang/de/audit.php index 1d57e3c..0e6fdf4 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -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', ]; diff --git a/lang/en/audit.php b/lang/en/audit.php index 9302954..f867b00 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -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', ]; diff --git a/resources/views/livewire/audit/index.blade.php b/resources/views/livewire/audit/index.blade.php index 8c8138d..373d5d8 100644 --- a/resources/views/livewire/audit/index.blade.php +++ b/resources/views/livewire/audit/index.blade.php @@ -69,13 +69,19 @@
@forelse ($events as $e)
- - + $e->is_error, + 'bg-raised text-ink-3' => ! $e->is_error, + ])> +

- {{ $e->actor }} · {{ $e->action }} + {{ $e->actor }} + · + $e->is_error, 'text-ink' => ! $e->is_error])>{{ $e->action_label }}

@if ($e->target)

{{ $e->target }}

diff --git a/tests/Feature/AuditLogDisplayTest.php b/tests/Feature/AuditLogDisplayTest.php new file mode 100644 index 0000000..2f2e7f9 --- /dev/null +++ b/tests/Feature/AuditLogDisplayTest.php @@ -0,0 +1,61 @@ +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'); + } +} diff --git a/tests/Feature/WireguardPageTest.php b/tests/Feature/WireguardPageTest.php index de9cb58..e8bb994 100644 --- a/tests/Feature/WireguardPageTest.php +++ b/tests/Feature/WireguardPageTest.php @@ -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.