diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d7e5d..f0021d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,27 @@ 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.3.0] - 2026-06-13 + +Zweisprachige Oberfläche: die gesamte UI gibt es jetzt auf Deutsch und Englisch, frei umschaltbar pro Konto. + +### Hinzugefügt +- **Sprache wählbar (Deutsch / Englisch).** Ein DE/EN-Umschalter (oben rechts, sowie auf der + Login-Seite) stellt die Sprache um; die Wahl wird pro Konto gespeichert und gilt sofort für + die gesamte Oberfläche. Ohne Wahl greift die Standardsprache (Deutsch). +- Übersetzungen liegen strukturiert unter `lang/{de,en}/.php` — eine Gruppe je Feature + plus geteiltes `common`, Schlüssel in beiden Sprachen identisch. + +### Geändert +- **Alle sichtbaren Texte sind lokalisiert.** Views, Komponenten und Backend-Services geben + Text ausschließlich über `__('gruppe.schlüssel')` aus, nie mehr fest verdrahtet — neue, + verbindliche Regel **R16**. Native Technik-Begriffe (`nginx.service`, `SSH`, `2FA`, Ports) + bleiben unverändert. + +### Entfernt +- Erläuternde „Warum …?"-Texte auf der System-Seite — die Seite zeigt nur noch Funktion und + Status. + ## [0.2.1] - 2026-06-13 Verfeinerungen an 0.2.0: vollautomatisches TLS ohne sichtbare Konfiguration und einheitliche Buttons. diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 56edc26..832232c 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -131,6 +131,6 @@ class Dashboard extends Component 'loadSub' => $cores.' '.($cores === 1 ? __('dashboard.core') : __('dashboard.cores')), 'services' => $this->svcRows, 'events' => AuditEvent::with('server')->latest()->limit(6)->get(), - ]); + ])->title($this->title()); } } diff --git a/app/Livewire/Modals/AddSshKey.php b/app/Livewire/Modals/AddSshKey.php index bae56af..8c25c11 100644 --- a/app/Livewire/Modals/AddSshKey.php +++ b/app/Livewire/Modals/AddSshKey.php @@ -50,7 +50,7 @@ class AddSshKey extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -73,7 +73,7 @@ class AddSshKey extends ModalComponent ]); $this->dispatch('keyChanged'); - $this->dispatch('notify', message: 'SSH-Schlüssel hinzugefügt.'); + $this->dispatch('notify', message: __('modals.add_ssh_key.notify_added')); $this->closeModal(); } diff --git a/app/Livewire/Modals/ConfirmAction.php b/app/Livewire/Modals/ConfirmAction.php index 0970abc..9f30f36 100644 --- a/app/Livewire/Modals/ConfirmAction.php +++ b/app/Livewire/Modals/ConfirmAction.php @@ -16,11 +16,11 @@ use LivewireUI\Modal\ModalComponent; */ class ConfirmAction extends ModalComponent { - public string $heading = 'Aktion bestätigen'; + public string $heading = ''; public string $body = ''; - public string $confirmLabel = 'Bestätigen'; + public string $confirmLabel = ''; public bool $danger = false; @@ -40,14 +40,29 @@ class ConfirmAction extends ModalComponent public array $params = []; /** Toast shown on confirm. Pass '' to defer the notification to the handler - * (e.g. when the real outcome is only known after a remote command runs). */ - public string $notify = 'Aktion ausgeführt.'; + * (e.g. when the real outcome is only known after a remote command runs); + * leave null to fall back to the generic default toast. */ + public ?string $notify = null; public static function modalMaxWidth(): string { return 'md'; } + /** Apply localized fallbacks for any copy the opener did not pass. */ + public function boot(): void + { + if ($this->heading === '') { + $this->heading = __('modals.confirm_action.default_heading'); + } + if ($this->confirmLabel === '') { + $this->confirmLabel = __('common.confirm'); + } + if ($this->notify === null) { + $this->notify = __('modals.confirm_action.default_notify'); + } + } + public function confirm(): void { if ($this->auditAction !== '') { diff --git a/app/Livewire/Modals/CreateServer.php b/app/Livewire/Modals/CreateServer.php index 813921f..541a06a 100644 --- a/app/Livewire/Modals/CreateServer.php +++ b/app/Livewire/Modals/CreateServer.php @@ -54,7 +54,7 @@ class CreateServer extends ModalComponent $numericDotted = preg_match('/^[0-9.]+$/', $value) === 1; $isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1; if (! $isIp && ! $isHost) { - $fail('Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.'); + $fail(__('modals.create_server.validation_ip_or_host')); } }], 'sshPort' => ['required', 'integer', 'min:1', 'max:65535'], @@ -72,13 +72,13 @@ class CreateServer extends ModalComponent protected function validationAttributes(): array { return [ - 'name' => 'Name', - 'ip' => 'IP/Host', - 'sshPort' => 'SSH-Port', - 'username' => 'Benutzer', - 'authType' => 'Authentifizierung', - 'secret' => $this->authType === 'key' ? 'Privater Schlüssel' : 'Passwort', - 'credentialName' => 'Zugangs-Name', + 'name' => __('modals.create_server.attr_name'), + 'ip' => __('modals.create_server.attr_ip'), + 'sshPort' => __('modals.create_server.attr_ssh_port'), + 'username' => __('modals.create_server.attr_user'), + 'authType' => __('modals.create_server.attr_auth'), + 'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'), + 'credentialName' => __('modals.create_server.attr_credential_name'), ]; } @@ -117,7 +117,7 @@ class CreateServer extends ModalComponent }); $this->dispatch('serverCreated'); - $this->dispatch('notify', message: 'Server hinzugefügt.'); + $this->dispatch('notify', message: __('modals.create_server.notify_added')); $this->closeModal(); } diff --git a/app/Livewire/Modals/EditCredential.php b/app/Livewire/Modals/EditCredential.php index 2978e4e..ee6ca10 100644 --- a/app/Livewire/Modals/EditCredential.php +++ b/app/Livewire/Modals/EditCredential.php @@ -50,17 +50,17 @@ class EditCredential extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } if (trim($this->username) === '' || trim($this->secret) === '') { - $this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.'; + $this->error = __('modals.edit_credential.error_required'); return; } if (mb_strlen(trim($this->name)) > 120) { - $this->error = 'Name darf höchstens 120 Zeichen lang sein.'; + $this->error = __('modals.edit_credential.error_name_too_long'); return; } @@ -84,7 +84,7 @@ class EditCredential extends ModalComponent ]); $this->dispatch('credentialChanged'); - $this->dispatch('notify', message: 'Zugangsdaten gespeichert.'); + $this->dispatch('notify', message: __('modals.edit_credential.notify_saved')); $this->closeModal(); } diff --git a/app/Livewire/Modals/Fail2banBan.php b/app/Livewire/Modals/Fail2banBan.php index 27f1590..00537ea 100644 --- a/app/Livewire/Modals/Fail2banBan.php +++ b/app/Livewire/Modals/Fail2banBan.php @@ -48,7 +48,7 @@ class Fail2banBan extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -62,7 +62,7 @@ class Fail2banBan extends ModalComponent } if (! $res['ok']) { - $this->error = $res['output'] !== '' ? $res['output'] : 'Sperren fehlgeschlagen.'; + $this->error = $res['output'] !== '' ? $res['output'] : __('modals.fail2ban_ban.error_ban_failed'); return; } @@ -77,7 +77,7 @@ class Fail2banBan extends ModalComponent ]); $this->dispatch('fail2banChanged'); - $this->dispatch('notify', message: 'IP '.trim($this->ip).' gesperrt.'); + $this->dispatch('notify', message: __('modals.fail2ban_ban.notify_banned', ['ip' => trim($this->ip)])); $this->closeModal(); } diff --git a/app/Livewire/Modals/Fail2banConfig.php b/app/Livewire/Modals/Fail2banConfig.php index 06e1752..2745759 100644 --- a/app/Livewire/Modals/Fail2banConfig.php +++ b/app/Livewire/Modals/Fail2banConfig.php @@ -39,7 +39,7 @@ class Fail2banConfig extends ModalComponent $server = Server::find($serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -67,7 +67,7 @@ class Fail2banConfig extends ModalComponent // Never overwrite the remote policy with defaults the operator never saw — // saving is only allowed once the current values were read successfully. if (! $this->loaded) { - $this->error = 'Aktuelle Konfiguration konnte nicht gelesen werden — Speichern ist gesperrt.'; + $this->error = __('modals.fail2ban_config.error_read_locked'); return; } @@ -83,17 +83,17 @@ class Fail2banConfig extends ModalComponent 'maxretry' => ['required', 'integer', 'min:1', 'max:100'], 'findtime' => $duration, ], [ - 'bantime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h, -1 für dauerhaft).', - 'findtime.regex' => 'Ungültige Dauer (z. B. 600, 10m, 1h).', + 'bantime.regex' => __('modals.fail2ban_config.error_bantime_invalid'), + 'findtime.regex' => __('modals.fail2ban_config.error_findtime_invalid'), ], [ - 'bantime' => 'Sperrdauer', - 'maxretry' => 'Max. Fehlversuche', - 'findtime' => 'Zeitfenster', + 'bantime' => __('modals.fail2ban_config.attr_bantime'), + 'maxretry' => __('modals.fail2ban_config.attr_maxretry'), + 'findtime' => __('modals.fail2ban_config.attr_findtime'), ]); $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -114,7 +114,7 @@ class Fail2banConfig extends ModalComponent } if (! $result['ok']) { - $this->error = 'Speichern fehlgeschlagen. '.$result['output']; + $this->error = __('modals.fail2ban_config.error_save_failed', ['output' => $result['output']]); return; } @@ -129,7 +129,7 @@ class Fail2banConfig extends ModalComponent ]); $this->dispatch('hardeningApplied'); - $this->dispatch('notify', message: 'fail2ban konfiguriert.'); + $this->dispatch('notify', message: __('modals.fail2ban_config.notify_saved')); $this->closeModal(); } diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php index fd8a789..3fec574 100644 --- a/app/Livewire/Modals/FileEditor.php +++ b/app/Livewire/Modals/FileEditor.php @@ -47,7 +47,7 @@ class FileEditor extends ModalComponent { $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); $this->loaded = true; return; @@ -71,7 +71,7 @@ class FileEditor extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -94,7 +94,7 @@ class FileEditor extends ModalComponent ]); $this->dispatch('fileSaved'); - $this->dispatch('notify', message: '„'.$this->name.'" gespeichert.'); + $this->dispatch('notify', message: __('modals.file_editor.notify_saved', ['name' => $this->name])); $this->closeModal(); } diff --git a/app/Livewire/Modals/FirewallRule.php b/app/Livewire/Modals/FirewallRule.php index 91dd8eb..e9056c5 100644 --- a/app/Livewire/Modals/FirewallRule.php +++ b/app/Livewire/Modals/FirewallRule.php @@ -48,7 +48,7 @@ class FirewallRule extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -64,7 +64,7 @@ class FirewallRule extends ModalComponent } if (! $res['ok']) { - $this->error = $res['output'] !== '' ? $res['output'] : 'Regel konnte nicht hinzugefügt werden.'; + $this->error = $res['output'] !== '' ? $res['output'] : __('modals.firewall_rule.error_add_failed'); return; } @@ -79,7 +79,7 @@ class FirewallRule extends ModalComponent ]); $this->dispatch('firewallChanged'); - $this->dispatch('notify', message: 'Firewall-Regel hinzugefügt.'); + $this->dispatch('notify', message: __('modals.firewall_rule.notify_added')); $this->closeModal(); } @@ -87,7 +87,10 @@ class FirewallRule extends ModalComponent private function summary(?int $port): string { if ($this->tool === 'firewalld') { - return 'Port '.$port.'/'.($this->proto === 'udp' ? 'udp' : 'tcp').' geöffnet'; + return __('modals.firewall_rule.summary_port_opened', [ + 'port' => (string) $port, + 'proto' => $this->proto === 'udp' ? 'udp' : 'tcp', + ]); } $parts = [$this->action]; @@ -95,7 +98,7 @@ class FirewallRule extends ModalComponent $parts[] = $port.($this->proto !== 'any' ? '/'.$this->proto : ''); } if (trim($this->from) !== '') { - $parts[] = 'von '.trim($this->from); + $parts[] = __('modals.firewall_rule.summary_from', ['source' => trim($this->from)]); } return implode(' ', $parts); diff --git a/app/Livewire/Modals/HardeningAction.php b/app/Livewire/Modals/HardeningAction.php index 3175605..4dce991 100644 --- a/app/Livewire/Modals/HardeningAction.php +++ b/app/Livewire/Modals/HardeningAction.php @@ -47,7 +47,7 @@ class HardeningAction extends ModalComponent $server = Server::find($serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -74,7 +74,7 @@ class HardeningAction extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -92,22 +92,22 @@ class HardeningAction extends ModalComponent $this->done = true; $this->ok = $result['ok']; // Clean result only — no raw command output on success; a short reason on failure. - $this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: 'Unbekannter Fehler.', 200); + $this->output = $this->ok ? '' : Str::limit(trim($result['output']) ?: __('modals.hardening_action.error_unknown'), 200); AuditEvent::create([ 'user_id' => Auth::id(), 'server_id' => $server->id, 'actor' => Auth::user()?->name ?? 'system', 'action' => 'harden.'.$this->action.($this->enable ? '.on' : '.off'), - 'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' (fehlgeschlagen)'), + 'target' => $server->name.' · '.$this->heading.($this->ok ? '' : ' '.__('modals.hardening_action.audit_failed_suffix')), 'ip' => request()->ip(), ]); if ($this->ok) { $this->dispatch('hardeningApplied'); - $this->dispatch('notify', message: $this->heading.' angewendet.'); + $this->dispatch('notify', message: __('modals.hardening_action.notify_applied', ['action' => $this->heading])); } else { - $this->dispatch('notify', message: $this->heading.' fehlgeschlagen.'); + $this->dispatch('notify', message: __('modals.hardening_action.notify_failed', ['action' => $this->heading])); } } diff --git a/app/Livewire/Modals/SystemUpdate.php b/app/Livewire/Modals/SystemUpdate.php index dd58468..ddcafc2 100644 --- a/app/Livewire/Modals/SystemUpdate.php +++ b/app/Livewire/Modals/SystemUpdate.php @@ -46,7 +46,7 @@ class SystemUpdate extends ModalComponent $server = Server::find($serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -75,7 +75,7 @@ class SystemUpdate extends ModalComponent $server = Server::find($this->serverId); if (! $server) { - $this->error = 'Server nicht gefunden.'; + $this->error = __('common.server_not_found'); return; } @@ -86,7 +86,7 @@ class SystemUpdate extends ModalComponent $this->done = true; $this->ok = false; $this->output = $e->getMessage(); - $this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.'); + $this->dispatch('notify', message: __('modals.system_update.notify_failed')); return; } @@ -102,15 +102,15 @@ class SystemUpdate extends ModalComponent 'server_id' => $server->id, 'actor' => Auth::user()?->name ?? 'system', 'action' => 'system.package_upgrade', - 'target' => $server->name.($this->ok ? '' : ' (fehlgeschlagen)'), + 'target' => $server->name.($this->ok ? '' : ' '.__('modals.system_update.audit_failed_suffix')), 'ip' => request()->ip(), ]); if ($this->ok) { $this->pending = 0; - $this->dispatch('notify', message: 'System aktualisiert.'); + $this->dispatch('notify', message: __('modals.system_update.notify_updated')); } else { - $this->dispatch('notify', message: 'Aktualisierung fehlgeschlagen.'); + $this->dispatch('notify', message: __('modals.system_update.notify_failed')); } } diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php index 3f55575..4eaa98c 100644 --- a/app/Livewire/Servers/Index.php +++ b/app/Livewire/Servers/Index.php @@ -52,6 +52,6 @@ class Index extends Component 'online' => (int) ($counts['online'] ?? 0), 'warning' => (int) ($counts['warning'] ?? 0), 'offline' => (int) ($counts['offline'] ?? 0), - ]); + ])->title($this->title()); } } diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index b3dd92d..cb0dad5 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -488,6 +488,6 @@ class Show extends Component public function render() { - return view('livewire.servers.show'); + return view('livewire.servers.show')->title($this->title()); } } diff --git a/app/Services/Fail2banService.php b/app/Services/Fail2banService.php index 9ea93aa..21fca9f 100644 --- a/app/Services/Fail2banService.php +++ b/app/Services/Fail2banService.php @@ -112,7 +112,7 @@ class Fail2banService $res = $this->fleet->runPrivileged($server, $cmd); if (! str_contains($res['output'], 'CLUSEV_READ_OK')) { - throw new RuntimeException('fail2ban-Konfiguration konnte nicht gelesen werden.'); + throw new RuntimeException(__('backend.fail2ban_config_read_failed')); } return $this->parseDefaults($res['output']); @@ -184,7 +184,7 @@ class Fail2banService public function unban(Server $server, string $jail, string $ip): array { if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) { - return ['ok' => false, 'output' => 'Ungültige Eingabe.']; + return ['ok' => false, 'output' => __('backend.invalid_input')]; } if ($reason = $this->support($server)) { return ['ok' => false, 'output' => $reason]; @@ -200,7 +200,7 @@ class Fail2banService public function ban(Server $server, string $jail, string $ip): array { if (! $this->validJail($jail) || ! filter_var($ip, FILTER_VALIDATE_IP)) { - return ['ok' => false, 'output' => 'Ungültige Eingabe.']; + return ['ok' => false, 'output' => __('backend.invalid_input')]; } if ($reason = $this->support($server)) { return ['ok' => false, 'output' => $reason]; @@ -213,7 +213,7 @@ class Fail2banService $loopback = $this->sameIp($ip, '::1') || (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false && str_starts_with($ip, '127.')); if ($loopback || ($cp !== null && $this->sameIp($ip, $cp))) { - return ['ok' => false, 'output' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).']; + return ['ok' => false, 'output' => __('backend.ip_belongs_to_clusev')]; } $res = $this->fleet->runPrivileged($server, 'fail2ban-client set '.$jail.' banip '.$ip); @@ -226,7 +226,7 @@ class Fail2banService { $ip = trim($ip); if (! $this->validIpOrCidr($ip)) { - return ['ok' => false, 'output' => 'Ungültige IP-Adresse oder CIDR.']; + return ['ok' => false, 'output' => __('backend.invalid_ip_or_cidr')]; } if ($reason = $this->support($server)) { return ['ok' => false, 'output' => $reason]; @@ -234,7 +234,7 @@ class Fail2banService $cfg = $this->readConfig($server); if (in_array($ip, $cfg['ignoreip'], true)) { - return ['ok' => true, 'noop' => true, 'output' => 'Bereits auf der Whitelist.']; + return ['ok' => true, 'noop' => true, 'output' => __('backend.already_whitelisted')]; } $list = $cfg['ignoreip']; $list[] = $ip; @@ -250,12 +250,12 @@ class Fail2banService return ['ok' => false, 'output' => $reason]; } if (in_array($ip, self::LOOPBACK, true)) { - return ['ok' => false, 'output' => 'Loopback bleibt immer auf der Whitelist.']; + return ['ok' => false, 'output' => __('backend.loopback_stays_whitelisted')]; } $cfg = $this->readConfig($server); if (! in_array($ip, $cfg['ignoreip'], true)) { - return ['ok' => true, 'noop' => true, 'output' => 'Nicht auf der Whitelist.']; + return ['ok' => true, 'noop' => true, 'output' => __('backend.not_on_whitelist')]; } $list = array_values(array_filter($cfg['ignoreip'], fn (string $e): bool => $e !== $ip)); diff --git a/app/Services/FirewallService.php b/app/Services/FirewallService.php index febd3c6..c04be2d 100644 --- a/app/Services/FirewallService.php +++ b/app/Services/FirewallService.php @@ -115,7 +115,7 @@ class FirewallService // Rule mutation is ufw-only this release; firewalld is read-only. if ($os->firewallTool === 'firewalld') { - return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.']; + return ['ok' => false, 'output' => __('backend.firewalld_read_only')]; } $action = in_array($action, ['allow', 'deny', 'reject', 'limit'], true) ? $action : 'allow'; @@ -123,13 +123,13 @@ class FirewallService $from = ($from !== null && trim($from) !== '') ? trim($from) : null; if ($port !== null && ($port < 1 || $port > 65535)) { - return ['ok' => false, 'output' => 'Ungültiger Port (1–65535).']; + return ['ok' => false, 'output' => __('backend.invalid_port')]; } if ($from !== null && ! $this->validIpOrCidr($from)) { - return ['ok' => false, 'output' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.']; + return ['ok' => false, 'output' => __('backend.invalid_source')]; } if ($port === null && $from === null) { - return ['ok' => false, 'output' => 'Port oder Quelle ist erforderlich.']; + return ['ok' => false, 'output' => __('backend.port_or_source_required')]; } // LOCKOUT GUARD: a deny/reject that can cover the SSH connection would sever @@ -140,15 +140,14 @@ class FirewallService $sshPort = $this->sshPort($server); if (in_array($action, ['deny', 'reject'], true) && in_array($proto, ['tcp', 'any'], true) && ($port === null || $port === $sshPort)) { - return ['ok' => false, 'output' => ($port === null - ? 'Eine '.$action.'-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. ' - : 'Eine '.$action.'-Regel auf den SSH-Port ('.$sshPort.') ist nicht erlaubt (Aussperrgefahr). ') - .'Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.']; + return ['ok' => false, 'output' => $port === null + ? __('backend.deny_without_port_blocks_ssh', ['action' => $action]) + : __('backend.deny_on_ssh_port_blocks_ssh', ['action' => $action, 'port' => $sshPort])]; } $tool = FirewallTool::for($os); if ($tool->isFirewalld() && $port === null) { - return ['ok' => false, 'output' => 'firewalld: Bitte einen Port angeben.']; + return ['ok' => false, 'output' => __('backend.firewalld_port_required')]; } $res = $this->fleet->runPrivileged($server, $tool->addRuleScript($action, $proto, $port, $from), 60); @@ -172,7 +171,7 @@ class FirewallService // Rule mutation is ufw-only this release; firewalld is read-only. if ($os->firewallTool === 'firewalld') { - return ['ok' => false, 'output' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.']; + return ['ok' => false, 'output' => __('backend.firewalld_read_only')]; } $tool = FirewallTool::for($os); @@ -185,13 +184,13 @@ class FirewallService // also race-free (no rule number to shift). $status = $this->status($server); if (! empty($status['readError'])) { - return ['ok' => false, 'output' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.']; + return ['ok' => false, 'output' => __('backend.firewall_status_read_failed')]; } $known = array_column($status['rules'], 'spec'); if (! in_array($spec, $known, true)) { // The rule vanished between confirm and apply — report not_found so the // caller does NOT audit a deletion that never happened. - return ['ok' => true, 'notFound' => true, 'output' => 'Regel existiert nicht mehr.']; + return ['ok' => true, 'notFound' => true, 'output' => __('backend.rule_gone')]; } // LOCKOUT GUARD: refuse to remove an allow/limit rule that keeps the SSH port open. if ($this->ufwSpecOpensSsh($spec, $sshPort)) { @@ -245,8 +244,7 @@ class FirewallService /** German refusal shown when a delete would sever Clusev's own SSH access. */ private function sshGuardMessage(int $port): string { - return "Diese Regel hält den SSH-Zugang (Port {$port}) offen und kann nicht entfernt werden — " - .'sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.'; + return __('backend.ssh_rule_guard', ['port' => $port]); } // NOTE: editing default policies (e.g. "deny incoming") was intentionally NOT diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 04d9b40..517a462 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -395,7 +395,7 @@ class FleetService try { $content = $sftp->get('.ssh/authorized_keys'); if (trim($content) === '') { - throw new RuntimeException('authorized_keys leer oder nicht lesbar.'); + throw new RuntimeException(__('backend.authorized_keys_unreadable')); } $lines = array_values(array_filter( @@ -442,7 +442,7 @@ class FleetService try { if (! $sftp->delete($path)) { - throw new RuntimeException('Loeschen fehlgeschlagen (Rechte?).'); + throw new RuntimeException(__('backend.delete_failed_perms')); } } finally { $sftp->disconnect(); @@ -456,7 +456,7 @@ class FleetService try { if ($sftp->size($path) > 50_000_000) { - throw new RuntimeException('Datei zu groß für den Download (>50 MB).'); + throw new RuntimeException(__('backend.file_too_large_download')); } return (string) $sftp->get($path); @@ -491,7 +491,7 @@ class FleetService try { if (! $sftp->put($path, $content)) { - throw new RuntimeException('Speichern fehlgeschlagen (Rechte?).'); + throw new RuntimeException(__('backend.save_failed_perms')); } } finally { $sftp->disconnect(); @@ -504,7 +504,7 @@ class FleetService try { if (! $sftp->putFromFile($remotePath, $localPath)) { - throw new RuntimeException('Upload fehlgeschlagen (Rechte?).'); + throw new RuntimeException(__('backend.upload_failed_perms')); } } finally { $sftp->disconnect(); @@ -748,7 +748,7 @@ class FleetService } if ($out === []) { - $out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.']; + $out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')]; } return $out; diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index ef5c720..2ac01dd 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -67,7 +67,7 @@ class HardeningService $res = $this->fleet->runPrivileged($server, $cmd); if (! $res['ok']) { - throw new RuntimeException('Härtungs-Status konnte nicht gelesen werden.'); + throw new RuntimeException(__('backend.hardening_status_read_failed')); } return $this->parseState($res['output'], $os); @@ -99,7 +99,7 @@ class HardeningService $upg = $pkg['autoupdate'] ?? ['installed' => false, 'active' => false]; $detail = fn (string $name, array $st): string => $name.' · ' - .(! $st['installed'] ? 'nicht installiert' : ($st['active'] ? 'aktiv' : 'inaktiv')); + .(! $st['installed'] ? __('backend.item_not_installed') : ($st['active'] ? __('common.active') : __('common.inactive'))); // A package feature is only effectively ON when installed AND active — // stale config (e.g. apt periodic = 1 after removal) must not read as secure. $on = fn (array $st): bool => $st['installed'] && $st['active']; @@ -115,11 +115,11 @@ class HardeningService // `featureOn` drives the button (Aktivieren/Deaktivieren); `secure` drives the OK/Offen pill. return [ - $row('ssh_root', 'SSH-Root-Login', 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')), - $row('ssh_password', 'SSH-Passwort-Login', 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')), + $row('ssh_root', __('backend.label_ssh_root'), 'PermitRootLogin '.$root, $root !== 'no', $root === 'no', $os->supports('ssh')), + $row('ssh_password', __('backend.label_ssh_password'), 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')), $row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')), $row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')), - $row('unattended', 'Automatische Updates', $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')), + $row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')), ]; } @@ -127,12 +127,12 @@ class HardeningService public function title(string $action, bool $enable): string { return match ($action) { - 'ssh_root' => $enable ? 'SSH-Root-Login erlauben' : 'SSH-Root-Login deaktivieren', - 'ssh_password' => $enable ? 'SSH-Passwort-Login erlauben' : 'SSH-Passwort-Login deaktivieren', - 'fail2ban' => $enable ? 'fail2ban aktivieren' : 'fail2ban deaktivieren', - 'firewall' => $enable ? 'Firewall aktivieren' : 'Firewall deaktivieren', - 'unattended' => $enable ? 'Automatische Updates aktivieren' : 'Automatische Updates deaktivieren', - default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + 'ssh_root' => $enable ? __('backend.title_ssh_root_enable') : __('backend.title_ssh_root_disable'), + 'ssh_password' => $enable ? __('backend.title_ssh_password_enable') : __('backend.title_ssh_password_disable'), + 'fail2ban' => $enable ? __('backend.title_fail2ban_enable') : __('backend.title_fail2ban_disable'), + 'firewall' => $enable ? __('backend.title_firewall_enable') : __('backend.title_firewall_disable'), + 'unattended' => $enable ? __('backend.title_unattended_enable') : __('backend.title_unattended_disable'), + default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; } @@ -140,21 +140,21 @@ class HardeningService { return match ($action) { 'ssh_root' => $enable - ? 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.' - : 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.', + ? __('backend.desc_ssh_root_enable') + : __('backend.desc_ssh_root_disable'), 'ssh_password' => $enable - ? 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).' - : 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.', + ? __('backend.desc_ssh_password_enable') + : __('backend.desc_ssh_password_disable'), 'fail2ban' => $enable - ? 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.' - : 'Stoppt fail2ban und deaktiviert den Autostart.', + ? __('backend.desc_fail2ban_enable') + : __('backend.desc_fail2ban_disable'), 'firewall' => $enable - ? 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.' - : 'Deaktiviert die Firewall des Servers.', + ? __('backend.desc_firewall_enable') + : __('backend.desc_firewall_disable'), 'unattended' => $enable - ? 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.' - : 'Schaltet die automatischen Updates ab.', - default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + ? __('backend.desc_unattended_enable') + : __('backend.desc_unattended_disable'), + default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; } @@ -169,7 +169,7 @@ class HardeningService 'fail2ban' => 'fail2ban', 'firewall' => 'firewall', 'unattended' => 'auto_updates', - default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; if ($reason = $os->supports($feature)) { return ['ok' => false, 'output' => $reason]; @@ -184,10 +184,10 @@ class HardeningService // Lock-out guard: never disable password auth without a usable key path. if ($action === 'ssh_password' && ! $enable) { if ($server->credential?->auth_type === 'password') { - return ['ok' => false, 'output' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.']; + return ['ok' => false, 'output' => __('backend.ssh_password_self_lockout')]; } if (! $this->hasAuthorizedKey($server)) { - return ['ok' => false, 'output' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).']; + return ['ok' => false, 'output' => __('backend.ssh_password_no_key')]; } } @@ -210,7 +210,7 @@ class HardeningService ? $pm->ensureInstalledScript('fail2ban').' && systemctl enable --now fail2ban' : 'systemctl disable --now fail2ban', 'unattended' => $this->autoUpdateScript($os, $pm, $enable), - default => throw new InvalidArgumentException("Unbekannte Härtung: {$action}"), + default => throw new InvalidArgumentException(__('backend.unknown_hardening', ['action' => $action])), }; } diff --git a/app/Support/Os/OsProfile.php b/app/Support/Os/OsProfile.php index 6e0b2bc..f71fb53 100644 --- a/app/Support/Os/OsProfile.php +++ b/app/Support/Os/OsProfile.php @@ -50,12 +50,12 @@ final class OsProfile public function familyLabel(): string { return match ($this->family) { - 'debian' => 'Debian/Ubuntu', - 'rhel' => 'RHEL/Fedora', - 'suse' => 'openSUSE/SLES', - 'arch' => 'Arch Linux', - 'alpine' => 'Alpine Linux', - default => 'Unbekanntes System', + 'debian' => __('backend.family_debian'), + 'rhel' => __('backend.family_rhel'), + 'suse' => __('backend.family_suse'), + 'arch' => __('backend.family_arch'), + 'alpine' => __('backend.family_alpine'), + default => __('backend.family_unknown'), }; } @@ -63,9 +63,9 @@ final class OsProfile public function firewallLabel(): string { return match ($this->firewallTool) { - 'ufw' => 'Firewall (UFW)', - 'firewalld' => 'Firewall (firewalld)', - default => 'Firewall', + 'ufw' => __('backend.firewall_label_ufw'), + 'firewalld' => __('backend.firewall_label_firewalld'), + default => __('backend.firewall_label_generic'), }; } @@ -79,27 +79,27 @@ final class OsProfile return match ($feature) { 'ssh' => $this->isSystemd() ? null - : 'SSH-Härtung benötigt systemd, das auf '.$this->familyLabel().' nicht erkannt wurde.', + : __('backend.support_ssh', ['family' => $this->familyLabel()]), 'services' => $this->isSystemd() ? null - : 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.', + : __('backend.support_services'), 'updates' => $this->managesPackages() ? null - : 'Paket-Updates über Clusev werden unter '.$this->familyLabel().' noch nicht unterstützt.', + : __('backend.support_updates', ['family' => $this->familyLabel()]), 'fail2ban' => ($this->managesPackages() && $this->isSystemd()) ? null - : 'fail2ban wird unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.', + : __('backend.support_fail2ban', ['family' => $this->familyLabel()]), 'firewall' => ($this->firewallTool !== 'none' && $this->isSystemd() && ($this->firewallTool === 'firewalld' || $this->managesPackages() || $this->has('ufw'))) ? null - : 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter '.$this->familyLabel().' nicht gefunden.', + : __('backend.support_firewall', ['family' => $this->familyLabel()]), // apt -> unattended-upgrades; dnf -> dnf-automatic. A yum-ONLY host (e.g. // CentOS 7) maps to the dnf manager for upgrades but has no dnf-automatic // (it uses yum-cron), so require a real `dnf` binary for this feature. 'auto_updates' => (($this->packageManager === 'apt' || ($this->packageManager === 'dnf' && $this->has('dnf'))) && $this->isSystemd()) ? null - : 'Automatische Updates werden unter '.$this->familyLabel().' über Clusev noch nicht unterstützt.', + : __('backend.support_auto_updates', ['family' => $this->familyLabel()]), default => null, }; } diff --git a/app/Support/Ssh/CredentialVault.php b/app/Support/Ssh/CredentialVault.php index 74a9dd5..1fca0b7 100644 --- a/app/Support/Ssh/CredentialVault.php +++ b/app/Support/Ssh/CredentialVault.php @@ -22,11 +22,11 @@ class CredentialVault $cred = $server->credential; if (! $cred) { - throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt."); + throw new RuntimeException(__('backend.no_ssh_credential', ['server' => $server->name])); } if ($cred->disabled_at !== null) { - throw new RuntimeException("SSH-Zugang für {$server->name} ist gesperrt."); + throw new RuntimeException(__('backend.ssh_access_locked', ['server' => $server->name])); } $secret = $cred->auth_type === 'key' diff --git a/config/clusev.php b/config/clusev.php index e2d5b0c..dfd2ca2 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,7 +3,7 @@ return [ // 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). - 'version' => '0.2.1', + 'version' => '0.3.0', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/lang/de/backend.php b/lang/de/backend.php new file mode 100644 index 0000000..0b21181 --- /dev/null +++ b/lang/de/backend.php @@ -0,0 +1,100 @@ + 'nicht installiert', + 'label_ssh_root' => 'SSH-Root-Login', + 'label_ssh_password' => 'SSH-Passwort-Login', + 'label_auto_updates' => 'Automatische Updates', + + // ── HardeningService: modal titles (per direction) ─────────────────── + 'title_ssh_root_enable' => 'SSH-Root-Login erlauben', + 'title_ssh_root_disable' => 'SSH-Root-Login deaktivieren', + 'title_ssh_password_enable' => 'SSH-Passwort-Login erlauben', + 'title_ssh_password_disable' => 'SSH-Passwort-Login deaktivieren', + 'title_fail2ban_enable' => 'fail2ban aktivieren', + 'title_fail2ban_disable' => 'fail2ban deaktivieren', + 'title_firewall_enable' => 'Firewall aktivieren', + 'title_firewall_disable' => 'Firewall deaktivieren', + 'title_unattended_enable' => 'Automatische Updates aktivieren', + 'title_unattended_disable' => 'Automatische Updates deaktivieren', + + // ── HardeningService: modal descriptions (per direction) ───────────── + 'desc_ssh_root_enable' => 'Erlaubt direkten Root-Login über SSH wieder (PermitRootLogin yes) und lädt den SSH-Dienst neu.', + 'desc_ssh_root_disable' => 'Unterbindet direkten Root-Login über SSH (PermitRootLogin no) und lädt den SSH-Dienst neu.', + 'desc_ssh_password_enable' => 'Erlaubt die Anmeldung per Passwort wieder (PasswordAuthentication yes).', + 'desc_ssh_password_disable' => 'Anmeldung nur noch per SSH-Key (PasswordAuthentication no). Nur möglich, wenn ein Key hinterlegt ist.', + 'desc_fail2ban_enable' => 'Installiert fail2ban (falls nötig) und startet den Dienst. Wiederholte Fehl-Logins werden gesperrt.', + 'desc_fail2ban_disable' => 'Stoppt fail2ban und deaktiviert den Autostart.', + 'desc_firewall_enable' => 'Installiert die Firewall (falls nötig), gibt vorher SSH + 80/443 frei und aktiviert sie.', + 'desc_firewall_disable' => 'Deaktiviert die Firewall des Servers.', + 'desc_unattended_enable' => 'Installiert (falls nötig) und aktiviert die automatischen Sicherheitsupdates des Systems.', + 'desc_unattended_disable' => 'Schaltet die automatischen Updates ab.', + + // ── HardeningService: errors + guards ──────────────────────────────── + 'hardening_status_read_failed' => 'Härtungs-Status konnte nicht gelesen werden.', + 'ssh_password_self_lockout' => 'Clusev verbindet sich selbst per Passwort — Passwort-Login kann nicht deaktiviert werden, sonst verliert Clusev den Zugang. Hinterlege zuerst einen SSH-Key-Zugang.', + 'ssh_password_no_key' => 'Kein SSH-Key hinterlegt — Passwort-Login kann nicht deaktiviert werden (Aussperrgefahr).', + + // ── FirewallService: guards / reasons / errors ─────────────────────── + 'firewalld_read_only' => 'Regelverwaltung für firewalld ist in dieser Version nur lesend — bitte am Server konfigurieren.', + 'invalid_port' => 'Ungültiger Port (1–65535).', + 'invalid_source' => 'Ungültige Quelle — IP-Adresse oder CIDR erwartet.', + 'port_or_source_required' => 'Port oder Quelle ist erforderlich.', + 'deny_without_port_blocks_ssh' => 'Eine :action-Regel ohne Port würde auch den SSH-Zugang blockieren und ist nicht erlaubt. Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.', + 'deny_on_ssh_port_blocks_ssh' => 'Eine :action-Regel auf den SSH-Port (:port) ist nicht erlaubt (Aussperrgefahr). Nutze fail2ban, um einzelne IPs vom SSH-Zugang auszusperren.', + 'firewalld_port_required' => 'firewalld: Bitte einen Port angeben.', + 'firewall_status_read_failed' => 'Firewall-Status konnte nicht gelesen werden — Regel nicht entfernt.', + 'rule_gone' => 'Regel existiert nicht mehr.', + 'ssh_rule_guard' => 'Diese Regel hält den SSH-Zugang (Port :port) offen und kann nicht entfernt werden — sonst sperrt sich Clusev selbst aus. Deaktiviere bei Bedarf die gesamte Firewall.', + + // ── Fail2banService: guards / reasons / errors ─────────────────────── + 'ip_belongs_to_clusev' => 'Diese IP gehört zum Clusev-Zugang und kann nicht gesperrt werden (Aussperrgefahr).', + 'already_whitelisted' => 'Bereits auf der Whitelist.', + 'not_on_whitelist' => 'Nicht auf der Whitelist.', + 'loopback_stays_whitelisted' => 'Loopback bleibt immer auf der Whitelist.', + 'invalid_input' => 'Ungültige Eingabe.', + 'invalid_ip_or_cidr' => 'Ungültige IP-Adresse oder CIDR.', + 'fail2ban_config_read_failed' => 'fail2ban-Konfiguration konnte nicht gelesen werden.', + + // ── OsProfile: family labels ───────────────────────────────────────── + 'family_debian' => 'Debian/Ubuntu', + 'family_rhel' => 'RHEL/Fedora', + 'family_suse' => 'openSUSE/SLES', + 'family_arch' => 'Arch Linux', + 'family_alpine' => 'Alpine Linux', + 'family_unknown' => 'Unbekanntes System', + + // ── OsProfile: firewall labels ─────────────────────────────────────── + 'firewall_label_ufw' => 'Firewall (UFW)', + 'firewall_label_firewalld' => 'Firewall (firewalld)', + 'firewall_label_generic' => 'Firewall', + + // ── OsProfile: supports() reasons (:family = familyLabel()) ────────── + 'support_ssh' => 'SSH-Härtung benötigt systemd, das auf :family nicht erkannt wurde.', + 'support_services' => 'Dienst-Steuerung benötigt systemd, das hier nicht erkannt wurde.', + 'support_updates' => 'Paket-Updates über Clusev werden unter :family noch nicht unterstützt.', + 'support_fail2ban' => 'fail2ban wird unter :family über Clusev noch nicht unterstützt.', + 'support_firewall' => 'Eine unterstützte Firewall (UFW oder firewalld) wurde unter :family nicht gefunden.', + 'support_auto_updates' => 'Automatische Updates werden unter :family über Clusev noch nicht unterstützt.', + + // ── FleetService: SFTP / file-manager / journal errors ─────────────── + 'authorized_keys_unreadable' => 'authorized_keys leer oder nicht lesbar.', + 'delete_failed_perms' => 'Löschen fehlgeschlagen (Rechte?).', + 'file_too_large_download' => 'Datei zu groß für den Download (>50 MB).', + 'save_failed_perms' => 'Speichern fehlgeschlagen (Rechte?).', + 'upload_failed_perms' => 'Upload fehlgeschlagen (Rechte?).', + 'journal_needs_privileges' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.', + + // ── CredentialVault: SSH credential guards (:server = name) ────────── + 'no_ssh_credential' => 'Kein SSH-Credential für Server :server hinterlegt.', + 'ssh_access_locked' => 'SSH-Zugang für :server ist gesperrt.', + + // ── HardeningService: invalid action invariant (:action) ───────────── + 'unknown_hardening' => 'Unbekannte Härtung: :action', +]; diff --git a/lang/de/modals.php b/lang/de/modals.php index a544efb..f045efe 100644 --- a/lang/de/modals.php +++ b/lang/de/modals.php @@ -158,6 +158,7 @@ return [ 'result_ok' => 'System aktualisiert.', 'result_failed' => 'Aktualisierung fehlgeschlagen.', 'submit' => 'Jetzt aktualisieren', + 'audit_failed_suffix' => '(fehlgeschlagen)', 'notify_updated' => 'System aktualisiert.', 'notify_failed' => 'Aktualisierung fehlgeschlagen.', ], diff --git a/lang/de/versions.php b/lang/de/versions.php index cdf5bb2..f1e8d66 100644 --- a/lang/de/versions.php +++ b/lang/de/versions.php @@ -25,6 +25,14 @@ return [ 'in_progress' => 'in Arbeit', 'changelog_empty' => 'Kein Änderungsprotokoll gefunden.', + // Changelog section labels (Keep a Changelog subsections) + 'section_added' => 'Hinzugefügt', + 'section_changed' => 'Geändert', + 'section_fixed' => 'Behoben', + 'section_security' => 'Sicherheit', + 'section_removed' => 'Entfernt', + 'section_deprecated' => 'Veraltet', + // Installed panel 'installed_title' => 'Installiert', 'running' => 'läuft', diff --git a/lang/en/auth.php b/lang/en/auth.php index 27d7b8b..5368873 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -63,10 +63,10 @@ return [ // ── Validation / error messages ────────────────────────────────────── 'too_many_attempts' => 'Too many attempts. Please try again in :seconds seconds.', - 'invalid_credentials' => 'These credentials do not match.', + 'invalid_credentials' => 'These credentials are incorrect.', 'session_expired' => 'Session expired. Please sign in again.', 'invalid_code' => 'Invalid code.', - 'code_mismatch' => 'Code does not match — check your authenticator app’s clock.', + 'code_mismatch' => 'Code does not match — check your authenticator app’s time.', // ── Validation attribute names ─────────────────────────────────────── 'attr_current_password' => 'current password', diff --git a/lang/en/backend.php b/lang/en/backend.php new file mode 100644 index 0000000..05bc84f --- /dev/null +++ b/lang/en/backend.php @@ -0,0 +1,100 @@ + 'not installed', + 'label_ssh_root' => 'SSH root login', + 'label_ssh_password' => 'SSH password login', + 'label_auto_updates' => 'Automatic updates', + + // ── HardeningService: modal titles (per direction) ─────────────────── + 'title_ssh_root_enable' => 'Allow SSH root login', + 'title_ssh_root_disable' => 'Disable SSH root login', + 'title_ssh_password_enable' => 'Allow SSH password login', + 'title_ssh_password_disable' => 'Disable SSH password login', + 'title_fail2ban_enable' => 'Enable fail2ban', + 'title_fail2ban_disable' => 'Disable fail2ban', + 'title_firewall_enable' => 'Enable firewall', + 'title_firewall_disable' => 'Disable firewall', + 'title_unattended_enable' => 'Enable automatic updates', + 'title_unattended_disable' => 'Disable automatic updates', + + // ── HardeningService: modal descriptions (per direction) ───────────── + 'desc_ssh_root_enable' => 'Re-allows direct root login over SSH (PermitRootLogin yes) and reloads the SSH service.', + 'desc_ssh_root_disable' => 'Blocks direct root login over SSH (PermitRootLogin no) and reloads the SSH service.', + 'desc_ssh_password_enable' => 'Re-allows password login (PasswordAuthentication yes).', + 'desc_ssh_password_disable' => 'Login via SSH key only (PasswordAuthentication no). Only possible if a key is in place.', + 'desc_fail2ban_enable' => 'Installs fail2ban (if needed) and starts the service. Repeated failed logins get banned.', + 'desc_fail2ban_disable' => 'Stops fail2ban and disables autostart.', + 'desc_firewall_enable' => 'Installs the firewall (if needed), opens SSH + 80/443 first, then enables it.', + 'desc_firewall_disable' => 'Disables the server firewall.', + 'desc_unattended_enable' => 'Installs (if needed) and enables the system\'s automatic security updates.', + 'desc_unattended_disable' => 'Turns off automatic updates.', + + // ── HardeningService: errors + guards ──────────────────────────────── + 'hardening_status_read_failed' => 'Could not read hardening status.', + 'ssh_password_self_lockout' => 'Clusev connects itself via password — password login cannot be disabled, otherwise Clusev loses access. Set up SSH key access first.', + 'ssh_password_no_key' => 'No SSH key in place — password login cannot be disabled (lockout risk).', + + // ── FirewallService: guards / reasons / errors ─────────────────────── + 'firewalld_read_only' => 'Rule management for firewalld is read-only in this version — please configure it on the server.', + 'invalid_port' => 'Invalid port (1–65535).', + 'invalid_source' => 'Invalid source — IP address or CIDR expected.', + 'port_or_source_required' => 'Port or source is required.', + 'deny_without_port_blocks_ssh' => 'A :action rule without a port would also block SSH access and is not allowed. Use fail2ban to block individual IPs from SSH access.', + 'deny_on_ssh_port_blocks_ssh' => 'A :action rule on the SSH port (:port) is not allowed (lockout risk). Use fail2ban to block individual IPs from SSH access.', + 'firewalld_port_required' => 'firewalld: Please specify a port.', + 'firewall_status_read_failed' => 'Could not read firewall status — rule not removed.', + 'rule_gone' => 'Rule no longer exists.', + 'ssh_rule_guard' => 'This rule keeps SSH access (port :port) open and cannot be removed — otherwise Clusev locks itself out. Disable the whole firewall if needed.', + + // ── Fail2banService: guards / reasons / errors ─────────────────────── + 'ip_belongs_to_clusev' => 'This IP belongs to Clusev\'s access and cannot be banned (lockout risk).', + 'already_whitelisted' => 'Already on the whitelist.', + 'not_on_whitelist' => 'Not on the whitelist.', + 'loopback_stays_whitelisted' => 'Loopback always stays on the whitelist.', + 'invalid_input' => 'Invalid input.', + 'invalid_ip_or_cidr' => 'Invalid IP address or CIDR.', + 'fail2ban_config_read_failed' => 'Could not read fail2ban configuration.', + + // ── OsProfile: family labels ───────────────────────────────────────── + 'family_debian' => 'Debian/Ubuntu', + 'family_rhel' => 'RHEL/Fedora', + 'family_suse' => 'openSUSE/SLES', + 'family_arch' => 'Arch Linux', + 'family_alpine' => 'Alpine Linux', + 'family_unknown' => 'Unknown system', + + // ── OsProfile: firewall labels ─────────────────────────────────────── + 'firewall_label_ufw' => 'Firewall (UFW)', + 'firewall_label_firewalld' => 'Firewall (firewalld)', + 'firewall_label_generic' => 'Firewall', + + // ── OsProfile: supports() reasons (:family = familyLabel()) ────────── + 'support_ssh' => 'SSH hardening requires systemd, which was not detected on :family.', + 'support_services' => 'Service control requires systemd, which was not detected here.', + 'support_updates' => 'Package updates via Clusev are not yet supported on :family.', + 'support_fail2ban' => 'fail2ban is not yet supported on :family via Clusev.', + 'support_firewall' => 'No supported firewall (UFW or firewalld) was found on :family.', + 'support_auto_updates' => 'Automatic updates are not yet supported on :family via Clusev.', + + // ── FleetService: SFTP / file-manager / journal errors ─────────────── + 'authorized_keys_unreadable' => 'authorized_keys is empty or unreadable.', + 'delete_failed_perms' => 'Delete failed (permissions?).', + 'file_too_large_download' => 'File too large to download (>50 MB).', + 'save_failed_perms' => 'Save failed (permissions?).', + 'upload_failed_perms' => 'Upload failed (permissions?).', + 'journal_needs_privileges' => 'The system journal requires elevated privileges (group adm/systemd-journal) or sudo.', + + // ── CredentialVault: SSH credential guards (:server = name) ────────── + 'no_ssh_credential' => 'No SSH credential stored for server :server.', + 'ssh_access_locked' => 'SSH access for :server is locked.', + + // ── HardeningService: invalid action invariant (:action) ───────────── + 'unknown_hardening' => 'Unknown hardening action: :action', +]; diff --git a/lang/en/common.php b/lang/en/common.php index 28e2a97..0736d4f 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -14,7 +14,7 @@ return [ 'enable' => 'Enable', 'disable' => 'Disable', 'lock' => 'Lock', - 'unlock' => 'Unblock', + 'unlock' => 'Unlock', 'back' => 'Back', 'retry' => 'Try again', 'confirm' => 'Confirm', diff --git a/lang/en/modals.php b/lang/en/modals.php index 09a0a70..e6c0d38 100644 --- a/lang/en/modals.php +++ b/lang/en/modals.php @@ -157,6 +157,7 @@ return [ 'result_ok' => 'System updated.', 'result_failed' => 'Update failed.', 'submit' => 'Update now', + 'audit_failed_suffix' => '(failed)', 'notify_updated' => 'System updated.', 'notify_failed' => 'Update failed.', ], @@ -164,6 +165,6 @@ return [ // ── Generic confirm dialog defaults (ConfirmAction) ─────────────────── 'confirm_action' => [ 'default_heading' => 'Confirm action', - 'default_notify' => 'Action carried out.', + 'default_notify' => 'Action completed.', ], ]; diff --git a/lang/en/servers.php b/lang/en/servers.php index 844bf77..07304da 100644 --- a/lang/en/servers.php +++ b/lang/en/servers.php @@ -52,7 +52,7 @@ return [ 'cred_auth_password' => 'Password', 'cred_none_title' => 'No access stored', 'cred_none_hint' => 'Without SSH access, live control is not possible.', - 'cred_deposit' => 'Store access', + 'cred_deposit' => 'Add credentials', // ── Show: specifications ────────────────────────────────────────────── 'specs_title' => 'Specifications', diff --git a/lang/en/versions.php b/lang/en/versions.php index cf5aec8..e646395 100644 --- a/lang/en/versions.php +++ b/lang/en/versions.php @@ -25,6 +25,14 @@ return [ 'in_progress' => 'in progress', 'changelog_empty' => 'No changelog found.', + // Changelog section labels (Keep a Changelog subsections) + 'section_added' => 'Added', + 'section_changed' => 'Changed', + 'section_fixed' => 'Fixed', + 'section_security' => 'Security', + 'section_removed' => 'Removed', + 'section_deprecated' => 'Deprecated', + // Installed panel 'installed_title' => 'Installed', 'running' => 'running', diff --git a/resources/js/app.js b/resources/js/app.js index 6d49345..1360f52 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -68,31 +68,21 @@ document.addEventListener('alpine:init', () => { })); // ── Command palette + keyboard shortcuts ──────────────────────────── - // German nav targets (mirrors the sidebar) with a leader-key hint. Leader - // mnemonics: i=dIenste, l=Log, e=Einstellungen, y=sYstem (d/s already taken). - const CMDK_NAV = [ - { label: 'Dashboard', href: '/', hint: 'g d' }, - { label: 'Server', href: '/servers', hint: 'g s' }, - { label: 'Dienste', href: '/services', hint: 'g i' }, - { label: 'Dateien', href: '/files', hint: 'g f' }, - { label: 'Audit-Log', href: '/audit', hint: 'g l' }, - { label: 'Einstellungen', href: '/settings', hint: 'g e' }, - { label: 'System', href: '/system', hint: 'g y' }, - { label: 'Version', href: '/versions', hint: 'g v' }, - ]; - const CMDK_ACTIONS = [ - { label: 'Server hinzufügen', action: 'create-server', hint: '' }, - ]; + // Nav + action item labels are passed in from the Blade markup (already + // translated via __()), so the palette stays in sync with the active locale. + // CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log, + // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' }; - window.Alpine.data('cmdk', () => ({ + window.Alpine.data('cmdk', (nav = [], actions = []) => ({ + nav, + actions, open: false, help: false, query: '', active: 0, leader: false, leaderTimer: null, - nav: CMDK_NAV, init() { // Reset overlays after an SPA navigation. The component is recreated on each @@ -112,7 +102,7 @@ document.addEventListener('alpine:init', () => { get items() { const q = this.query.trim().toLowerCase(); - const all = [...CMDK_NAV, ...CMDK_ACTIONS]; + const all = [...this.nav, ...this.actions]; return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q)); }, diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index 04d94c2..b83ed39 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -5,21 +5,36 @@ --}} @php $chords = [ - ['Strg / ⌘ K', 'Befehle öffnen'], - ['/', 'Suche auf der Seite fokussieren'], - ['?', 'Diese Hilfe anzeigen'], - ['g d', 'Dashboard'], - ['g s', 'Server'], - ['g i', 'Dienste'], - ['g f', 'Dateien'], - ['g l', 'Audit-Log'], - ['g e', 'Einstellungen'], - ['g y', 'System'], - ['g v', 'Version'], + ['Strg / ⌘ K', __('shell.cmdk_open')], + ['/', __('shell.cmdk_focus_search')], + ['?', __('shell.cmdk_show_help')], + ['g d', __('shell.nav_dashboard')], + ['g s', __('shell.nav_servers')], + ['g i', __('shell.nav_services')], + ['g f', __('shell.nav_files')], + ['g l', __('shell.nav_audit')], + ['g e', __('shell.nav_settings')], + ['g y', __('shell.nav_system')], + ['g v', __('shell.nav_versions')], + ]; + + $nav = [ + ['label' => __('shell.nav_dashboard'), 'href' => '/', 'hint' => 'g d'], + ['label' => __('shell.nav_servers'), 'href' => '/servers', 'hint' => 'g s'], + ['label' => __('shell.nav_services'), 'href' => '/services', 'hint' => 'g i'], + ['label' => __('shell.nav_files'), 'href' => '/files', 'hint' => 'g f'], + ['label' => __('shell.nav_audit'), 'href' => '/audit', 'hint' => 'g l'], + ['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'], + ['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'], + ['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'], + ]; + + $actions = [ + ['label' => __('servers.add_server'), 'action' => 'create-server', 'hint' => ''], ]; $kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2'; @endphp -
@@ -33,13 +48,13 @@
- Esc + {{ __('shell.cmdk_esc') }}
- öffnen - + {{ __('shell.cmdk_open_hint') }} +
@@ -71,8 +86,8 @@
-

Tastenkürzel

-
diff --git a/resources/views/components/server-item.blade.php b/resources/views/components/server-item.blade.php index d8aa1a3..6fc05be 100644 --- a/resources/views/components/server-item.blade.php +++ b/resources/views/components/server-item.blade.php @@ -1,6 +1,6 @@ @props(['name', 'ip', 'status' => 'online', 'cpu' => 0, 'mem' => 0, 'os' => null]) @php - $label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$status] ?? ucfirst($status); + $label = ['online' => __('common.online'), 'warning' => __('common.warning'), 'offline' => __('common.offline')][$status] ?? ucfirst($status); @endphp diff --git a/resources/views/components/topbar.blade.php b/resources/views/components/topbar.blade.php index 0bbc29c..17cb6fe 100644 --- a/resources/views/components/topbar.blade.php +++ b/resources/views/components/topbar.blade.php @@ -1,5 +1,9 @@ @props(['title' => null]) -@php($title ??= __('shell.title_default')) +@php + // Block form (not inline @php(...)): an inline @php here would be swallowed by + // Blade's raw-block precompiler up to the @endphp further down this file. + $title ??= __('shell.title_default'); +@endphp
@@ -22,22 +21,22 @@ @if ($loaded)
- - {{ __('modals.fail2ban_config.bantime_label') }} + @error('bantime')

{{ $message }}

@enderror
- + @error('maxretry')

{{ $message }}

@enderror
- - {{ __('modals.fail2ban_config.findtime_label') }} + @error('findtime')

{{ $message }}

@enderror
@@ -45,12 +44,12 @@ @endif
- {{ $loaded ? 'Abbrechen' : 'Schließen' }} + {{ $loaded ? __('common.cancel') : __('common.close') }} @if ($loaded) - Speichern + {{ __('common.save') }} @endif
diff --git a/resources/views/livewire/modals/file-editor.blade.php b/resources/views/livewire/modals/file-editor.blade.php index a5b1a3a..237700d 100644 --- a/resources/views/livewire/modals/file-editor.blade.php +++ b/resources/views/livewire/modals/file-editor.blade.php @@ -15,9 +15,9 @@ @elseif ($error)

{{ $error }}

@elseif ($tooBig) -

Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.

+

{{ __('modals.file_editor.too_big') }}

@elseif ($binary) -

Binärdatei — kann nicht im Editor angezeigt werden.

+

{{ __('modals.file_editor.binary') }}

@else @@ -25,11 +25,11 @@
- Schließen + {{ __('common.close') }} @if ($loaded && ! $error && ! $tooBig && ! $binary) - Speichern + {{ __('common.save') }} @endif
diff --git a/resources/views/livewire/modals/firewall-rule.blade.php b/resources/views/livewire/modals/firewall-rule.blade.php index 58b73f0..52a5566 100644 --- a/resources/views/livewire/modals/firewall-rule.blade.php +++ b/resources/views/livewire/modals/firewall-rule.blade.php @@ -9,12 +9,12 @@
-

Firewall-Regel hinzufügen

+

{{ __('modals.firewall_rule.title') }}

@if ($tool === 'firewalld') - Öffnet einen Port in der Standard-Zone von firewalld. + {{ __('modals.firewall_rule.subtitle_firewalld') }} @else - Neue UFW-Regel. Quelle leer lassen = von überall. + {{ __('modals.firewall_rule.subtitle_ufw') }} @endif

@@ -30,28 +30,28 @@
@if ($tool !== 'firewalld')
- +
@endif
- - + +
- +
@@ -59,18 +59,18 @@ @if ($tool !== 'firewalld')
- - + +
@endif
- Abbrechen + {{ __('common.cancel') }} - Hinzufügen + {{ __('common.add') }}
diff --git a/resources/views/livewire/modals/hardening-action.blade.php b/resources/views/livewire/modals/hardening-action.blade.php index 4ed0971..3ec8397 100644 --- a/resources/views/livewire/modals/hardening-action.blade.php +++ b/resources/views/livewire/modals/hardening-action.blade.php @@ -4,7 +4,7 @@
-

{{ $heading ?: 'Härtung anwenden' }}

+

{{ $heading ?: __('modals.hardening_action.title_fallback') }}

@if ($description)

{{ $description }}

@endif @@ -24,7 +24,7 @@ ])> $ok, 'text-offline' => ! $ok]) />
-

$ok, 'text-offline' => ! $ok])>{{ $ok ? 'Erfolgreich angewendet.' : 'Fehlgeschlagen.' }}

+

$ok, 'text-offline' => ! $ok])>{{ $ok ? __('modals.hardening_action.result_ok') : __('modals.hardening_action.result_failed') }}

@if (! $ok && $output !== '')

{{ $output }}

@endif @@ -34,14 +34,14 @@
@if ($done) - Schließen + {{ __('common.close') }} @else - Abbrechen + {{ __('common.cancel') }} @unless ($error) - Anwenden + {{ __('common.apply') }} @endunless @endif diff --git a/resources/views/livewire/modals/system-update.blade.php b/resources/views/livewire/modals/system-update.blade.php index 09c8ab5..0acf182 100644 --- a/resources/views/livewire/modals/system-update.blade.php +++ b/resources/views/livewire/modals/system-update.blade.php @@ -4,10 +4,9 @@
-

System-Updates

+

{{ __('modals.system_update.title') }}

- Aktualisiert die installierten Pakete auf {{ $serverName ?: 'diesem Server' }} über die - Paketverwaltung. Es werden keine neuen Versionssprünge erzwungen — nur verfügbare Updates eingespielt. + {{ __('modals.system_update.subtitle', ['server' => $serverName ?: __('modals.system_update.this_server')]) }}

@@ -20,7 +19,7 @@ @elseif (! $supported)
-

{{ $unsupportedReason ?: 'Auf diesem System nicht verfügbar.' }}

+

{{ $unsupportedReason ?: __('modals.system_update.unsupported_fallback') }}

@else
@@ -35,18 +34,18 @@

@if ($pending === null) - Update-Anzahl unbekannt + {{ __('modals.system_update.count_unknown') }} @elseif ($pending === 1) - 1 Paket-Update verfügbar + {{ __('modals.system_update.count_one') }} @else - {{ $pending }} Paket-Updates verfügbar + {{ __('modals.system_update.count_many', ['count' => $pending]) }} @endif

@if ($pending === null) - Konnte nicht ermittelt werden — Aktualisierung trotzdem möglich. + {{ __('modals.system_update.hint_unknown') }} @else - {{ $pending > 0 ? 'Aktualisierung empfohlen.' : 'Das System ist auf dem aktuellen Stand.' }} + {{ $pending > 0 ? __('modals.system_update.hint_recommended') : __('modals.system_update.hint_up_to_date') }} @endif

@@ -64,7 +63,7 @@ 'text-offline' => ! $ok, ])> - {{ $ok ? 'System aktualisiert.' : 'Aktualisierung fehlgeschlagen.' }} + {{ $ok ? __('modals.system_update.result_ok') : __('modals.system_update.result_failed') }}

@if (! $ok && $output !== '')
{{ $output }}
@@ -75,14 +74,14 @@
@if ($done) - Schließen + {{ __('common.close') }} @else - Abbrechen + {{ __('common.cancel') }} @if (! $error && $supported) - Jetzt aktualisieren + {{ __('modals.system_update.submit') }} @endif @endif diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index e710e3f..d295eb5 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -484,7 +484,7 @@
- {{-- SSH-Schlüssel --}} + {{-- SSH keys --}} diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index dccc3be..4bd1029 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -1,13 +1,20 @@ @php - // Map a Keep-a-Changelog subsection to a badge tone. - $tone = fn (string $g): string => [ - 'Hinzugefügt' => 'accent', - 'Geändert' => 'cyan', - 'Behoben' => 'neutral', - 'Sicherheit' => 'accent', - 'Entfernt' => 'neutral', - 'Veraltet' => 'neutral', - ][$g] ?? 'neutral'; + // CHANGELOG.md uses German Keep-a-Changelog headings as the canonical section keys; + // map each to a localized display label + a badge tone. + $sectionKey = [ + 'Hinzugefügt' => 'added', + 'Geändert' => 'changed', + 'Behoben' => 'fixed', + 'Sicherheit' => 'security', + 'Entfernt' => 'removed', + 'Veraltet' => 'deprecated', + ]; + $sectionTone = [ + 'added' => 'accent', 'changed' => 'cyan', 'fixed' => 'neutral', + 'security' => 'accent', 'removed' => 'neutral', 'deprecated' => 'neutral', + ]; + $sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g; + $tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral'; $repoHost = parse_url($repository, PHP_URL_HOST); $repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/'); @@ -92,7 +99,7 @@ @foreach ($rel['groups'] as $group => $items)
- {{ $group }} + {{ $sectionLabel($group) }}
    @foreach ($items as $text)