Compare commits

...

334 Commits

Author SHA1 Message Date
boban 97cc0091c0 Fix: 419 Session-Fehler leitet direkt zum Login weiter statt Livewire-Dialog 2026-04-30 18:41:19 +02:00
boban eeae972d8b Feat: RSpamd local_addrs (Loopback) automatisch in ensure_system setzen 2026-04-29 19:28:54 +02:00
boban 16fa6d3f4e Fix: Fehlerseiten als individuelle Dateien (4xx-Catch-all Laravel-Bug umgangen) 2026-04-29 19:05:49 +02:00
boban 04e28903ea Fix: Backup-Script direkt auf clubird-Namen umgestellt, Workaround entfernt 2026-04-29 18:59:37 +02:00
boban 4efc014a15 Fix: Backup-Dateiname von mailwolt_ auf clubird_ umbenennen nach externem Script 2026-04-29 18:54:56 +02:00
boban 2cdcb5ec38 Feat: Fehlerseiten 4xx + 5xx im CluBird-Design 2026-04-29 18:53:19 +02:00
boban c313582b0b Fix: UI-Routes auf UI-Domain beschränkt + config:cache nach Update 2026-04-29 18:47:21 +02:00
boban eff76f6568 Fix: Webmail-Root leitet auf Webmail-Login statt UI-Login weiter 2026-04-29 18:41:01 +02:00
boban c48a5c7e02 Fix: config-Key auf clubird umgestellt (Backup-Namen + Webmail-Domain-Binding) 2026-04-29 18:37:54 +02:00
boban d190beadcf Feat: Sidebar-Logo auf CluBird-Design + Syne-Font aktualisiert 2026-04-27 20:58:08 +02:00
boban f1c1cf55da Feat: --font-syne in @theme registriert 2026-04-27 20:55:39 +02:00
boban c94f90bcde Fix: Syne-Font lokal eingebunden statt Google Fonts 2026-04-27 20:38:57 +02:00
boban 0fedff759c Feat: CluBird-Logo + Syne-Font auf Login- und Webmail-Seite 2026-04-27 20:33:51 +02:00
boban dec95d5803 Feat: Domains-Button zeigt Spinner + disabled-State beim Speichern 2026-04-27 20:25:14 +02:00
boban 1ae3c7c54a Fix: mailwolt-apply-hostname in update.sh eingetragen (sbin + sudoers) 2026-04-27 20:20:02 +02:00
boban fce4f43833 Fix: mailwolt-apply-hostname Script + myhostname/aliases in apply-domains integriert 2026-04-27 20:15:21 +02:00
boban 6710827b81 Fix: Postfix myhostname + /etc/aliases beim Domain-Speichern automatisch aktualisieren 2026-04-27 20:09:03 +02:00
boban f20a30839f Fix: 2FA-Setup funktioniert jetzt korrekt — Profilseite + secret column fix 2026-04-27 19:17:29 +02:00
boban 3bb9afefc3 Fix: Logout-Methode in LoginController hinzugefügt 2026-04-27 19:02:31 +02:00
boban 5c8a03cc45 Fix: 503-Seite lädt nicht mehr blind alle 3s neu, sondern wartet bis /ping antwortet 2026-04-27 06:21:56 +02:00
boban 05cc37271c Fix: mailwolt-installer aus git-Index entfernt (eingebettetes Repo) 2026-04-27 06:18:10 +02:00
boban 7a89493efd Feat: Standard DKIM-Selektor von mwl1 auf clb1 geändert 2026-04-27 06:18:05 +02:00
boban afce3d9687 Feat: Quarantäne pro Zeile und pro Tab löschen (lokal via Cache) 2026-04-27 06:16:03 +02:00
boban 0beb12569b Feat: Quarantäne-Verlauf leeren Button (RSpamd historyreset) 2026-04-27 06:11:41 +02:00
boban be3502f197 Fix: Postfix mydestination mit vollem Pfad /usr/sbin/postconf und korrektem myhostname 2026-04-27 06:05:30 +02:00
boban 315608a108 Fix: Postfix mydestination ergänzt damit System-Mails lokal zugestellt werden 2026-04-27 05:58:15 +02:00
boban 30d2c28504 Fix: UpdateCard dispatcht Toast+closeModal gleichzeitig; Fail2ban Dienst-Badge nicht gestreckt 2026-04-27 05:39:02 +02:00
boban b7e411be82 Fix: artisan_up ans Ende verschoben – 503-Seite bleibt bis alles fertig ist 2026-04-27 05:30:53 +02:00
boban 93699108eb Feat: Fail2ban-Banlist mit Grid-Layout für saubere Spaltenausrichtung 2026-04-27 05:28:55 +02:00
boban ff28b98555 Fix: Fail2ban Restzeit via Python3-Script statt sqlite3-CLI (nicht installiert) 2026-04-27 05:20:10 +02:00
boban 3e157f4e56 Fix: Update-Modal wartet auf [DONE]-Marker im Log bevor done-State angezeigt wird 2026-04-27 04:17:21 +02:00
boban d84537b343 Fix: Fail2ban Restzeit-Fallback bei abgelaufenem DB-Eintrag + Update-Modal 2s Verzögerung vor done-State 2026-04-27 04:10:02 +02:00
boban 21015e69a3 Fix: Fail2ban SQLite-Abfrage nicht von is_readable abhängig machen (www-data hat kein Lesezugriff) 2026-04-27 04:05:55 +02:00
boban fc0ede4840 Feat: Fail2ban-Banlist zeigt Restlaufzeit und lesbaren Dienst-Namen 2026-04-27 04:03:01 +02:00
boban aaeea2bb86 Fix: Fail2Ban-Dateien auf clubird umbenannt (clubird-fail2ban, clubird-jails.local) 2026-04-27 03:54:53 +02:00
boban f266228fcf Fix: Fail2Ban Sudoers + Jails automatisch via ensure_system einrichten 2026-04-27 03:53:29 +02:00
boban 41de455826 Add: Logs-Button in UpdateCard + logos.svg 2026-04-27 03:50:20 +02:00
boban 49463bf34d Fix: Tab-URL-Keys auf Englisch (general, domains, backup, notifications) 2026-04-27 03:41:34 +02:00
boban eb77ec3342 Feat: Einstellungs-Tabs auf Deutsch (sicherung/meldungen) + Sidebars für alle Tabs 2026-04-27 03:40:40 +02:00
boban ff6002ca0b Feat: Einstellungen mit URL-Tabs (Allgemein, Domains & SSL, Backup, Benachrichtigungen) 2026-04-27 03:35:59 +02:00
boban 1f013b6dcd Fix: 503-Seite vollständig wiederhergestellt (fehlender Body/Ende-Tag) 2026-04-27 03:31:41 +02:00
boban dcb4456ba6 Fix: Update-Modal auf v403-Basis zurückgesetzt (ohne Slider) 2026-04-27 03:27:36 +02:00
boban 2fb66df19e Fix: Update-Modal – nur Ping-Animation und Slider entfernt, Basis v406 2026-04-27 03:25:57 +02:00
boban f756789cc4 Fix: Update-Modal ohne Slider und Ping-Dots, Spinner im Header 2026-04-27 03:18:22 +02:00
boban 32cf27bcd2 Fix: Update-Modal ohne Alpine.js – verhindert leeres Modal bei laufendem Update 2026-04-27 03:17:47 +02:00
boban 74600185ba Redesign: Update-Modal mit CluBird-Icon, Progress-Bar und besserem Log-Bereich 2026-04-27 03:14:28 +02:00
boban 919c2013f9 Design: 503-Seite finalisiert — nur Spinner, kein Bar/Dots 2026-04-27 03:11:31 +02:00
boban e23d9058ee Fix: 503-Seite zeigt immer Update-Ansicht — PHP-FPM Neustart erzeugt keinen Maintenance-Context 2026-04-27 02:58:36 +02:00
boban b48c74b676 Fix: 503 Wartungsmodus-Erkennung via storage/framework/maintenance.php statt Exception-Klasse 2026-04-27 02:57:28 +02:00
boban e49b1f6c4c Fix: 503-Seite unterscheidet Wartungsmodus (Update) von echtem Serverfehler 2026-04-27 02:55:29 +02:00
boban f473ec66d4 Fix: 503-Seite auf altes Layout zurück + CluBird-Icon + 3s Auto-Refresh 2026-04-27 02:54:49 +02:00
boban 22b6a9e0f2 Redesign: 503-Seite mit CluBird-Icon, Progress-Bar und Schritt-Anzeige 2026-04-27 02:52:20 +02:00
boban 93bcb56605 Rename: WoltGuard → CluGuard in UI-Labels 2026-04-27 02:48:48 +02:00
boban b6475fea75 Fix: Pipe-Signaturen durch $aliases ersetzen — clubird:* Befehle waren nicht aufrufbar 2026-04-27 02:46:38 +02:00
boban 14901a40e3 Fix: displayLatest nur anzeigen wenn tatsächlich Update verfügbar (kein Stale-Cache-Anzeige) 2026-04-27 02:30:46 +02:00
boban 20c9100226 Fix: doppeltes "v" in Dashboard-Versions-Anzeige
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:22:14 +02:00
boban 5dc55a9d1c Fix: Update-Erkennung via Gitea-API + Fallback-Kette verbessert
- CheckUpdates: Gitea REST-API als neuen Fallback (funktioniert ohne Auth
  bei öffentlichem Repo), git ls-remote als weiterer Fallback
- $remoteFile vor dem Fallback-Block deklariert (undefined variable fix)
- UpdateCard + UpdatePage: latNorm aus Cache normalisieren → kein doppeltes "v"
- UpdateCard: openLogs()-Methode hinzugefügt (Logs-Button im Dashboard)
- Setup-Wizard: Zeitzone automatisch aus Browser-Intl erkannt (@script)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 02:21:42 +02:00
boban fc7e1ed0f9 Fix: mailwolt:* als Aliase für clubird:* – Sbin-Scripts müssen nicht geändert werden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:57:31 +02:00
boban 5f71a8d5e9 Fix: Dovecot24-Migration ergänzt 10-master.conf, 10-ssl.conf + dovecot_storage_version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:45:41 +02:00
boban ea7dc3b94d Fix: Dovecot24-Migration ergänzt mail_location → mail_driver + mail_path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:34:32 +02:00
boban 4671909e14 Fix: Artisan-Kommando für Dovecot 2.4 Config-Migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:30:30 +02:00
boban 653bf55415 Refactor: Config-Dateipfade generisch – mailwolt-* Prefix entfernt
- rspamd: mailwolt-actions.conf → actions.conf
- dovecot: 99-mailwolt-tls.conf → 99-tls.conf
- postfix: mailwolt-tls.cf → tls.cf
- fail2ban: mailwolt-whitelist.local / 00-mailwolt-defaults.local → generic
- neu: clubird:migrate-config-names Artisan-Kommando für Server-Migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:13:56 +02:00
boban 978fad64f4 Fix: CheckUpdates nutzt version_raw als primäre Installationsversion + git fetch als Fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:12:08 +02:00
boban 23d6d9cb46 Design: Update-Card mit CluBird Logo + mw-* Farbsystem
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:04:40 +02:00
boban d3012792bc Fix: Artisan-Aufrufe von mailwolt:* auf clubird:* umgestellt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:04:09 +02:00
boban bbc7cc6c34 refactor: config/mailwolt.php → config/clubird.php + Artisan Commands umbenennen
- config/mailwolt.php → config/clubird.php (Datei umbenannt)
- Alle config('mailwolt.*') → config('clubird.*') ersetzt (15 PHP + 3 Blade)
- Artisan-Signaturen: mailwolt:* → clubird:* (5 Commands)
- Artisan-Aufrufe in Jobs + Scheduler aktualisiert
- Config-Cache neu aufgebaut

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:00:46 +02:00
boban 8ea925e4ae Fix: Toast Mittelmaß – 320px, badge kleiner als titel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:55:06 +02:00
boban c276a17a98 Fix: Toast kompakter + kein Umbruch bei Titel
- max-width 350→300px, padding/gap reduziert
- Icon 34→28px, Close-Btn 30→22px
- Titel-Span fix: notification-title-text statt notification-title (kein verschachtelter Flex mehr)
- Font-sizes: title 11.5px, text 10.5px, badge 9px
- Akzentfarben für success/warning/error/info angepasst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:49:52 +02:00
boban 597773123f feat: Rebranding Mailwolt → CluBird
- SVG-Dateien erstellt: icon, icon-sm, favicon, logo-dark, logo-sidebar
- Sidebar: CluBird-Vogellogo + Wordmark mit Indigo-Akzent
- Alle sichtbaren "Mailwolt"-Texte in Blade-Dateien auf CluBird geändert
- Favicon-Link in dvx.blade.php ergänzt
- CSS-Klassen (.mw-*), config/mailwolt.php und Sudo-Scripts bleiben unverändert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:44:42 +02:00
boban afad3c3471 Fix: Gap zwischen System-Domain und leerem Domains-State
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:35:57 +02:00
boban 8a546f4f3d Fix: DNS-Modal zeigt .. statt MTA-FQDN wenn env() gecacht ist
env('MTA_SUB') und env('BASE_DOMAIN') liefern null bei config:cache.
Jetzt: Setting::get('mail_domain') als primäre Quelle, config() als Fallback.
MTA_SUB in mailpool.php als config-Key ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:33:31 +02:00
boban 486166c05a feat: Toastra-Plugin eingebunden + DKIM-Regenerierung fix
- Toastra als Toast-System integriert (ersetzt GlassToastra)
- CSS auf var(--mw-*) Dark-Theme angepasst, keine Klassen entfernt
- Livewire-Adapter mappt done/warn/error/info auf Toastra-Typen
- DnsDkim: doppelter sudo-install-dkim entfernt (DkimService macht es bereits)
- dkimReady() prüft Storage-Pfad statt sudo-Test (kein Sudoers-Eintrag nötig)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:29:29 +02:00
boban f375ea0215 Fix: Ein Toast mit Fehlerzusammenfassung statt kein Feedback bei Fehlern
applyDomains + syncSysmailDomain geben Fehler zurück statt Toasts zu dispatchen.
saveDomains zeigt am Ende einen Toast: grün wenn alles ok, orange mit Details bei Problemen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:14:44 +02:00
boban fa8e9e7e88 Fix: DKIM-Fehler blockiert nicht mehr Domain-Erstellung + nur 1 Toast beim Speichern
- DomainObserver: DKIM-install-Fehler wird geloggt statt Domain-Create zu blockieren
- saveDomains: nur noch 1 Toast, alle Zwischen-Toasts (Nginx, DNS, Sysmail) entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 00:02:12 +02:00
boban 742c39f91a Fix: BASE_DOMAIN wird beim Speichern der Domains aus mail_domain abgeleitet
War auf Live-Server noch 'example.com' weshalb sysmail nie angelegt wurde.
mx.pxo.at → BASE_DOMAIN=pxo.at, MTA_SUB=mx automatisch gesetzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:53:05 +02:00
boban 2ec10a8a81 Fix: syncSysmailDomain mit Fallback für fehlende BASE_DOMAIN + Toast bei Fehler
Leitet BASE_DOMAIN aus config → env → mail_domain-Setting ab.
Gibt Toast-Meldung aus wenn Seeder fehlschlägt, statt still zu scheitern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:51:16 +02:00
boban fb6573598d Feat: syncSysmailDomain bei saveDomains – anlegen, umbenennen oder überspringen
Beim Speichern der Domains wird geprüft ob sysmail.<BASE_DOMAIN> bereits stimmt.
Falls nicht vorhanden: anlegen. Falls Domain geändert: alte löschen + neu erstellen.
Falls bereits korrekt: nichts tun.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:46:17 +02:00
boban 6bb2d09621 Fix: system_notify_email Accessor statt nicht-existenter notifyEmail()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:41:37 +02:00
boban cdfdcc0756 Fix: Benachrichtigungs-Defaults – User-Email + sinnvoller Absendername
Absendername-Default: '{Instanzname} Benachrichtigung'.
Admin-Email-Default: Login-Email des eingeloggten Users (notifyEmail()).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:40:45 +02:00
boban c6d6798898 Feat: Benachrichtigungs-Einstellungen + sysmail.BASE_DOMAIN zurückgesetzt
- Absendername und Admin-E-Mail konfigurierbar in Einstellungen
- MAIL_FROM_NAME wird automatisch in .env synchronisiert
- Sysmail-Domain wieder sysmail.<BASE_DOMAIN> (nicht MTA-FQDN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:37:55 +02:00
boban 496912f5ca Fix: Webmail-Icon bei System-Domain entfernt
System-Domain ist nur zum Versenden (no-reply), Webmail-Zugriff macht dort keinen Sinn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:31:55 +02:00
boban 2c82555235 Fix: SystemDomainSeeder läuft beim Installer-Abschluss, nicht in den Settings
sysmail.<MTA_FQDN> wird jetzt automatisch beim Ende einer erfolgreichen Installation
angelegt – nicht nachträglich beim Speichern der Domain-Einstellungen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:27:52 +02:00
boban eb17d056b7 Fix: Sysmail-Domain basiert auf Mail-Domain (sysmail.<MTA_FQDN>)
Statt sysmail.<BASE_DOMAIN> wird jetzt sysmail.<MTA_SUB>.<BASE_DOMAIN> angelegt
(z.B. sysmail.mx.pxo.at statt sysmail.pxo.at) – Base-Domain des Users bleibt unangetastet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:25:35 +02:00
boban 96477ede1c Feat: SystemDomainSeeder wird bei saveDomains ausgeführt wenn sysmail fehlt
Beim Speichern der Server-Domains in den Einstellungen wird automatisch die
sysmail-Domain (sysmail.<BASE_DOMAIN>) mit DKIM, SPF, DMARC und no-reply Postfach
angelegt, sofern sie noch nicht existiert. Seeder nutzt updateOrCreate für Postfach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:20:44 +02:00
boban 8ff63dd966 Revert: Sysmail-Auto-Provisionierung entfernt – war konzeptionell falsch
Sysmail wird nur für die Server-eigene Domain beim Setup angelegt, nicht für User-Domains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:12:39 +02:00
boban 50a68047fc Feat: Sysmail-Domain wird automatisch angelegt wenn eine Maildomain erstellt wird
Beim Anlegen einer neuen User-Domain (kein is_system, kein is_server) wird automatisch
'sysmail.[domain]' als System-Domain mit DKIM/SPF/DMARC (via DomainObserver) und
das Postfach 'sysmail@sysmail.[domain]' angelegt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 23:03:58 +02:00
boban a2ba9a7c03 UI: Update-Seite überarbeitet – sauberes mbx-Design, eine Karte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:52:34 +02:00
boban ea37abacf8 Fix: Livewire multiple root elements – style-Tag in Root-div verschoben
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:50:42 +02:00
boban 32a3b51b2a UI: Update-Seite im korrekten mw-Design – 2-spaltig, responsiv
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:50:06 +02:00
boban 0375aed2ce UI: Update-Seite komplett neu gestaltet – responsiv, mobile-first
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:46:53 +02:00
boban fd8d81fecb Fix: Update-Modal öffnet immer nach Update + optimize nach Post-Actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:41:37 +02:00
boban fcbe944e32 Fix: optimize:clear+optimize nach PHP-FPM Restart ausführen (kein 404)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:38:15 +02:00
boban 598f0edacf Fix: mailwolt-sandbox-sync via update.sh automatisch installieren
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:34:52 +02:00
boban 00f715480f Fix: Sandbox Postfix-Sync via sudo-Helper (Permission denied)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:30:26 +02:00
boban 9673e717d1 Fix: UpdateCard-Status korrekt + Dashboard-Card zeigt neue Version an
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:24:39 +02:00
boban bf76e93103 Fix: Version-Datei wird bei jedem Start selbst aktualisiert
AppServiceProvider schreibt /var/lib/mailwolt/version wenn der git-Tag
neuer ist. Kein manuelles Eingreifen mehr nötig auf Dev-Server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:08:28 +02:00
boban dbd7848f68 Fix: UpdateCard + CheckUpdates bevorzugen git-Tag über veraltete Version-Datei
Dev-Server zeigte v1.0.137 weil /var/lib/mailwolt/version nie aktualisiert
wurde. git describe --tags liefert immer die korrekte aktuelle Version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 22:03:09 +02:00
boban cae269fae1 Fix: App-Version bevorzugt git-Tag wenn neuer als Version-Datei
Lokalserver zeigte 1.0.137 weil /var/lib/mailwolt/version nie durch
update.sh aktualisiert wurde. git describe --tags liefert immer den
aktuellen Tag direkt aus dem Repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:59:04 +02:00
boban 77fad53655 Fix: ClamAV-Socket false-positive + --check-only als root + Backup sudoers
ClamAV: nur systemd-Probe – Socket-Datei bleibt nach Stop erhalten.
--check-only: git ls-remote als root statt www-data (keine Credentials).
Backup: /etc/sudoers.d/mailwolt immer anlegen, unabhängig von certbot/dkim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:52:02 +02:00
boban de314adebe Fix: Backup läuft als root via sudo – keine Permission-Fehler mehr
mailwolt-backup Script liest /etc/dovecot/private, /etc/letsencrypt/live
etc. als root. ensure_system() installiert es nach sbin + sudoers.
BackupRun.php ruft es via sudo -n auf.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:37:27 +02:00
boban 7e7d5c1069 Fix: CheckUpdates nutzt exec() statt shell_exec() für --check-only
shell_exec kann auf PHP-FPM Servern deaktiviert sein, wodurch
version_remote nie aktualisiert wurde und kein Update angezeigt wurde.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:36:08 +02:00
boban aaccb4bb32 Fix: optionale Dienste (ClamAV) immer live prüfen statt Monit-Cache
Monit-Cache wurde jede Minute neu befüllt mit altem Status.
Optionale Dienste werden jetzt direkt via systemctl geprüft.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:33:11 +02:00
boban 7ccc7921c1 Fix: health:services Cache nach ClamAV enable/disable leeren
Dashboard zeigte ClamAV bis zu 5 Min. falsch als online/offline weil
der Monit-Cache veraltet war. Cache wird jetzt sofort invalidiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:29:33 +02:00
boban a96d8f690a Fix: Wartungsmodus immer aktiv während Cache-Rebuild – kein 404 mehr
artisan down/up umschließt jetzt immer fix_permissions, nicht nur bei
Migrations/Composer/PHP-Restart. Requests während optimize:clear+optimize
bekommen 503 statt 404 durch fehlenden Bootstrap-Cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:26:42 +02:00
boban b0daf839d9 Fix: ensure_system() schreibt Versionsdateien nach jedem Update-Lauf
/var/lib/mailwolt/version blieb bei manuellem git pull oder abgebrochenem
Update auf altem Stand. UpdateCard-Vergleich schlug dadurch fehl und
zeigte kein "Update verfügbar" an.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:24:20 +02:00
boban ce66a84c8d Fix: ClamAV wird beim Update automatisch installiert, UI nur Ein/Aus
ensure_system() installiert clamav-daemon falls nicht vorhanden,
deaktiviert aber den Dienst. User sieht nur noch Ein/Ausschalten.
Install-Button, $installed-Property und install()-Methode entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:19:26 +02:00
boban fa65bc1c2e Fix: ClamAV isInstalled() via file_exists statt Hardcode true
Prüft /usr/sbin/clamd und systemd unit-Dateien ohne exec().
Zeigt Install-Button wenn ClamAV nicht installiert ist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:17:24 +02:00
boban 5f627dcb0e Fix: ClamAV enable mit --no-block + kein Queue-Job nötig
systemctl start --no-block kehrt sofort zurück, systemd startet ClamAV
im Hintergrund. runCmd('enable') direkt aufrufen statt Queue-Job.
wire:poll.keep-alive verhindert payload-Fehler in der Browser-Console.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:15:18 +02:00
boban 3f6f787fd3 Fix: ClamAV starting-State überlebt Reload + kein false-dirty bei untracked
Flag-Datei /tmp/mw-clamav-starting speichert Startzeitpunkt sodass
Spinner + Timer auch nach Seitenreload korrekt weiterläuft.
git_dirty_check filtert jetzt untracked Dateien (??) heraus damit
mailwolt-installer/ und ähnliche nicht fälschlich als dirty gelten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:11:55 +02:00
boban 2331ef74f6 Feat: ClamAV Aktivieren mit Spinner + Sekundentimer bis ClamAV läuft
wire:poll.3s prüft serviceActive() nach Klick. Zeigt laufenden Spinner
mit Sekundenanzeige (0s, 3s, 6s…) bis ClamAV aktiv ist, dann Erfolgsmeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:08:46 +02:00
boban 029d959d51 Fix: ClamAV enable via Queue Job statt shell_exec im Web-Request
nohup/shell_exec wird von PHP-FPM-Prozessgroup nach Request-Ende gekillt.
ClamavEnable Job läuft im Queue-Worker und blockiert nicht den Web-Request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:00:33 +02:00
boban d0e1d5613c Fix: ClamAV – kein blockierendes Overlay mehr, Spinner nur auf Klick
Vollflächiges wire:loading-Overlay entfernt das beim Seitenaufruf
blockierte. wire:loading.class animate-spin nur noch auf wire:target=refresh
beschränkt damit kein Spinner beim Laden erscheint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:56:59 +02:00
boban 68e7635f6d Fix: ClamAV enable() blockiert nicht mehr – shell_exec + nohup + </dev/null
exec() wartet auch mit & auf Prozessende. shell_exec mit nohup und
stdin-Redirect auf /dev/null stellt echtes Hintergrund-Detaching sicher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:54:42 +02:00
boban 673e9ec1df Fix: ClamAV enable() – kein Spinner mehr, einfache Textmeldung
$starting-Property, pollStatus() und wire:poll entfernt.
enable() startet ClamAV im Hintergrund und zeigt nur eine
Info-Meldung an – kein dauerhafter Spinner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:53:35 +02:00
boban 0fc6ee81d6 Fix: wire:poll nur innerhalb @if($starting) – kein dauerhafter Poll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:49:06 +02:00
boban 79b1eeb93c Fix: ClamAV enable non-blocking – Hintergrund-Start + Poll alle 3s
- enable() startet Dienst via nohup im Hintergrund (kein Request-Timeout)
- $starting=true → wire:poll.3s ruft pollStatus() auf
- Overlay zeigt "ClamAV wird gestartet… lädt Viren-DB / bis 60s"
- pollStatus() erkennt wenn Dienst läuft → Overlay weg, Erfolg-Banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:44:56 +02:00
boban dc5d8582e1 Fix: ClamAV Feedback – Loading-Overlay + Erfolg/Fehler-Banner inline
- Loading-Overlay über Status-Karte beim Aktivieren/Deaktivieren/Installieren
- $lastSuccess Property für grünen Erfolg-Banner (kein Toast nötig)
- runCmd() helper mit Log für Debugging
- Fehler-Text white-space:pre-wrap für mehrzeilige Ausgaben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:42:45 +02:00
boban a9ad26757b Fix: installed immer true – Enable/Disable immer sichtbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:37:10 +02:00
boban 1bcd93908e Fix: isInstalled via systemctl list-unit-files statt Dateipfad-Check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:35:34 +02:00
boban 7d738392b2 Fix: isInstalled() prüft systemd-Unit statt Binary-Pfade
Zuverlässiger auf Servern wo PHP-FPM keinen Zugriff auf /usr/bin hat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:33:37 +02:00
boban 6fa77f9f9b Feature: ClamAV Installation direkt aus der UI + install-Befehl im Wrapper
- mailwolt-clamav: install) apt-get install clamav clamav-daemon clamav-freshclam
- ClamavManager: install() Methode + $installing State
- Blade: Installieren-Button wenn nicht installiert (statt nur Text-Hinweis)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:32:30 +02:00
boban b721b7b0df Fix: ensure_system immer am Anfang – sbin kann nie mehr veralten
- ensure_system() jetzt direkt nach git_safe/git_dirty_check aufgerufen
- Sbin wird bei JEDEM Update-Aufruf aktualisiert, nicht nur auf bestimmten Pfaden
- Root-Ursache: alte sbin (v1.0.137, April 23) hatte kein ensure_system → alle
  Fixes in scripts/update.sh wurden nie auf dem Server ausgeführt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:25:13 +02:00
boban ad85bc0326 Fix: _cleanup überschreibt keinen frisch gebauten Cache mehr
- fix_ownership() getrennt von fix_permissions() (nur chown/chmod, kein optimize)
- _cleanup: bei Erfolg nur fix_ownership, bei Fehler fix_permissions (mit optimize)
- Verhindert dass _cleanup nach erfolgreichem Update den View-Cache wieder löscht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:20:21 +02:00
boban 7b1274f349 Fix: ClamAV Button-Styles korrigiert + mbx-btn-danger CSS-Klasse
- Aktualisieren + Jetzt: mbx-act-btn (icon-only 28px) → mbx-btn-mute (text+icon)
- Deaktivieren: mbx-act-danger (kein base-style) → mbx-btn-danger (neue Klasse)
- mbx-btn-danger: transparent mit rotem Border/Text, flex align-items:center

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:03:10 +02:00
boban cbdab12c19 UI: ClamAV-Seite Layout verbessert – Sidebar mit RAM-Hinweis + Info
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:59:14 +02:00
boban b83af57e56 Fix: PHP-FPM startet erst nach fix_permissions – kein 404-Fenster mehr
- fix_permissions (chown + optimize) läuft jetzt VOR restart_php_fpm
- config:cache + route:cache aus dem Update-Flow entfernt (fix_permissions/optimize übernimmt das)
- artisan up erfolgt NACH PHP-FPM Restart – App geht erst online wenn alles korrekt ist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:53:19 +02:00
boban 0635d1d9fe Fix: ClamAV in Sidebar + optimize nach optimize:clear – kein 404 nach Update
- dvx.blade.php: Virenschutz-Link (ClamAV) in Sicherheits-Sektion eingefügt
- fix_permissions: optimize:clear + optimize hintereinander – Cache wird nach
  Permissions-Fix neu aufgebaut, kein leerer/fehlender Cache mehr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:49:12 +02:00
boban a771f97516 Fix: fix_permissions ohne route:cache/config:cache – kein 404/500 mehr nach Update
- Chownt alle App-Verzeichnisse (app, bootstrap, config, db, public, resources, routes, scripts, storage) + Key-Files nach jedem git-Pull
- Entfernt config:cache + route:cache aus fix_permissions (Fehlerquelle für 404/500)
- Nur noch optimize:clear – Laravel lazy-rebuilt Config/Routes beim nächsten Request
- APP_GROUP-Bug korrigiert: optimize:clear lief als APP_GROUP statt APP_USER

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:44:50 +02:00
boban 9d40597842 Fix: npm --cache explizit auf APP_DIR/.npm-cache – kein HOME-Schreibfehler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:39:28 +02:00
boban 140d737231 Fix: npm-Cache-Verzeichnis vor Build dem APP_USER zuweisen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:38:22 +02:00
boban 09f9cf3553 Fix: Frontend-Build-Sicherung + Auto-Rebuild bei fehlendem manifest.json
- frontend_build_quiet: altes public/build/ als .bak sichern,
  bei npm-Fehler wiederherstellen → Site bleibt immer erreichbar
- fix_permissions: wenn manifest.json fehlt, automatisch neu bauen
  → kein manueller Eingriff nach fehlgeschlagenem Update nötig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:37:44 +02:00
boban 1d18e80749 Fix: fix_permissions in ensure_system – läuft auch mit veraltetem sbin
ensure_system() wird bei JEDEM Update aufgerufen, selbst wenn kein
Code geändert wurde. fix_permissions dort garantiert saubere Rechte
und frischen Cache unabhängig von der sbin-Version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:32:46 +02:00
boban 190b627fd4 Fix: git-Ops als root, fix_permissions immer im Exit-Trap
- git_safe: chown -R APP_DIR immer (kein bedingter Check)
- git_dirty_check + alle git fetch/checkout: als root → kein
  "permission denied" / "dubious ownership" mehr
- _cleanup: fix_permissions() immer aufrufen (auch bei Fehler-Abbruch)
  → kein 404/500 nach fehlgeschlagenem Update mehr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:30:57 +02:00
boban a119686bfa Fix: git_safe() korrigiert Eigentümer automatisch bei User-Wechsel
chown -R nur wenn APP_DIR nicht dem APP_USER gehört, sonst nur .git.
Verhindert "Your local changes would be overwritten" nach root-Läufen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:25:32 +02:00
boban b107e9a580 Feature: ClamAV-Verwaltung in Sicherheit-Sidebar
- Neue Seite /security/clamav: Status, Aktivieren/Deaktivieren,
  Signatur-Update, RAM-Hinweis, Info-Box
- Optionale Dienste (ClamAV) im Dashboard nur sichtbar wenn aktiv
- mailwolt-clamav sbin-Wrapper + sudoers-Regel in ensure_system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:21:37 +02:00
boban a7f6d8e242 Add: ClamAV in Dashboard-Dienste-Liste
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:15:44 +02:00
boban 3e12d7fd70 Fix: Dashboard zeigt nur die 8 Kern-Dienste (woltguard.dashboard-Liste)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:11:44 +02:00
boban c16315711d Fix: git_safe() system-level safe.directory + chown .git für APP_USER-Wechsel
Nach APP_USER-Wechsel von mailwolt→www-data schlägt git mit "dubious
ownership" fehl. git config --system schreibt /etc/gitconfig (root),
gilt für alle User. Zusätzlich .git vollständig neu besitzen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:07:58 +02:00
boban 3c6325db32 Fix: Dashboard zeigt Dienste auch ohne Monit (Fallback auf systemd/tcp-Probes)
- loadServices() liest Monit-Cache, fällt zurück auf woltguard.php Karten
  mit direkten systemd/tcp-Probes wenn Cache leer ist
- Monit als Dienst in woltguard.php ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:02:51 +02:00
boban a51f271069 Fix: APP_USER default auf www-data – kein 404 mehr nach Update
Artisan-Befehle (config:cache, route:cache, optimize:clear) liefen als
'mailwolt'-User, PHP-FPM läuft als 'www-data' → Cache-Dateien nicht
lesbar → 404 nach jedem Update. Default auf www-data gesetzt damit
beide User übereinstimmen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:55:56 +02:00
boban e0128312cf Fix: fix_permissions baut Cache als www-data + setgid auf cache/storage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:51:30 +02:00
boban c834b5f85d Fix: fix_permissions läuft nach jedem Update-Pfad (kein 404 mehr nach Update)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:46:33 +02:00
boban 7454638506 Fix: bootstrap/cache Rechte nach artisan-Befehlen für www-data freigeben
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:43:38 +02:00
boban f49b92074e Fix: ensure_system installiert mailwolt-ws (Reverb) als systemd-Service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:41:23 +02:00
boban 29566499f9 Fix: artisan cache-Befehle in apply-domains als App-User statt root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:37:49 +02:00
boban 81f1c9512a Fix: apply-domains mit Backup/Restore + robuster Cert-Erkennung
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:27:36 +02:00
boban 1e053c2bb6 Fix: nginx /ws/ Reverb-Proxy in apply-domains + health:probe-disk --ttl entfernt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:20:03 +02:00
boban 2369a29161 Fix: migrate-env-reverb prüft auch REVERB_PORT beim Skip-Check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:15:56 +02:00
boban 26eb3abdcd Fix: Scheme-Erkennung via APP_URL + nginx-Config statt letsencrypt-Pfad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:13:26 +02:00
boban 4fc31726be Fix: Cert-Prüfung via renewal/-Verzeichnis (www-data-lesbar) statt live/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:10:32 +02:00
boban 8fa28a4d84 Fix: migrate-env-reverb liest Domain aus DB-Setting ui_domain als Fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:04:52 +02:00
boban 60ebd0ed16 Fix: mailwolt:migrate-env-reverb Command + Update führt Migration immer aus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:01:57 +02:00
boban 093b5a9eea Fix: Update migriert REVERB .env-Werte automatisch auf Domain-Basis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:58:06 +02:00
boban becc1bd737 Fix: apply-domains korrigiert alle VITE_REVERB_* und REVERB_* .env-Werte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:55:40 +02:00
boban b59e4c53b4 Fix: mailwolt-apply-domains aktualisiert APP_HOST in .env und baut Assets neu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:50:04 +02:00
boban 29ef2b6a2c Fix: CheckUpdates liest Version-Datei vor git-describe (APP_ENV=local Bug)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:30:07 +02:00
boban 1b79454df5 Fix: Blade-Syntaxfehler in SSL-Seite (@if/@elseif/@endif inline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:22:44 +02:00
boban 38d6fdba6f Fix: SSL-Seite zweispaltig (Tabelle links, Einrichten rechts) + Settings kompakt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:20:28 +02:00
boban 996c9c19fe Fix: Laravel Scheduler cron + sudoers für --check-only + CheckUpdates Fallback
- ensure_system() installiert /etc/cron.d/mailwolt für schedule:run
- sudoers: www-data darf mailwolt-update --check-only aufrufen
- CheckUpdates: if/elseif → echter Fallback auf mailwolt-fetch-tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:13:05 +02:00
boban 871d69cc2d Fix: Korrekte Route-Namen ui.security.ssl + ui.system.settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:09:25 +02:00
boban f12c412bbe Fix: Route-Name security.ssl + SSL-Seite zweispaltig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:07:11 +02:00
boban 7f56926b70 Refactor: SSL-Verwaltung nach Security/SSL verschoben
- Zertifikate einrichten/erneuern nur noch unter Sicherheit → SSL/TLS
- SSL-Seite: Provisioning mit Fortschritt, Ablaufdatum + Tage in Tabelle
- Einstellungen: nur noch read-only Status + Link zu SSL-Seite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:01:58 +02:00
boban 904d60ed2b Fix: 503 fetch-Interceptor + Auto-Update-Polling + SSL-Banner prüft echte Certs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:26:20 +02:00
boban 8fd9fccc70 Fix: Livewire 503 löst echten Page-Reload aus statt Modal-Rendering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:17:00 +02:00
boban 227c623578 Feat: Wartungsseite beim Update statt roher 503
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:13:46 +02:00
boban d60d228012 Fix: mx-Domain bekommt LE-Zertifikat + Postfix/Dovecot werden konfiguriert
- mailwolt-apply-domains: MAIL_HOST wird in ACME-Challenge-Block aufgenommen,
  certbot wird auch für die Mail-Domain ausgeführt, Postfix + Dovecot erhalten
  danach automatisch das neue Zertifikat
- SslCertificatesTable: certbot-Ausgabe korrekt geparst (Einrückung mit Leerzeichen)
- settings-form: "kein Zertifikat nötig" entfernt (Mail-Domain braucht Zertifikat)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:04:02 +02:00
boban 96e2b4d5ab Fix: .git/objects-Rechte werden automatisch repariert (root-Läufe)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:57:29 +02:00
boban 5ec7084cbd Fix: mailwolt-update schreibt jetzt alle Ausgaben in Log-Datei
exec > >(tee -a LOG_FILE) leitet stdout an tee weiter:
- CLI: Ausgabe weiterhin im Terminal + in Log-Datei
- UI (nohup >/dev/null): stdout geht nach /dev/null aber tee
  schreibt trotzdem in die Log-Datei

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:54:09 +02:00
boban 240c672198 Fix: Update-UI zeigt alte Version / schließt sofort ab (Race Condition)
Problem: state-Datei vom letzten Update zeigte noch 'done'. pollUpdate()
las das direkt nach runUpdate() als "Update fertig" — bevor der neue
Prozess überhaupt startete. displayCurrent war dann noch die alte Version.

Fix:
- runUpdate() setzt state='starting' und löscht rc VOR dem Shell-Aufruf
- runUpdate() setzt rc=null, lowState='starting', postActionsDone=false zurück
- refreshLowLevelState(): Kommentar klärt dass 'starting' als running gilt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:50:44 +02:00
boban bd7bb50a52 Fix: SSL-Cert-Status robuster + Provisioning-State nach Reload erhalten
- loadSslStatus(): clearstatcache() + renewal-Conf als primärer Existenzcheck
  (renewal/ ist immer 755, zuverlässiger als is_dir auf live/ das 750 sein kann)
  + /usr/bin/openssl (voller Pfad für sudoers-Matching)
- restoreSslProvisioningState(): stellt letzten Provisioning-Zustand aus
  State-Dateien wieder her, so dass Status nach Page-Reload sichtbar bleibt
- Button zeigt nach Abschluss "Erneut einrichten" statt disabled zu bleiben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:44:08 +02:00
boban 17ed5b18d9 Fix: ensure_system() läuft bei JEDER mailwolt-update Ausführung
Scripts, Sudoers-Regeln und sbin-Binaries werden jetzt immer
aktualisiert — auch wenn kein Update verfügbar ist. Damit entfällt
das manuelle Nachtragen von Sudoers-Regeln nach neuen Releases.

ensure_system() wird aufgerufen:
- Vor jedem "bereits aktuell"-Exit (tags + branch mode)
- Am Ende jedes erfolgreichen Updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:34:09 +02:00
boban 096e983313 Fix: SSL-Cert-Status zeigt 'Fehlt' obwohl Cert vorhanden
Problem: /etc/letsencrypt/archive/ ist chmod 700 (root only).
file_exists() auf Symlinks in live/ schlägt fehl weil Symlink-Ziel
in archive/ nicht lesbar ist. is_dir() auf live/domain/ funktioniert
da das Verzeichnis selbst 755 ist.

- SettingsForm: file_exists() → is_dir() für Existenzcheck
- SettingsForm: openssl-Aufruf via sudo -n (archive/ ist root-only)
- installer.sh + update.sh: sudoers-Regel für openssl auf LE-Cert-Pfade
  (www-data darf nur exakt diesen openssl-Aufruf, keine anderen Pfade)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:27:49 +02:00
boban 880b99f1e0 Fix: git_dirty_check bricht nicht mehr mit rc=2 ab
Auf Produktivservern gibt es fast immer kleine Änderungen (App-generierte
Dateien, Setup-Änderungen). Statt Abbruch: tracked Dateien via git reset
--hard zurücksetzen, untracked Nicht-Systemdateien via git clean entfernen.
.env, storage/ und node_modules/ werden dabei bewusst ausgespart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:21:45 +02:00
boban 2688b2528b Fix: artisan down ohne --render=errors.503 (View existiert nicht)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:16:49 +02:00
boban ae3b1e6b14 Fix: Update-Check Bootstrap — --check-only Flag in mailwolt-update
Problem: Henne-Ei-Situation — alte Server haben alte CheckUpdates.php die
  git fetch als www-data aufruft (schlägt wegen fehlender Credentials fehl).
  Neue mailwolt-fetch-tags Helper sind noch nicht deployed.

Lösung:
- scripts/update.sh: --check-only Flag — ruft nur git ls-remote als App-User
  auf, schreibt version_remote, beendet sich sofort (kein Update)
- update.sh: schreibt version_remote auch nach normalem Tag-Fetch (damit es
  nach manuellen Updates aktuell bleibt)
- CheckUpdates.php: sichere Fallback-Kette:
  1. mailwolt-update --check-only (nur wenn Skript das Flag kennt — kein
     versehentliches Triggern auf alten Servern)
  2. mailwolt-fetch-tags (ab v1.1.268)
  3. lokale git tags (letzter Fallback)
  + version_remote wird nur verwendet wenn < 2h alt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:13:32 +02:00
boban f740ffc753 Feature: Dedizierter SSL-Abschnitt in Einstellungen mit Echtzeit-Fortschritt
- Neuer Abschnitt "SSL-Zertifikate" in den Einstellungen:
  · Zeigt pro Domain (UI, Webmail, Mailserver) ob LE-Cert vorhanden ist,
    Ablaufdatum und Status (OK / fehlt / läuft ab / abgelaufen)
  · Button "Let's Encrypt Zertifikate einrichten" startet Provisioning
    im Hintergrund (nohup, non-blocking)
  · Live-Fortschrittsanzeige per wire:poll.2s mit Status-Icons pro Domain
    (pending → running → done/error/skip)
- saveDomains() ruft apply-domains jetzt ohne certbot auf (--ssl-auto 0) —
  Domains speichern und SSL einrichten sind damit getrennte Aktionen
- loadSslStatus() liest Certbot-Zertifikat-Info direkt aus openssl
- spin-Keyframe für Spinner-Animation in app.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:04:58 +02:00
boban c4a2f33293 Fix: Update-Check via mailwolt-fetch-tags + Fehlerdetails in Settings-Toast
- Neu: scripts/mailwolt-fetch-tags ruft git ls-remote als App-User auf (hat Credentials),
  schreibt neuesten Tag nach /var/lib/mailwolt/version_remote
- CheckUpdates.php nutzt jetzt sudo mailwolt-fetch-tags statt direktem git fetch als www-data
  (www-data hat keine Git-Credentials für private Repos)
- SettingsForm.php zeigt tatsächliche Fehlerzeilen ([!], error, failed) im Toast statt
  generischer "fehlgeschlagen"-Meldung
- installer.sh + update.sh installieren mailwolt-fetch-tags nach /usr/local/sbin/
- update.sh trägt mailwolt-fetch-tags automatisch in sudoers nach (Upgrade-Pfad)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 07:46:18 +02:00
boban 831f656b54 Fix: mailwolt-apply-domains — dig-Fallback + fertig-Echo
- certbot_safe(): dig ist auf Ubuntu nicht immer installiert (kein dnsutils).
  Mit set -euo pipefail crashte die Funktion lautlos → certbot nie ausgeführt.
  Fallback auf getent ahostsv6 wenn dig fehlt; Zuweisung mit || has_aaaa=""
  damit set -e nicht greift falls beide Befehle scheitern.

- echo "mailwolt-apply-domains fertig" am Ende: SettingsForm.php prüft ob
  "fertig" im Output steht um ssl_configured=1 zu setzen — ohne diesen Echo
  wurde SSL nie als konfiguriert markiert, selbst wenn Zertifikate vorhanden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 07:26:44 +02:00
boban 5511498200 Refactor: update.sh nach scripts/ verschoben
Alle Scripts zentral unter scripts/:
- scripts/update.sh (war update.sh)
- scripts/mailwolt-apply-domains

installer.sh und update.sh selbst auf neuen Pfad angepasst.

Update laufender Server (einmalig):
  sudo install -m 755 /var/www/mailwolt/scripts/update.sh /usr/local/sbin/mailwolt-update
  sudo install -m 755 /var/www/mailwolt/scripts/mailwolt-apply-domains /usr/local/sbin/mailwolt-apply-domains

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 07:04:37 +02:00
boban 3b0ebce1df Fix: update.sh aktualisiert sich selbst + scripts/ immer am Ende
Beide Scripts werden jetzt bei jedem erfolgreichen Update eingespielt,
unabhängig von CHANGED_FILES — löst den Bootstrap-Problem wo die alte
mailwolt-update Version die neue Logik nicht kannte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 06:58:15 +02:00
boban 22eee053a3 Fix: update.sh aktualisiert scripts/ automatisch nach git pull
Bei Änderungen unter scripts/ wird mailwolt-apply-domains automatisch
nach /usr/local/sbin/ kopiert — kein manueller Eingriff mehr nötig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:23:15 +02:00
boban 05cc53ef49 Refactor: mailwolt-apply-domains als eigenständige Datei im Repo
Script aus installer.sh-Heredoc in scripts/mailwolt-apply-domains ausgelagert.
installer.sh kopiert es jetzt via install -m 755 statt Heredoc.

Vorteile:
- git pull + sudo cp reicht um das Script auf laufenden Servern zu aktualisieren
- Keine doppelte Pflege mehr (Heredoc vs. Datei)
- Änderungen direkt im Script-File sichtbar (git diff)

Update laufender Server:
  sudo install -m 755 /var/www/mailwolt/scripts/mailwolt-apply-domains \
    /usr/local/sbin/mailwolt-apply-domains

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:20:09 +02:00
boban 8a654bef89 Fix: Wizard Step 5 — korrekte Fehlermeldungen bei SSL-Fehler
installer.sh / mailwolt-apply-domains:
- State-Dateien jetzt korrekt pro Domain: "done" nur wenn LE-Cert existiert,
  sonst "error" — verhindert grüne Checkmarks bei fehlgeschlagenem certbot
- mail-Domain: "skip" statt fälschlich "done" (certbot läuft nicht für MX im Wizard)
- sleep 6 nur wenn Cert ausgestellt wurde (nginx-HTTPS-Switch nötig)

WizardDomains.php:
- Bei frühem DNS-Abbruch: verbleibende "running"-Domains auf "error" setzen
  statt ewig als "Wird registriert…" hängen zu bleiben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:04:30 +02:00
boban 72973e3ca5 Fix: installer.sh — UI_HAS_CERT unbound variable in mailwolt-apply-domains
UI_HAS_CERT/WM_HAS_CERT wurden im Subshell ( ... ) > NGINX_SITE definiert.
Nach dem Subshell waren sie im Outer Scope ungebunden — mit set -euo pipefail
führte das zu "unbound variable" Crash bei der State-Datei-Zuweisung.
Variablen vor den Subshell in den Outer Scope verschoben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:18:48 +02:00
boban 8ff1aeac2a Fix: Wizard Step 5 — per-Domain-Fortschritt, Cert-Fallback, ssl_configured
- mailwolt-apply-domains schreibt jetzt pro Domain running/done/error/nodns
  in die State-Dateien während certbot läuft (statt alles auf einmal am Ende)
- get_cert_dir() erstellt fullchain.pem/privkey.pem Symlinks auf cert.pem/key.pem
  wenn kein LE-Zertifikat vorhanden — verhindert nginx-t-Fehler und kaputten Redirect
- WizardDomains.php: ssl_configured wird jetzt anhand /etc/letsencrypt/live/ geprüft
  statt per Shell-Output (der wegen exec>>LOG immer leer war)
- Shell-Script schreibt done-Datei selbst; PHP nur noch als Absturz-Fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:54:11 +02:00
boban d50aedeafb Fix: nginx http2 Syntax für nginx 1.25+ (listen 443 ssl + http2 on)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:16:20 +02:00
boban bc2810eb8a Fix: certbot in sudoers + SSL-Seite zeigt Zertifikate
www-data braucht sudo-Recht auf certbot für SSL-Seite (certificates/renew)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:29:57 +02:00
boban 894f753b81 Fix: Wizard SSL-Flow end-to-end sauber gelöst
- pollSetup() macht keinen auto-redirect mehr (port 443 wäre noch nicht offen)
- "Zum Login" ist jetzt ein plain <a href="/login"> ohne Livewire-POST
  → nginx leitet /login nach SSL-Switch automatisch auf HTTPS weiter
- mailwolt-apply-domains schreibt done=1/0 (je nach Cert-Status) VOR nginx-Switch
  + sleep 6s damit Polling noch 3x done lesen kann bevor port 443 öffnet
- done=1 nur wenn mindestens ein LE-Cert erfolgreich ausgestellt wurde

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:11:50 +02:00
boban 9d3cbd88b6 Fix: Race Condition SSL-Wizard + fastcgi_param HTTPS on
- mailwolt-apply-domains schreibt State-Dateien (done=1) BEVOR nginx auf HTTPS
  switcht, dann sleep 6s → Browser kann noch über HTTP redirecten
- WizardDomains.php überschreibt done nicht wenn Shell-Script es bereits gesetzt hat
- fastcgi_param HTTPS on in HTTPS-Blocks ergänzt (ohne dies liefert Laravel 404
  weil Request-Schema falsch erkannt wird)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:06:25 +02:00
boban 1547302297 Fix: Wizard leitet nach SSL-Setup automatisch auf HTTPS weiter
- SESSION_SECURE_COOKIE wird nicht mehr automatisch gesetzt (verursachte 419 während HTTP-Poll)
- pollSetup() leitet Browser sofort auf https://domain/setup weiter sobald SSL fertig
- verhindert dass Livewire-Polling über HTTP läuft während nginx schon auf HTTPS umgestellt hat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:54:08 +02:00
boban 68a31e894d Fix: SESSION_DOMAIN=null entfernt aus .env.example
String "null" wird von Laravel nicht als PHP null interpretiert —
Cookie bekommt Domain=null, Browser lehnt ihn ab → 419 auf allen Livewire-Requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:25:38 +02:00
boban 7833257126 Fix: footer_ok + Monit Nginx-Check bereinigt
- footer_ok: HTTPS/self-signed Zeile entfernt (nginx hat anfangs kein HTTPS mehr)
- Mail-TLS Cert Label ergänzt damit klar ist wofür das Zertifikat ist
- Monit: Port-443-Check für nginx entfernt (kein HTTPS initial → Monit würde nginx in Loop neustarten)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:15:34 +02:00
boban 216e46311b chore: mailwolt-installer in eigenes Repo ausgelagert
Verschoben nach https://git.nexlab.at/boban/mailwolt-installer.git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:11:39 +02:00
boban f9f7433b98 Fix: Kein Self-signed HTTPS mehr — plain HTTP bis LE-Cert vorhanden
- Nginx initial: nur HTTP-Block (kein 443/self-signed)
- mailwolt-apply-domains: kein self-signed Fallback; ohne LE-Cert
  bleibt nginx HTTP-only, mit LE-Cert wird auf HTTPS umgestellt
- Monit: MariaDB per matching statt pidfile (mysqld.pid oft nicht da)
- ACME-Challenge Location bereits im initialen HTTP-Block vorhanden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:02:26 +02:00
boban 73bda08244 Fix: Livewire NoModificationAllowedError im Setup-Wizard Step 5
wire:poll Div war bedingt gerendert – beim Entfernen aus dem DOM
versuchte Livewire noch es zu patchen → outerHTML Fehler.
Div bleibt jetzt immer im DOM, pollSetup() kehrt früh zurück wenn fertig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:55:18 +02:00
boban 660402a32d Fix: storage/backups/ zu .gitignore hinzugefügt
Verhindert Abbruch des Update-Scripts durch Dirty-Check wenn
Backups im storage-Verzeichnis vorhanden sind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:48:27 +02:00
boban 32f43020d3 Fix: HTTPS-Redirect im Wizard entfernt + nginx HTTP-Block korrigiert
HTTPS→HTTP Redirect war ein Workaround für SESSION_SECURE_COOKIE,
der jetzt durch den Installer-Fix (false initial) nicht mehr nötig ist.
fastcgi_param HTTPS on gehört nur in den 443-Block, nicht in den 80-Block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:43:26 +02:00
boban ed176ec243 Fix: Monit-Config vollständig und robust
- Alle Dienste überwacht: postfix, dovecot, mariadb, redis, rspamd,
  opendkim, opendmarc, nginx, fail2ban, clamav
- rspamd via process-matching statt pidfile (zuverlässiger)
- SSL-Checks mit for 3 cycles (kein Sofort-Restart bei Init)
- /var/run/ → /run/ Pfade korrigiert (moderne Debian-Konvention)
- monit -t vor dem Start (Konfig-Fehler werden sichtbar)
- 5 restarts within 10 cycles alert bei allen Diensten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:33:57 +02:00
boban 4fd37985b3 Fix: Passwort-Mindestlänge im Wizard auf 6 Zeichen gesenkt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:30:32 +02:00
boban 7d30faa7d7 Fix: SESSION_SECURE_COOKIE verhindert HTTP-Setup (419-Fehler)
Installer setzt SESSION_SECURE_COOKIE=false initial – damit Setup-Wizard
über http://ip erreichbar ist. WizardDomains setzt es auf true nach
erfolgreichem SSL. mount()-Redirect ohne exit (sauberer Return).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:29:13 +02:00
boban 4f3066e225 Fix: Spinner und Text im Login-Button immer nebeneinander
Livewire setzt beim Einblenden display:inline statt inline-flex.
Innerer Wrapper erzwingt inline-flex damit SVG und Text nie stacken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:14:16 +02:00
boban a30c21a1a9 Fix: Login-Spinner standardmäßig versteckt (display:none)
wire:loading-Span war vor Livewire-Init sichtbar, wodurch Anmelden
und Spinner gleichzeitig angezeigt wurden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:13:08 +02:00
boban 085f27d67c Redesign: Login-Seite an Wizard-Design angepasst
Eigenes HTML-Layout ohne Sidebar, mw-* CSS-Klassen, gleiches Logo
und Karten-Design wie der Setup-Wizard. Icons als Inline-SVG da
app.js (Phosphor) auf der Login-Seite nicht geladen wird.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:11:53 +02:00
boban 3869f6e67f Fix: Login-Redirect nur bei erfolgreichem SSL auf Domain umleiten
ssl_configured=1 → https://{ui_domain}/login
ssl_configured=0 → /login (bleibt auf aktueller IP/Host)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:08:01 +02:00
boban a322aa17ac Fix: goToLogin leitet auf konfigurierte UI-Domain weiter
route('login') nutzt die gecachte Config (alte IP/URL). Stattdessen
direkt auf https://{ui_domain}/login umleiten, da APP_URL erst nach
dem Prozess-Neustart greift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:06:54 +02:00
boban 0b415c6862 Fix: Setup-Wizard bei HTTPS auf HTTP umleiten
Vor SSL-Zertifikaten schlägt Livewires AJAX über HTTPS fehl.
mount() leitet automatisch auf http:// um damit der Wizard funktioniert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:04:50 +02:00
boban 31f486c753 Fix: SSL-Erstellung nicht von APP_ENV abhängig machen
isProduction()-Check entfernt — SSL wird jetzt allein durch skipSsl gesteuert,
da APP_ENV=local sonst SSL-Zertifikate komplett verhindert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:03:24 +02:00
boban af045b21d5 Fix: Version via git ls-remote statt describe (kein --unshallow nötig)
ls-remote fragt den Remote direkt — funktioniert mit shallow clone ohne
History-Download. Kein --unshallow, kein Fehler mehr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:49:58 +02:00
boban 5b9e486b98 Fix: --unshallow vor --tags fetch damit git describe funktioniert
Shallow clones (--depth=1) kennen keine Tag-Historie. --unshallow
konvertiert zuerst zum vollständigen Clone, danach sind alle Tags erreichbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:46:50 +02:00
boban fbce5dc8ba Fix: update.sh State-Dateien, Lock, Version-Datei + Installer-Integration
update.sh:
- State-Dateien (/var/lib/mailwolt/update/state + rc) werden geschrieben
- Lock-Datei verhindert parallele Update-Prozesse
- write_version_files() aktualisiert auch /var/lib/mailwolt/version
- Kombinierter _cleanup-Trap ersetzt cleanup_maintenance
- LATEST_TAG via git rev-list statt sort -V (zuverlässiger)
- Update-Log nach /var/log/mailwolt-update.log

installer.sh:
- update.sh wird als /usr/local/sbin/mailwolt-update installiert
- Sudoers-Eintrag für mailwolt-update ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:38:27 +02:00
boban f8f30d57f7 Feature: Installer-Spinner + verbesserter Smoke-Test
- Spinner (⠋⠙⠹…) läuft während quietly() auf Abschluss wartet
- stop_spin() in ok/warn/err integriert, EXIT-Trap sichert Cleanup
- Smoke-Test: kein eval, separate Funktionen pro Protokoll (smtp/tls/imap/pop3)
- Service-Namen neben Port, Zusammenfassung X/7 Dienste erreichbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:31:30 +02:00
boban eb16f7d6ad Refactor: Installer-Output auf saubere Schritt-Anzeige umgestellt
- Nur Hauptschritte mit Zeitschätzung sichtbar (~Pakete 2-5 Min, etc.)
- Alle verbose Ausgaben (apt/composer/npm/git) gehen in /var/log/mailwolt-install.log
- Bei Fehler: letzte 20 Log-Zeilen werden angezeigt + Log-Pfad
- quietly()/try_quiet() Helper für stille Ausführung
- Smoke-Test zeigt nur OK/⚠ pro Port ohne verbose openssl-Output
- Node/npm wird nach Git-Clone installiert (package.json bereits vorhanden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:27:31 +02:00
boban 814776d1ff Fix: Tags nach shallow clone explizit fetchen für Version-Datei
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:20:19 +02:00
boban 01e7db589a Fix: Installer + Wizard Step 5 robuster gegen IPv6/SSL-Fehler
- installer.sh: mailwolt-apply-domains mit 3-Phasen certbot (HTTP → LE → SSL),
  IPv6-Check vor certbot, Zertifikat-Ablauf-Check (10 Tage), Version-Datei schreiben
- WizardDomains: noipv6-Status aus Helper-Output erkennen
- Wizard: retryDomains()-Methode für Wiederholung ohne neuen Wizard-Durchlauf
- Step 5 Blade: Hints pro Fehlerstatus, Retry-Button, "Trotzdem zum Login"
- UpdatePage: Version aus Datei, Fallback auf git describe (kein "dev" mehr)
- UpdatePage: refreshLowLevelState behandelt fehlende State-Datei als idle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:16:42 +02:00
boban 3c8eaa16df Fix: Installer bricht bei nicht-kritischen Fehlern nicht mehr ab
- || true für Services (postfix, dovecot, redis, nginx)
- || true für artisan config/route/view:cache
- npm run build: Warnung statt Abbruch, Hinweis zum manuellen Nachholen
- Monit ist bereits aktiviert (systemctl enable --now)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:01:06 +02:00
boban e833ab72c6 Fix: WizardDomains übergibt SSL-Handling komplett an mailwolt-apply-domains
- Kein certbot --nginx mehr im Wizard (scheitert an catch-all server_name)
- mailwolt-apply-domains erstellt Vhosts zuerst, dann certbot --webroot
- sudoers-Eintrag für certbot im Installer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:48:47 +02:00
boban d5d5fd819c Fix: mailwolt-apply-domains Helper + sudoers + Monit aktiviert
- mailwolt-apply-domains Script im Installer erstellt
- sudoers-Eintrag für www-data (certbot + apply-domains ohne Passwort)
- Wizard State-Dir Owner www-data
- Monit standardmäßig aktiviert (nicht mehr disabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:24:28 +02:00
boban 19618746ba Fix: Installer komplett bereinigt für sauberen Erstdurchlauf
- acl-Paket ergänzt (setfacl)
- DB_NAME/DB_USER Doppel-Assignment entfernt
- VITE_REVERB_HOST nutzt jetzt tatsächliche SERVER_IP
- BROADCAST_CONNECTION=reverb gesetzt
- COMPOSER_ALLOW_SUPERUSER entfernt
- config:cache / route:cache / view:cache nach Migration
- /var/lib/mailwolt/wizard Verzeichnis angelegt
- git safe.directory gesetzt
- Footer zeigt /setup URL statt Login

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:08:42 +02:00
boban 8551a00414 Fix: Setup-Route und Wizard gegen fehlende DB absichern (try/catch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:03:30 +02:00
boban 94cddb7987 Fix: Pusher-Key-Fehler + wire:model remember + Reverb-Keys im Installer
- connection.js: Echo nur initialisieren wenn VITE_REVERB_APP_KEY gesetzt
- LoginForm: $remember Property ergänzt
- installer.sh: Reverb-Keys automatisch generieren und in .env schreiben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:58:21 +02:00
boban 7c3376bfbc Fix: Setup-Wizard ohne Auth erreichbar, Root-Route prüft setup_completed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:56:05 +02:00
boban 75d1f136a3 Fix: Installer-Cleanup (PHP-FPM-Socket dynamisch, Arg-Parsing oben, APP_PW früh, doppelter setfacl entfernt)
- PHPV-Erkennung vor nginx-Config verschoben, Socket-Pfad dynamisch
- Argument-Parsing (-dev/-stag) ganz an den Anfang
- APP_PW früh generieren damit es überall verfügbar ist
- Doppelten setfacl-Block entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:35 +02:00
boban b9c2eb5eef Refactor: Installer-Reihenfolge korrigiert (Git-Clone zuerst, dann .env/composer/migrate)
- composer create-project entfernt (wir klonen das eigene Repo)
- .env wird nach dem Clone auf dem echten Codebase gesetzt
- composer install, key:generate, migrate und storage:link nach Clone
- nodesource curl|bash durch Datei-Download ersetzt (stdin-safe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:33:53 +02:00
boban a07a0d1a98 Fix: APP_DIR vor git clone leeren wenn kein .git vorhanden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:19:48 +02:00
boban 8e8dff39c9 Fix: git zu Paketliste hinzugefügt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:16:49 +02:00
boban d271c96828 Fix: trailing > auf Zeile 512 entfernt (BOOTSTRAP_ADMIN_EMAIL redirect)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:14:32 +02:00
boban b8d121f251 Fix: unclosed quote in installer (BOOTSTRAP_EMAIL) verursachte Syntax Error
php-sqlite3 ergänzt um composer post-install migrate-Warning zu vermeiden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:08:01 +02:00
boban 45e762be7f Fix: \$uri escape in Nginx heredoc verhindert set -u Abbruch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:00:23 +02:00
boban 38d22c85ed Feature: API-Key/Webhook responsive div-grid, Sandbox-Icon in Domains, Search-fix
- API-Key-Tabelle: unified CSS-Grid div-layout (kein separates mobile/desktop HTML mehr),
  Scopes auf max. 2 Badges + +N Modal, Lösch-Bestätigung via Livewire-Modal
- Webhook-Tabelle: selbes div-grid Pattern, Status/HTTP inline auf Mobile
- Globale Suche: go()-Methode fixed (forceClose + setTimeout 350ms gegen resetState-Race)
- Domains: Sandbox-Icon ersetzt Globus durch gelbes Warndreieck wenn Sandbox aktiv
- Sandbox: SandboxRoute-Model, SandboxService, Migration, Routen-Verwaltung
- CSS: mw-kl-*/mw-whl-* Grid-Klassen, minmax(0,1fr) Fix für Text-Truncation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:48:06 +02:00
boban fc8dbf894a Fix: bash trap entfernt public/hot zuverlässig nach Vite-Stop
trap EXIT/INT/TERM feuert auch bei Ctrl+C — vorheriges node-Cleanup
lief nur beim Start, nicht beim Beenden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 06:22:04 +02:00
boban acab5d4c84 Fix: strictPort:true verhindert Vite auf falschem Port
Wenn Port 5173 belegt ist schlägt npm run dev sofort fehl statt
still auf 5174 zu wechseln — nginx würde dann ins Leere proxyen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 06:20:27 +02:00
boban c1d5ca1988 Refactor: app-webmail.js als eigener Vite-Entry ohne Admin-Websocket
Webmail-Layouts laden jetzt app-webmail.js statt app.js.
websocket.js, ui/command.js und sidebar.js werden im Webmail nicht
mehr geladen — kein /ui/tasks/active Aufruf mehr möglich.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 06:11:50 +02:00
boban 79548c5aa0 Fix: meta-Tag statt window-Variable für Webmail-Context-Erkennung
type=module Scripts haben kein garantiertes Timing mit inline Scripts.
<meta name="mw-context" content="webmail"> im <head> ist vor Modulausführung
garantiert im DOM verfügbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 06:01:45 +02:00
boban 52887e2dd5 Fix: MW_CONTEXT vor app.js setzen damit bootstrapToasts() es liest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 05:56:42 +02:00
boban 3064c0b186 Fix: /ui/tasks/active wird auf Webmail-Seiten nicht mehr aufgerufen
window.MW_CONTEXT='webmail' in beiden Webmail-Layouts gesetzt.
bootstrapToasts() prüft diesen Context und bricht früh ab um
den 401-Fehler auf der Webmail-Domain zu vermeiden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 05:54:42 +02:00
boban 949cdd1e5b Fix: Webmail path-fallback zurück (name 'webmail.') + hot-file cleanup
Path-based Fallback /webmail/* mit ->name('webmail.') re-added — kein
Namenskonflikt mehr mit web.php 'login'. Behebt gecachte 301-Redirects
im Browser. npm dev-script räumt public/hot vor dem Start auf damit
Vite-Stop nie mehr die App kaputt macht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:24:01 +02:00
boban 5158d7b3b3 Fix: SVG-Pfade (fehlendes M) + Vite-HMR-Proxy in nginx UI-Vhost
SVG paths in domain-list und installer-page hatten kein führendes 'M' im
d-Attribut — Browser-Fehler behoben. Nginx UI-Vhost bekommt Proxy-Locations
für /@vite/, /node_modules/, /resources/ und WebSocket /vite-hmr (Port 5173)
damit npm run dev durch nginx funktioniert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:12:08 +02:00
boban 6796ff3859 Fix: Route-Namenskonflikt 'login' bei konfigurierter Webmail-Domain
Path-based Fallback (/webmail/*) wird nicht mehr registriert wenn eine
dedizierte Webmail-Subdomain konfiguriert ist. Sonst kollidiert das nackte
->name('login') aus webmail.php mit dem login-Route aus web.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:05:55 +02:00
boban 12a16dbd64 Fix: Webmail-Vhost serviert volle Laravel-App statt nur /webmail/*
Mit dedizierter Webmail-Subdomain ist kein /webmail/-Präfix nötig —
Laravel's domain()-Routing in bootstrap/app.php übernimmt die Isolation.
Nur / leitet auf /login um, alle anderen Pfade gehen direkt an Laravel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:02:15 +02:00
boban 46fb3f12ff Fix: Webmail-Vhost nur /webmail/* — Control Panel nicht mehr erreichbar
- build_webmail_http_only / build_webmail_tls als eigene Funktionen
- Webmail-Domain: / und /login → redirect auf /webmail/login
- Webmail-Domain: nur /webmail/* wird an Laravel weitergeleitet
- Alles andere auf Webmail-Domain → 403
- UI-Domain bleibt unverändert (voller Laravel-Zugriff)
- mailwolt-apply-domains deployed aktualisiert (write_webmail_vhost)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:54:05 +02:00
boban 3bc3862b69 Fix: /var/lib/mailwolt/wizard mit www-data Owner anlegen
Installer legt das Wizard-State-Verzeichnis jetzt mit chown www-data an
damit finish() im Wizard die Status-Dateien schreiben kann.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:41:36 +02:00
boban 627ef668e5 Feature: Domain Auto-fill, SSL-Skip, Dashboard SSL-Banner
- Wizard Schritt 2: leere Domain-Felder werden beim Tippen auto-gefüllt
  (wer nur eine Domain nutzt muss sie nur einmal eingeben)
- Wizard Schritt 4: Checkbox "SSL jetzt überspringen" mit Hinweistext
- Wizard Schritt 5: skip-Status wird pro Domain angezeigt
- WizardDomains schreibt ssl_configured=0/1 in Settings
- SettingsForm: setzt ssl_configured=1 nach erfolgreichem applyDomains
- Dashboard: gelber Banner wenn ssl_configured != 1, Link zu Einstellungen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:36:11 +02:00
boban 077a029ff4 Feature: Wizard Schritt 5 — Domain-Setup mit Fortschrittsanzeige
- Neuer Schritt 5: SSL-Registrierung läuft im Hintergrund pro Domain
- Artisan-Command mailwolt:wizard-domains schreibt per-Domain Status-Dateien
- Wizard pollt alle 2s: pending → running → done/nodns/error
- "Zum Login" Button erscheint wenn alle Domains abgeschlossen
- Mail-Domain erhält ebenfalls SSL-Zertifikat (für STARTTLS/IMAPS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:29:52 +02:00
boban 35fe7c2c6f Feature: perPage als URL-Parameter + kompaktes Pagination-Fenster
- perPage (#[Url as:'limit']) bleibt nach Reload erhalten (25/50/100)
- Pagination zeigt max 5 Seiten (±2 um aktuelle) + 1/letzte mit ...
- Per-Seite-Select in Quarantäne und Queue eingefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:16:17 +02:00
boban ff53344a67 Fix: Livewire pagination tailwind.blade mit mq-pagination Design überschrieben
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:12:17 +02:00
boban 8699b9d991 Refactor: Pagination mit Livewire WithPagination + LengthAwarePaginator
- WithPagination Trait + LengthAwarePaginator für Array-Daten
- $messages->links() statt manueller Pagination-Blöcke
- Livewire tailwind.blade.php überschrieben mit mq-pagination/mq-pag-btn Klassen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:09:58 +02:00
boban bc66870681 Feature: Pagination für Quarantäne und Mail-Queue (25 pro Seite)
- Quarantäne und Queue zeigen je 25 Einträge pro Seite
- Pagination-Bar mit Seitenanzeige (X-Y von Z) und Blätter-Buttons
- Seite wird bei Filter- oder Suchwechsel auf 1 zurückgesetzt
- Quarantäne: rows-Select entfernt (API holt intern 500, UI paginiert)
- CSS-Klassen mq-pagination, mq-pag-btn passend zum Dark-Design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:06:14 +02:00
boban b52ea46f22 Fix: Lokal immer git describe als installierte Version verwenden
Auf APP_ENV=local wird die aktuelle Version direkt aus git describe
gelesen statt aus /var/lib/mailwolt/version — verhindert falschen
"Update verfügbar" Hinweis auf der Entwicklungsmaschine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:02:57 +02:00
boban ad60bd61fe Fix: Update-Prüfung als www-data (git safe.directory), Toggle-Switch statt Checkbox
- CheckUpdates: git safe.directory vor fetch setzen damit www-data-Ausführung funktioniert
- Auto-Scroll: nativen Checkbox durch CSS Toggle-Switch ersetzt (passt zum Dark-Design)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:00:35 +02:00
boban 291f13c034 Fix: Updates-Seite — Prüfung synchron, Log-Abstände, Checkbox-Stil
- checkForUpdates() läuft jetzt synchron (nicht im Hintergrund), damit
  das Ergebnis sofort angezeigt wird ohne Seite neu laden zu müssen
- Log-Viewer: white-space:pre-wrap + <br> entfernt durch display:block
  pro Span — kein doppelter Zeilenabstand mehr
- Auto-Scroll Checkbox nutzt jetzt mw-modal-check Klassen (passend zum Design)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:55:41 +02:00
boban 2049057f7f Feature: Setup-Wizard neu — 4-Schritte-Einrichtung mit Dark-Design
- Wizard komplett überarbeitet: System / Domains / Admin / Zusammenfassung
- Eigenes Layout (layouts/setup.blade.php), zentriert, kein Sidebar
- Schritt-Indikator mit Checkmarks für abgeschlossene Schritte
- Per-Schritt Validierung, live Fehleranzeige
- Weiter/Zurück-Buttons mit korrekter Ausrichtung (margin-left:auto)
- Livewire wire:loading-Spinner auf SVG-Icons (behebt JS-Fehler in core.js)
- finish() schreibt Settings, .env und legt Admin-User an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:49:13 +02:00
boban 2ae126cf20 Fix: Gitea-Repo URL im installer.sh eingetragen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:32:10 +02:00
boban 3240bfcd0c Fix: mailwolt-installer als regulärer Ordner (kein Submodule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:24:05 +02:00
boban 7bb922191f Feature: Update-System, Backup-Cron, SSL-Workflow, UI-Verbesserungen
- Update-Seite (/system/update) mit Log-Viewer, Fortschrittsbalken und goldenem Nav-Badge
- /usr/local/sbin/mailwolt-update Wrapper + backup:scheduled Cron-Command
- SSL: Checkbox entfernt, immer automatisch in Prod; local-Modus überspringt certbot mit manuellem Erzwingen-Modal
- Domain-Felder: live Validierung via updatedUiDomain/updatedMailDomain/updatedWebmailDomain
- DNS-Check in applyDomains() wiederhergestellt
- Backup-Cron: BackupScheduled Command + Laravel-Scheduler Eintrag in console.php
- /etc/cron.d/mailwolt-scheduler angelegt für schedule:run
- mailwolt-installer als regulärer Ordner (kein Submodule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:23:43 +02:00
boban af369acbf6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:57:05 +01:00
boban d81c3bc07c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:52:54 +01:00
boban 821a2bde33 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-08 12:43:52 +01:00
boban 8e68051fde Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-04 18:40:39 +01:00
boban afb8d09db3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:20:53 +01:00
boban fc04ef44d0 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 23:17:06 +01:00
boban a7d84899fb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:58:18 +01:00
boban e3dc81ef73 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:53:37 +01:00
boban 9acea7b89b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:20:53 +01:00
boban 6c3cde5f65 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:10:01 +01:00
boban 77f22518c8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-11-01 22:05:14 +01:00
boban 9aa9475387 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 18:17:27 +01:00
boban d4255b08fa Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 17:43:14 +01:00
boban 94aec78d4c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:52:41 +01:00
boban d3783e1717 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 16:41:54 +01:00
boban dcf9a8d3e9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:32:24 +01:00
boban 595828c5f6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:29:30 +01:00
boban 834f173bb9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:26:34 +01:00
boban a3a4ec4d06 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 04:13:39 +01:00
boban 2f390af9ed Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:42:36 +01:00
boban 530faf6b45 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:33:30 +01:00
boban 8058f9b814 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:27:36 +01:00
boban c4b906223c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:18:53 +01:00
boban 81860d1851 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:14:26 +01:00
boban 792f0e3528 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 03:01:17 +01:00
boban 46591669d6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:38:00 +01:00
boban 8690067d9c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:21:22 +01:00
boban beb2f863a3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:14:54 +01:00
boban e833033074 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 01:08:26 +01:00
boban 02e558bf4b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:59:51 +01:00
boban d9867db546 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:43:16 +01:00
boban e77d9f64bb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:27:23 +01:00
boban ee44ff3def Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:23:48 +01:00
boban 4d1fd64158 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:15:55 +01:00
boban 6b0dd7d176 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-31 00:05:18 +01:00
boban 67b6e1fa02 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-30 23:55:07 +01:00
boban 8b4f2d9fe8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:32:25 +01:00
boban e3c7e8de33 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:24:36 +01:00
boban 385b67c3c5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 19:13:10 +01:00
boban 251f2d9c8f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 18:34:08 +01:00
boban aaae226c8d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 04:23:41 +01:00
boban 3c1093311c Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 04:10:49 +01:00
boban 0cb7212d4b Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:54:39 +01:00
boban ab13bab984 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:44:49 +01:00
boban 47bca4c8de Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:26:32 +01:00
boban 9074904683 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 03:10:11 +01:00
boban 659f3cb7ae Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-29 02:45:40 +01:00
boban c8cae445c5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:53:41 +01:00
boban 2ef27b5e8f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:26:47 +01:00
boban d200e3e73f Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:22:23 +01:00
boban e23713a5c6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 22:11:12 +01:00
boban 56e7453f8d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:55:38 +01:00
boban 8790cffeb4 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:34:06 +01:00
boban 2ed1d1cd36 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:31:18 +01:00
boban 10b4872a04 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 21:28:43 +01:00
boban 4645b168f7 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 20:20:30 +01:00
boban 3152dc94e2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:51:33 +01:00
boban 59c495af84 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:38:37 +01:00
boban a5d3ac08c6 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:27:52 +01:00
boban 4197b61905 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 19:05:01 +01:00
boban dd645aed68 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:53:11 +01:00
boban 703843a9c2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:18:22 +01:00
boban 3108d521a5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 18:07:37 +01:00
boban dd3f413e6a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-28 17:25:02 +01:00
boban 09117fe1e9 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:57:15 +01:00
boban ddd96eb9f2 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:37:30 +01:00
boban da30b80056 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 19:05:11 +01:00
boban 074d2da4ec Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 18:25:09 +01:00
boban d76ea0b703 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 18:00:20 +01:00
boban adea3c5275 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 02:18:35 +01:00
boban 9d3ca94b87 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-27 01:50:34 +01:00
boban 520617d9b3 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:59:59 +01:00
boban d6f0c5d7cb Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:54:57 +01:00
boban 3b816e2198 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:44:12 +01:00
boban 8e35c617b8 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:33:53 +01:00
boban d65aaf9a5d Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 20:03:50 +01:00
boban a5e745ca4a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:43:47 +01:00
boban cdb16fc4a5 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:31:52 +01:00
boban 988de01e82 Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:27:41 +01:00
boban 3bf7db585a Fix: Mailbox Stats über Dovecot mit config/mailpool.php 2025-10-26 19:25:52 +01:00
317 changed files with 34960 additions and 5310 deletions

0
,
View File

View File

@ -31,9 +31,11 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
# SESSION_DOMAIN=.example.com
SESSION_DOMAIN=
BROADCAST_CONNECTION=log
#BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
@ -63,3 +65,11 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Mailwolt domain config
BASE_DOMAIN=example.com
UI_SUB=admin
MTA_SUB=mail
# Custom webmail subdomain — users access webmail at WEBMAIL_SUB.BASE_DOMAIN
# Leave empty to use only the path-based fallback (/webmail on main domain)
WEBMAIL_SUB=webmail

1
.gitignore vendored
View File

@ -17,6 +17,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/backups
/storage/pail
/vendor
Homestead.json

0
0
View File

102
CLAUDE.md Normal file
View File

@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development (starts PHP server + queue + logs + Vite concurrently)
composer dev
# Build assets for production
npm run build
# Vite dev server only
npm run dev
# Run all tests
composer test
php artisan test
# Run a single test file
php artisan test tests/Feature/ExampleTest.php
# Run a single test by name
php artisan test --filter=TestName
# Migrations
php artisan migrate
```
## Architecture
### Design
- Tailwind CSS v4
- Modals sind immer vom Livewire Modals
- Kein Inline-Style nur wenn wirklich notwendig.
- Immer prüfen das das Style nicht kaputt ist und wenn Icons in Buttons mit Text sind, sind diese immer nebeneinadner Nie übereinander.
### What this is
Mailwolt is a self-hosted mail server administration panel (German UI). It manages domains, mailboxes, aliases, DNS records (DKIM/DMARC/SPF/TLSA), TLS, Fail2ban, IMAP, and email quarantine/queues. It also has a mail sandbox for testing Postfix transports.
### Layout & Routing
- **`resources/views/layouts/dvx.blade.php`** is the actual app layout (not `app.blade.php`). Livewire full-page components use `#[Layout('layouts.dvx')]`.
- Routes are in `routes/web.php` — each page maps directly to a Livewire full-page component via `->name()`.
- Navigation structure is driven by `config/ui-menu.php`.
- Für den Style wird Tailwind CSS v4
### Livewire component structure
```
app/Livewire/Ui/
├── Nx/ — Current production UI (Dashboard, DomainList, MailboxList, AliasList, etc.)
├── Domain/ — Domain modals and DKIM/DNS views
├── Mail/ — Mailbox/alias modals, queue, quarantine
├── Security/ — Fail2ban, SSL, RSpamd, TLS, audit logs
├── System/ — Settings, users, API keys, webhooks, sandbox, backups
├── Search/ — Global search palette modal
└── Webmail/ — Webmail-specific components
```
Full-page components live in `Ui/Nx/` and `Ui/System/`, `Ui/Security/`, etc. Modals are always in a `Modal/` subfolder and extend `LivewireUI\Modal\ModalComponent`.
### Modals (wire-elements/modal v2)
- Open from **outside** a Livewire component: `onclick="Livewire.dispatch('openModal', {component:'ui.system.modal.my-modal', arguments:{key:value}})"`
- Open from **inside** a Livewire component: `$this->dispatch('openModal', component: '...', arguments: [...])`
- **Never** use `wire:click="$dispatch('openModal',...)"` outside a Livewire component context — it won't work.
- Modal argument keys must match the `mount(int $keyName)` parameter names exactly.
- To prevent closing on backdrop/Escape, override in the modal class:
```php
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
```
- To force-close the entire modal stack: `$this->forceClose()->closeModal()`
### Livewire dependency injection
- **Never** use constructor injection in Livewire components — Livewire calls `new Component()` with no args.
- Use `boot(MyService $service)` instead: this is called on every request and supports DI.
### CSS design system
The app uses a custom `mw-*` variable and class system defined in `resources/css/app.css` (Tailwind CSS v4, no `tailwind.config.js`):
**CSS variables:**
- `--mw-bg`, `--mw-bg3`, `--mw-bg4` — background layers
- `--mw-b1`, `--mw-b2`, `--mw-b3` — border shades
- `--mw-t1` through `--mw-t5` — text shades (t1 = primary, t4/t5 = muted)
- `--mw-v`, `--mw-v2`, `--mw-vbg` — purple accent (primary brand color)
- `--mw-gr` — green (success)
**Reusable component classes:** `.mw-btn-primary`, `.mw-btn-secondary`, `.mw-btn-cancel`, `.mw-btn-save`, `.mw-btn-del`, `.mbx-act-btn`, `.mbx-act-danger`, `.mw-modal-frame`, `.mw-modal-head`, `.mw-modal-body`, `.mw-modal-foot`, `.mw-modal-label`, `.mw-modal-input`, `.mw-modal-error`, `.mbx-badge-mute`, `.mbx-badge-ok`, `.mbx-badge-warn`.
### Services
`app/Services/` contains: `DkimService`, `DnsRecordService`, `ImapService`, `MailStorage`, `SandboxMailParser`, `SandboxService`, `TlsaService`, `TotpService`, `WebhookService`.
### API
REST API under `/api/v1/` uses Laravel Sanctum. Token abilities map to scopes like `mailboxes:read`, `domains:write`, etc. Defined in `routes/api.php`.
### Sandbox mail system
The mail sandbox intercepts Postfix mail via a pipe transport (`php artisan sandbox:receive`). `SandboxRoute` model controls which domains/addresses are intercepted. `SandboxService::syncTransportFile()` writes `/etc/postfix/transport.sandbox` and runs `postmap`.
### Queue & real-time
- Queue driver: database (configurable). Jobs in `app/Jobs/`.
- Real-time updates use Laravel Reverb + Pusher.js. Livewire polls (`wire:poll.5s`) are used as fallback on some pages.

72
INSTALL_REPORT.md Normal file
View File

@ -0,0 +1,72 @@
# MailWolt Install Report
Datum: 2026-04-17
## Befunde & Fixes
### Migrationen
- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 115)
- Tabellen vorhanden: `domains`, `mail_users`, `mail_aliases`, `mail_alias_recipients`, `dkim_keys`, `settings`, `system_tasks`, `spf_records`, `dmarc_records`, `tlsa_records`, `backup_*`, `fail2ban_*`, `two_factor_*`
- Feldbezeichnungen weichen von der Spec ab (z. B. `local` statt `source` bei Aliases) ist konsistent mit der restlichen Anwendung
### Setup-Wizard
- **Fix:** Route `/setup` fehlte in `routes/web.php` → hinzugefügt
- Controller: `App\Http\Controllers\Setup\SetupWizard` (existiert)
- Middleware `EnsureSetupCompleted` leitet nicht-abgeschlossene Setups korrekt auf `/setup` weiter
- `SETUP_PHASE=bootstrap` in `.env` → Setup noch nicht abgeschlossen
### Nginx vHost (`/etc/nginx/sites-available/mailwolt.conf`)
- `$uri`-Escapes: korrekt (kein Escaping-Problem)
- PHP-FPM Socket: `unix:/run/php/php8.2-fpm.sock`
- `nginx -t`: **syntax ok / test successful**
## Dienste-Status
```
postfix active
dovecot active
nginx active
mariadb active
redis-server active
rspamd active
opendkim activating ← startet noch / Sockets werden ggf. verzögert gebunden
fail2ban active
php8.2-fpm active
laravel-queue active
reverb active
```
## Port-Test (Smoke Test)
```
Port 25: OPEN (SMTP)
Port 465: OPEN (SMTPS)
Port 587: OPEN (Submission)
Port 143: OPEN (IMAP)
Port 993: OPEN (IMAPS)
Port 80: OPEN (HTTP → HTTPS Redirect)
Port 443: OPEN (HTTPS)
```
## Noch ausstehend (manuell)
- **Setup-Wizard aufrufen:** https://10.10.70.58/setup
(Domain, Admin-E-Mail, Admin-Passwort setzen)
- **DKIM-Keys für Domain generieren** (nach Setup, wenn Domain angelegt):
```
rspamadm dkim_keygen -s mail -d example.com
```
- **DNS-Einträge setzen** (beim Domain-Hoster):
- `MX``mail.example.com`
- `SPF``v=spf1 mx ~all`
- `DKIM` → TXT-Eintrag aus `rspamadm dkim_keygen`
- `DMARC``v=DMARC1; p=none; rua=mailto:dmarc@example.com`
- **Let's Encrypt Zertifikat** (nach DNS-Propagation):
```
certbot --nginx -d mail.example.com
```
- **opendkim** prüfen ob Dienst vollständig gestartet ist:
```
systemctl status opendkim
```

View File

View File

View File

@ -0,0 +1,201 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\Setting;
use Illuminate\Console\Command;
class BackupRun extends Command
{
protected $signature = 'backup:run {jobId : ID des BackupJob-Eintrags}';
protected $description = 'Führt einen Backup-Job aus und aktualisiert den Status';
public function handle(): int
{
$job = BackupJob::with('policy')->findOrFail($this->argument('jobId'));
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
$log = [];
$exitCode = 0;
// ── 1. External backup script (takes full control if present) ──────
$script = '/usr/local/sbin/mailwolt-backup';
if (file_exists($script)) {
$output = [];
exec('sudo -n ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
$artifact = null;
foreach ($output as $line) {
if (str_starts_with($line, 'ARTIFACT:')) {
$artifact = trim(substr($line, 9));
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
// ── 2. Built-in backup ────────────────────────────────────────────
// Database
if ($policy?->include_db ?? true) {
[$ok, $msg, $dumpFile] = $this->dumpDatabase($tmpDir);
if ($ok) {
$sources[] = $dumpFile;
$log[] = '✓ Datenbank gesichert';
} else {
$log[] = '✗ Datenbank: ' . $msg;
$exitCode = 1;
}
}
// Mail directories
if ($policy?->include_maildirs ?? true) {
$mailDir = (string) Setting::get('backup_mail_dir', '');
if (empty($mailDir)) {
foreach (['/var/vmail', '/var/mail/vhosts', '/home/vmail'] as $c) {
if (is_dir($c)) { $mailDir = $c; break; }
}
}
if ($mailDir && is_dir($mailDir)) {
$sources[] = $mailDir;
$log[] = '✓ Maildirs: ' . $mailDir;
} else {
$log[] = '✗ Maildir nicht gefunden (konfiguriere den Pfad in den Einstellungen)';
}
}
// Config files + SSL
if ($policy?->include_configs ?? true) {
foreach (['/etc/postfix', '/etc/dovecot', '/etc/opendkim', '/etc/rspamd', '/etc/letsencrypt'] as $dir) {
if (is_dir($dir)) {
$sources[] = $dir;
$log[] = '✓ ' . $dir;
}
}
if (file_exists(base_path('.env'))) {
$sources[] = base_path('.env');
$log[] = '✓ .env';
}
}
if (empty($sources)) {
$msg = 'Keine Quellen zum Sichern gefunden.';
$job->update(['status' => 'failed', 'finished_at' => now(), 'error' => $msg]);
$this->cleanTmp($tmpDir);
return self::FAILURE;
}
// Build archive — use storage/app/backups so www-data always has write access
$outDir = storage_path('app/backups');
if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) {
// Final fallback: /tmp (always writable)
$outDir = sys_get_temp_dir() . '/clubird_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];
$tarExit = 0;
exec("tar --ignore-failed-read -czf " . escapeshellarg($outFile) . " {$srcArgs} 2>&1", $tarOutput, $tarExit);
$this->cleanTmp($tmpDir);
// tar exit 1 = some files unreadable but archive was written — treat as ok
if ($tarExit <= 1 && file_exists($outFile)) {
$tarExit = 0;
}
if ($tarExit !== 0) {
$exitCode = $tarExit;
}
$fullLog = implode("\n", $log) . "\n\n" . implode("\n", array_slice($tarOutput, -20));
$this->finalize($job, $exitCode, $fullLog, $tarExit === 0 ? $outFile : null);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
private function dumpDatabase(string $tmpDir): array
{
$conn = config('database.connections.' . config('database.default'));
if (($conn['driver'] ?? '') !== 'mysql') {
return [false, 'Nur MySQL/MariaDB wird unterstützt.', null];
}
$host = $conn['host'] ?? '127.0.0.1';
$port = (string)($conn['port'] ?? '3306');
$username = $conn['username'] ?? '';
$password = $conn['password'] ?? '';
$database = $conn['database'] ?? '';
$dumpFile = "{$tmpDir}/database.sql";
$cmd = 'MYSQL_PWD=' . escapeshellarg($password)
. ' mysqldump'
. ' -h ' . escapeshellarg($host)
. ' -P ' . escapeshellarg($port)
. ' -u ' . escapeshellarg($username)
. ' --single-transaction --routines --triggers'
. ' ' . escapeshellarg($database)
. ' > ' . escapeshellarg($dumpFile)
. ' 2>&1';
$output = [];
$exit = 0;
exec($cmd, $output, $exit);
if ($exit !== 0 || !file_exists($dumpFile)) {
return [false, implode('; ', $output), null];
}
return [true, '', $dumpFile];
}
private function finalize(BackupJob $job, int $exitCode, string $log, ?string $artifact): void
{
$sizeBytes = ($artifact && file_exists($artifact)) ? filesize($artifact) : 0;
if ($exitCode === 0) {
$job->update([
'status' => 'ok',
'finished_at' => now(),
'log_excerpt' => $log,
'artifact_path' => $artifact,
'size_bytes' => $sizeBytes,
]);
$job->policy?->update([
'last_run_at' => now(),
'last_status' => 'ok',
'last_size_bytes' => $sizeBytes,
]);
} else {
$job->update([
'status' => 'failed',
'finished_at' => now(),
'error' => $log,
]);
$job->policy?->update(['last_status' => 'failed']);
}
}
private function cleanTmp(string $dir): void
{
if (!is_dir($dir)) return;
foreach (glob("{$dir}/*") ?: [] as $f) {
is_file($f) ? unlink($f) : null;
}
@rmdir($dir);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Illuminate\Console\Command;
class BackupScheduled extends Command
{
protected $signature = 'backup:scheduled {policyId}';
protected $description = 'Legt einen BackupJob an und startet backup:run (wird vom Scheduler aufgerufen)';
public function handle(): int
{
$policy = BackupPolicy::find($this->argument('policyId'));
if (! $policy || ! $policy->enabled) {
return self::SUCCESS;
}
if (BackupJob::whereIn('status', ['queued', 'running'])->exists()) {
$this->warn('Backup läuft bereits — übersprungen.');
return self::SUCCESS;
}
$job = BackupJob::create([
'policy_id' => $policy->id,
'status' => 'queued',
'started_at' => now(),
]);
$artisan = base_path('artisan');
exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &");
$this->info("Backup-Job #{$job->id} gestartet.");
return self::SUCCESS;
}
}

View File

@ -6,49 +6,125 @@ use Illuminate\Console\Command;
class CheckUpdates extends Command
{
protected $signature = 'mailwolt:check-updates';
protected $description = 'Check for newer MailWolt releases via git tags';
protected $signature = 'clubird:check-updates';
protected $aliases = ['mailwolt:check-updates'];
protected $description = 'Check for newer CluBird releases via git tags';
public function handle(): int
{
$currentNorm = $this->readInstalledVersionNorm();
$currentRaw = $this->readInstalledVersionRaw() ?? ($currentNorm ? 'v'.$currentNorm : null);
$appPath = base_path();
$cmd = <<<BASH
set -e
cd {$appPath}
git fetch --tags --force --quiet origin +refs/tags/*:refs/tags/*
(git tag -l 'v*' --sort=-v:refname | head -n1) || true
BASH;
$appPath = base_path();
$remoteFile = '/var/lib/mailwolt/version_remote';
$latestTagRaw = trim((string) shell_exec($cmd));
if ($latestTagRaw === '') {
$latestTagRaw = trim((string) shell_exec("cd {$appPath} && git tag -l --sort=-v:refname | head -n1"));
// Fallback-Kette für Remote-Tags (erste funktionierende Methode gewinnt):
// 1. mailwolt-update --check-only (sudoers: mailwolt-update)
// 2. mailwolt-fetch-tags (sudoers: mailwolt-fetch-tags)
// 3. Gitea/GitHub API (kein Auth nötig wenn Repo öffentlich)
// 4. git ls-remote (klappt wenn Credentials im git-Config)
// 5. git fetch --tags direkt
$updateBin = '/usr/local/sbin/mailwolt-update';
$fetchHelper = '/usr/local/sbin/mailwolt-fetch-tags';
$fetched = false;
if (file_exists($updateBin) && str_contains((string) @file_get_contents($updateBin), '--check-only')) {
@exec('sudo -n ' . escapeshellarg($updateBin) . ' --check-only 2>/dev/null', $_, $rc);
$fetched = ($rc === 0);
}
if (!$fetched && file_exists($fetchHelper)) {
@exec('sudo -n ' . escapeshellarg($fetchHelper) . ' 2>/dev/null', $_, $rc);
$fetched = ($rc === 0);
}
// Gitea/GitHub API funktioniert ohne Credentials wenn Repo öffentlich
if (!$fetched) {
$remoteUrl = trim((string) @shell_exec('git -C ' . escapeshellarg($appPath) . ' remote get-url origin 2>/dev/null'));
if (preg_match('~https?://([^/]+)/([^/]+/[^/]+?)(?:\.git)?$~', $remoteUrl, $rm)) {
$host = $rm[1];
$project = $rm[2];
// Gitea API
$apiUrl = "https://{$host}/api/v1/repos/{$project}/tags?limit=50";
$ctx = stream_context_create(['http' => ['timeout' => 5, 'ignore_errors' => true]]);
$json = @file_get_contents($apiUrl, false, $ctx);
if ($json) {
$tags = json_decode($json, true);
if (is_array($tags)) {
$versions = [];
foreach ($tags as $t) {
$name = $t['name'] ?? '';
if (preg_match('/^v[\d.]+$/', $name)) {
$versions[] = $name;
}
}
usort($versions, 'version_compare');
$latest = end($versions);
if ($latest) {
@mkdir(dirname($remoteFile), 0755, true);
file_put_contents($remoteFile, $latest);
$fetched = true;
}
}
}
}
}
// git ls-remote (klappt wenn Credentials im git-Config hinterlegt sind)
if (!$fetched) {
$lsOut = [];
@exec('git -C ' . escapeshellarg($appPath) . ' ls-remote --tags origin \'v*\' 2>/dev/null', $lsOut);
$lsVersions = [];
foreach ($lsOut as $line) {
if (preg_match('~refs/tags/(v[\d.]+)$~', $line, $m)) {
$lsVersions[] = $m[1];
}
}
if (!empty($lsVersions)) {
usort($lsVersions, 'version_compare');
$latest = end($lsVersions);
@mkdir(dirname($remoteFile), 0755, true);
file_put_contents($remoteFile, $latest);
$fetched = true;
}
}
// Direkter git fetch als letzter Fallback
if (!$fetched) {
@exec('git -C ' . escapeshellarg($appPath) . ' fetch --tags origin 2>/dev/null', $_, $rc);
}
// version_remote von Helfer geschrieben; als frisch wenn < 2h alt
$remoteRaw = '';
if (file_exists($remoteFile) && (time() - filemtime($remoteFile)) < 7200) {
$remoteRaw = trim((string) file_get_contents($remoteFile));
}
// Lokale Tags (nach fetch hoffentlich aktuell)
$out = [];
@exec("git -C " . escapeshellarg($appPath) . " tag -l 'v*' --sort=-v:refname 2>/dev/null", $out);
$latestTagRaw = $remoteRaw !== '' ? $remoteRaw : trim($out[0] ?? '');
$latestNorm = $this->normalizeVersion($latestTagRaw);
// Nichts gefunden -> alles leeren
if (!$latestNorm) {
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available'); // legacy
cache()->forget('mailwolt.update_available');
$this->warn('Keine Release-Tags gefunden.');
return self::SUCCESS;
}
// Nur wenn wirklich neuer als installiert -> Keys setzen
if ($currentNorm && version_compare($latestNorm, $currentNorm, '>')) {
cache()->forever('updates:latest', $latestNorm);
cache()->forever('updates:latest_raw', $latestTagRaw ?: ('v'.$latestNorm));
cache()->forever('mailwolt.update_available', $latestNorm); // legacy-kompat
cache()->forever('mailwolt.update_available', $latestNorm);
$this->info("Update verfügbar: {$latestTagRaw} (installiert: ".($currentRaw ?? $currentNorm).")");
} else {
// Kein Update -> Keys löschen
cache()->forget('updates:latest');
cache()->forget('updates:latest_raw');
cache()->forget('mailwolt.update_available'); // legacy
cache()->forget('mailwolt.update_available');
$this->info("Aktuell (installiert: ".($currentRaw ?? $currentNorm ?? 'unbekannt').").");
}
@ -60,22 +136,35 @@ class CheckUpdates extends Command
private function readInstalledVersionNorm(): ?string
{
$paths = [
'/var/lib/mailwolt/version', // vom Wrapper (normiert)
base_path('VERSION'), // App-Fallback
];
foreach ($paths as $p) {
// version_raw ist die zuverlässigste Quelle wird vom Update-Script geschrieben
$rawVer = $this->normalizeVersion($this->readInstalledVersionRaw() ?? '');
$fileVer = null;
foreach (['/var/lib/mailwolt/version', base_path('VERSION')] as $p) {
$raw = @trim(@file_get_contents($p) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
if ($raw !== '') { $fileVer = $this->normalizeVersion($raw); break; }
}
// Noch ein Fallback aus RAW-Datei
$raw = $this->readInstalledVersionRaw();
return $raw ? $this->normalizeVersion($raw) : null;
// version_raw bevorzugen (echter installierter Stand)
// Nur wenn version_raw fehlt: version-Datei oder git-Tag als Fallback
if ($rawVer) {
return $rawVer;
}
// git-Tag als letzter Fallback (funktioniert auf Dev-Servern)
$out = [];
@exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
$gitVer = $this->normalizeVersion(trim($out[0] ?? ''));
if ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) {
return $gitVer;
}
return $fileVer ?: null;
}
private function readInstalledVersionRaw(): ?string
{
$p = '/var/lib/mailwolt/version_raw'; // vom Wrapper (z.B. "v1.0.25" oder "v1.0.25-3-gabcd")
$p = '/var/lib/mailwolt/version_raw';
$raw = @trim(@file_get_contents($p) ?: '');
return $raw !== '' ? $raw : null;
}
@ -85,8 +174,8 @@ class CheckUpdates extends Command
if (!$v) return null;
$v = trim($v);
if ($v === '') return null;
$v = ltrim($v, "vV \t\n\r\0\x0B"); // führendes v entfernen
$v = preg_replace('/-.*$/', '', $v); // Build-/dirty-Suffix abschneiden
$v = ltrim($v, "vV \t\n\r\0\x0B");
$v = preg_replace('/-.*$/', '', $v);
return $v !== '' ? $v : null;
}
}

View File

@ -6,12 +6,13 @@ use Illuminate\Console\Command;
class MailwoltRestart extends Command
{
protected $signature = 'mailwolt:restart-services';
protected $signature = 'clubird:restart-services';
protected $aliases = ['mailwolt:restart-services'];
protected $description = 'Restart or reload MailWolt-related system services';
public function handle(): int
{
$units = config('mailwolt.units', []);
$units = config('clubird.units', []);
foreach ($units as $u) {
$base = (string)($u['name'] ?? '');
@ -45,12 +46,12 @@ class MailwoltRestart extends Command
//class MailwoltRestart extends Command
//{
// protected $signature = 'mailwolt:restart-services';
// protected $signature = 'clubird:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('mailwolt.units', []);
// $units = config('clubird.units', []);
// $allowed = ['reload','restart','try-reload-or-restart'];
//
// foreach ($units as $u) {
@ -90,12 +91,12 @@ class MailwoltRestart extends Command
//
//class MailwoltRestart extends Command
//{
// protected $signature = 'mailwolt:restart-services';
// protected $signature = 'clubird:restart-services';
// protected $description = 'Restart or reload MailWolt-related system services';
//
// public function handle(): int
// {
// $units = config('mailwolt.units', []);
// $units = config('clubird.units', []);
//
// foreach ($units as $u) {
// $unit = rtrim($u['name'] ?? '', '.service') . '.service';

View File

@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateConfigNames extends Command
{
protected $signature = 'clubird:migrate-config-names {--dry-run : Nur anzeigen was gemacht würde}';
protected $description = 'Benennt server-seitige Config-Dateien von mailwolt-* auf generische Namen um';
private array $renames = [
'/etc/rspamd/local.d/mailwolt-actions.conf' => '/etc/rspamd/local.d/actions.conf',
'/etc/dovecot/conf.d/99-mailwolt-tls.conf' => '/etc/dovecot/conf.d/99-tls.conf',
'/etc/postfix/mailwolt-tls.cf' => '/etc/postfix/tls.cf',
'/etc/fail2ban/jail.d/mailwolt-whitelist.local' => '/etc/fail2ban/jail.d/whitelist.local',
'/etc/fail2ban/jail.d/00-mailwolt-defaults.local'=> '/etc/fail2ban/jail.d/00-defaults.local',
];
public function handle(): int
{
$dry = $this->option('dry-run');
foreach ($this->renames as $old => $new) {
if (!file_exists($old)) {
$this->line(" <fg=gray>übersprungen</> {$old} (nicht vorhanden)");
continue;
}
if (file_exists($new) && !is_link($new)) {
$this->line(" <fg=yellow>bereits vorhanden</> {$new}");
continue;
}
if ($dry) {
$this->line(" <fg=cyan>[dry-run]</> mv {$old}{$new}");
continue;
}
// Als root direkt umbenennen; als www-data via sudo (sudo mv muss in sudoers sein)
if (posix_getuid() === 0) {
$ok = @rename($old, $new);
$errMsg = error_get_last()['message'] ?? '';
} else {
@exec('sudo -n mv ' . escapeshellarg($old) . ' ' . escapeshellarg($new) . ' 2>&1', $out, $rc);
$ok = ($rc === 0);
$errMsg = implode(' ', $out);
}
if ($ok) {
$this->info(" umbenannt: {$old}{$new}");
} else {
$this->error(" FEHLER bei {$old}: {$errMsg}");
$this->line(" → manuell: <fg=yellow>mv {$old} {$new}</>");
}
}
if (!$dry) {
$this->info('Fertig. Dienste ggf. neu starten: rspamd, dovecot, postfix, fail2ban');
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,234 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateDovecot24 extends Command
{
protected $signature = 'clubird:migrate-dovecot24 {--dry-run : Nur anzeigen was geändert würde}';
protected $description = 'Migriert Dovecot-Config von 2.3 auf 2.4 Syntax';
public function handle(): int
{
$dry = $this->option('dry-run');
// 1) dovecot.conf: dovecot_config_version als erste Zeile
$this->fixDovecotConf($dry);
// 2) 10-auth.conf: disable_plaintext_auth → auth_allow_cleartext
$this->fixAuthConf($dry);
// 3) 10-mail.conf: mail_location → mail_driver + mail_path
$this->fixMailConf($dry);
// 4) 10-master.conf: einzeilige Blöcke aufsplitten
$this->fixMasterConf($dry);
// 5) 10-ssl.conf: ssl_cert/ssl_key → ssl_server_cert_file/ssl_server_key_file
$this->fixSslConf($dry);
// 6) auth-sql.conf.ext: neue passdb/userdb-Syntax
$this->fixAuthSqlConf($dry);
if (!$dry) {
$this->info('Fertig. Starte dovecot neu…');
@exec('systemctl restart dovecot 2>&1', $out, $rc);
if ($rc === 0) {
$this->info('Dovecot erfolgreich gestartet.');
} else {
$this->error('Dovecot-Neustart fehlgeschlagen: ' . implode("\n", $out));
$this->line('Prüfen mit: doveconf -n 2>&1');
}
}
return self::SUCCESS;
}
private function fixDovecotConf(bool $dry): void
{
$file = '/etc/dovecot/dovecot.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
if (str_starts_with(trim($content), 'dovecot_config_version')) {
// Falsche Version ersetzen
$new = preg_replace('/^dovecot_config_version\s*=\s*\S+/m', 'dovecot_config_version = 2.4.1', $content);
} else {
$new = "dovecot_config_version = 2.4.1\n" . $content;
}
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde dovecot_config_version ergänzen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixMailConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-mail.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
$new = preg_replace_callback(
'/^mail_location\s*=\s*(\w+):(.+)$/m',
function ($m) {
return "mail_driver = {$m[1]}\nmail_path = {$m[2]}";
},
$content
);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde mail_location aufteilen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixMasterConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-master.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
// Einzeilige Blöcke "name { key = val }" → mehrzeilig
$new = preg_replace_callback(
'/^(\s*)(\S+)\s*\{\s*([^}]+)\s*\}(\s*)$/m',
function ($m) {
$indent = $m[1];
$name = $m[2];
$inner = trim($m[3]);
$pairs = preg_split('/\s+(?=\w+=)/', $inner);
$body = implode("\n{$indent} ", array_map('trim', $pairs));
return "{$indent}{$name} {\n{$indent} {$body}\n{$indent}}";
},
$content
);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde einzeilige Blöcke aufsplitten in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixSslConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-ssl.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
// ssl_cert = </path> → ssl_server_cert_file = /path
$new = preg_replace('/^ssl_cert\s*=\s*<(.+)$/m', 'ssl_server_cert_file = $1', $content);
$new = preg_replace('/^ssl_key\s*=\s*<(.+)$/m', 'ssl_server_key_file = $1', $new);
// Direkte Pfade ohne < (falls schon teilweise migriert)
$new = preg_replace('/^ssl_cert\s*=\s*(?!<)(.+)$/m', 'ssl_server_cert_file = $1', $new);
$new = preg_replace('/^ssl_key\s*=\s*(?!<)(.+)$/m', 'ssl_server_key_file = $1', $new);
// dovecot_storage_version in dovecot.conf (wenn noch nicht vorhanden)
$mainConf = '/etc/dovecot/dovecot.conf';
$mainContent = (string) @file_get_contents($mainConf);
if (!str_contains($mainContent, 'dovecot_storage_version')) {
if (!$dry) {
file_put_contents($mainConf, $mainContent . "\ndovecot_storage_version = 2.4.1\n");
$this->info(" dovecot_storage_version ergänzt in {$mainConf}");
} else {
$this->line(" [dry-run] würde dovecot_storage_version ergänzen in {$mainConf}");
}
}
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde ssl_cert/ssl_key umbenennen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixAuthConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/10-auth.conf';
$content = @file_get_contents($file);
if ($content === false) { $this->warn("Nicht gefunden: {$file}"); return; }
$new = str_replace('disable_plaintext_auth = yes', 'auth_allow_cleartext = no', $content);
$new = str_replace('disable_plaintext_auth = no', 'auth_allow_cleartext = yes', $new);
if ($new === $content) { $this->line(" ok (unverändert): {$file}"); return; }
if ($dry) { $this->line(" [dry-run] würde disable_plaintext_auth ersetzen in {$file}"); return; }
file_put_contents($file, $new);
$this->info(" aktualisiert: {$file}");
}
private function fixAuthSqlConf(bool $dry): void
{
$file = '/etc/dovecot/conf.d/auth-sql.conf.ext';
$sqlConf = '/etc/dovecot/dovecot-sql.conf.ext';
// Alte Werte aus dovecot-sql.conf.ext lesen
$sqlRaw = (string) @file_get_contents($sqlConf);
$host = $this->parseSqlValue($sqlRaw, 'host') ?: '127.0.0.1';
$dbname = $this->parseSqlValue($sqlRaw, 'dbname') ?: 'mailwolt';
$user = $this->parseSqlValue($sqlRaw, 'user') ?: 'mailwolt';
$pass = $this->parseSqlValue($sqlRaw, 'password') ?: '';
$scheme = $this->parseSqlValue($sqlRaw, 'default_pass_scheme') ?: 'BLF-CRYPT';
// password_query extrahieren (auch aus Kommentaren)
$query = $this->parsePasswordQuery($sqlRaw);
$new = "mysql {$host} {\n"
. " dbname = {$dbname}\n"
. " user = {$user}\n"
. " password = {$pass}\n"
. "}\n\n"
. "passdb sql {\n"
. " default_password_scheme = {$scheme}\n"
. " query = {$query}\n"
. "}\n\n"
. "userdb static {\n"
. " fields {\n"
. " uid = vmail\n"
. " gid = vmail\n"
. " home = /var/mail/vhosts/%{user|domain}/%{user|username}\n"
. " }\n"
. "}\n";
if ($dry) {
$this->line(" [dry-run] würde {$file} neu schreiben:");
$this->line($new);
return;
}
file_put_contents($file, $new);
$this->info(" neu geschrieben: {$file}");
}
private function parseSqlValue(string $content, string $key): ?string
{
// Aus connect-Zeile: connect = host=x dbname=y user=z password=w
if (preg_match('/^connect\s*=\s*(.+)/m', $content, $m)) {
$connect = $m[1];
if (preg_match('/' . preg_quote($key, '/') . '\s*=\s*(\S+)/i', $connect, $m2)) {
return trim($m2[1]);
}
}
// Direkte Zeile: key = value
if (preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(.+)/m', $content, $m)) {
return trim($m[1]);
}
return null;
}
private function parsePasswordQuery(string $content): string
{
// Auskommentierte oder aktive password_query / query Zeile
if (preg_match('/^#?\s*password_query\s*=\s*(.+)/m', $content, $m)) {
$q = trim($m[1]);
// %u → %{user}
$q = str_replace("'%u'", "'%{user}'", $q);
return rtrim($q, ';');
}
return "SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%{user}' AND is_active = 1 LIMIT 1";
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class MigrateEnvReverb extends Command
{
protected $signature = 'clubird:migrate-env-reverb';
protected $aliases = ['mailwolt:migrate-env-reverb'];
protected $description = 'Migriert veraltete REVERB_* .env-Werte auf Domain-Basis';
public function handle(): int
{
$env = base_path('.env');
if (!file_exists($env)) {
$this->warn('.env nicht gefunden.');
return self::SUCCESS;
}
$content = file_get_contents($env);
// APP_HOST ermitteln: .env → APP_URL → DB-Setting ui_domain
$appHost = $this->extractVar($content, 'APP_HOST');
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
$appUrl = $this->extractVar($content, 'APP_URL');
$appHost = parse_url($appUrl ?: '', PHP_URL_HOST) ?: '';
}
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
try {
$appHost = (string) \App\Models\Setting::get('ui_domain', '');
} catch (\Throwable) {
$appHost = '';
}
}
if (!$appHost || preg_match('/^\d+\.\d+\.\d+\.\d+$/', $appHost)) {
$this->warn('Kein gültiger Hostname gefunden Migration übersprungen.');
return self::SUCCESS;
}
$scheme = $this->detectScheme($appHost, $content);
$correctPort = $scheme === 'https' ? '443' : '80';
$viteHost = $this->extractVar($content, 'VITE_REVERB_HOST');
$currentPort = $this->extractVar($content, 'REVERB_PORT');
$hostOk = ($viteHost === '${REVERB_HOST}' || $viteHost === $appHost);
$portOk = ($currentPort === $correctPort);
if ($hostOk && $portOk) {
$this->info('REVERB-Werte bereits korrekt.');
return self::SUCCESS;
}
$port = $correctPort;
$fixes = [
'REVERB_HOST' => '${APP_HOST}',
'REVERB_PORT' => $port,
'REVERB_SCHEME' => $scheme,
'REVERB_PATH' => '/ws',
'REVERB_SERVER_HOST' => '127.0.0.1',
'REVERB_SERVER_PORT' => '8080',
'REVERB_SERVER_SCHEME' => 'http',
'REVERB_SERVER_PATH' => '',
'VITE_REVERB_HOST' => '${REVERB_HOST}',
'VITE_REVERB_PORT' => '${REVERB_PORT}',
'VITE_REVERB_SCHEME' => '${REVERB_SCHEME}',
'VITE_REVERB_PATH' => '${REVERB_PATH}',
];
foreach ($fixes as $key => $val) {
if (preg_match("/^{$key}=/m", $content)) {
$content = preg_replace("/^{$key}=.*/m", "{$key}={$val}", $content);
} else {
$content .= "\n{$key}={$val}";
}
}
// APP_HOST setzen falls fehlend
if (!$this->extractVar($content, 'APP_HOST')) {
$content .= "\nAPP_HOST={$appHost}";
}
file_put_contents($env, $content);
$this->info("REVERB .env migriert für Host: {$appHost}");
// Assets neu bauen damit wsHost korrekt eingebacken wird
$buildLog = base_path('../mailwolt-frontend-build.log');
exec('cd ' . escapeshellarg(base_path()) . ' && npm run build --silent 2>/dev/null', $out, $rc);
if ($rc !== 0) {
$this->warn('npm run build fehlgeschlagen bitte manuell ausführen.');
} else {
$this->info('Assets neu gebaut.');
}
return self::SUCCESS;
}
private function detectScheme(string $host, string $envContent): string
{
// 1. APP_URL in .env bereits https?
$appUrl = $this->extractVar($envContent, 'APP_URL');
if (str_starts_with($appUrl, 'https://')) return 'https';
// 2. nginx-Konfiguration prüfen (world-readable)
foreach (glob('/etc/nginx/sites-enabled/*') ?: [] as $f) {
$c = @file_get_contents($f) ?: '';
if (str_contains($c, $host) && str_contains($c, 'ssl_certificate')) return 'https';
}
// 3. letsencrypt-Pfade (falls doch lesbar)
if (file_exists("/etc/letsencrypt/renewal/{$host}.conf")
|| is_dir("/etc/letsencrypt/live/{$host}")
|| file_exists("/etc/letsencrypt/live/{$host}/fullchain.pem")) {
return 'https';
}
return 'http';
}
private function extractVar(string $content, string $key): string
{
preg_match("/^{$key}=(.*)$/m", $content, $m);
return trim($m[1] ?? '', " \t\"'");
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use App\Models\Setting;
class ProbeRbl extends Command
{
protected $signature = 'rbl:probe {--force : Ignoriert Intervalle und prüft sofort}';
protected $description = 'Prüft öffentliche RBLs und speichert das Ergebnis in settings:health.rbl';
// Intervalle
private int $minIntervalDays = 7; // frühestens alle 7 Tage neu prüfen
private int $ttlDays = 14; // Ergebnis 14 Tage gültig
public function handle(): int
{
$now = now();
$existing = (array) Setting::get('health.rbl', []) ?: [];
$lastAt = isset($existing['checked_at']) ? \Illuminate\Support\Carbon::parse($existing['checked_at']) : null;
$nextDue = $lastAt ? $lastAt->copy()->addDays($this->minIntervalDays) : null;
if (!$this->option('force') && $nextDue && $now->lt($nextDue)) {
$this->info("Übersprungen: nächste Prüfung erst ab {$nextDue->toIso8601String()} (force mit --force).");
return self::SUCCESS;
}
// IPs ermitteln (Installer-ENV bevorzugt)
[$ipv4, $ipv6] = $this->resolvePublicIpsFromInstallerEnv();
$ipv4 = $ipv4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
$ipv6 = $ipv6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
// Kandidat für RBL (nur IPv4)
$ip = $this->validIPv4($ipv4) ? $ipv4 : null;
if (!$ip) {
$file = trim((string) @file_get_contents('/etc/mailwolt/public_ip'));
if ($this->validIPv4($file)) $ip = $file;
}
if (!$ip) {
$curl = trim((string) @shell_exec('curl -fsS --max-time 2 ifconfig.me 2>/dev/null'));
if ($this->validIPv4($curl)) $ip = $curl;
}
if (!$ip) $ip = '0.0.0.0';
// Abfragen (DNS)
[$lists, $meta] = $this->queryRblLists($ip);
$payload = [
'ip' => $ip,
'ipv4' => $ipv4,
'ipv6' => $ipv6,
'hits' => count($lists),
'lists' => array_values($lists), // nur die tatsächlich gelisteten Zonen
'meta' => $meta, // {zone:{status, txt?}}
'checked_at' => $now->toIso8601String(),
'valid_until' => $now->copy()->addDays($this->ttlDays)->toIso8601String(),
'min_next' => $now->copy()->addDays($this->minIntervalDays)->toIso8601String(),
];
// Persistieren (DB) + in Redis spiegeln
Setting::set('health.rbl', $payload);
Cache::put('health.rbl', $payload, now()->addDays($this->ttlDays));
$this->info(sprintf(
'RBL: ip=%s hits=%d lists=[%s]',
$payload['ip'], $payload['hits'], implode(',', $payload['lists'])
));
return self::SUCCESS;
}
/* ---------- Helpers ---------- */
private function resolvePublicIpsFromInstallerEnv(): array
{
$file = '/etc/mailwolt/installer.env';
if (!is_readable($file)) return [null, null];
$ipv4 = $ipv6 = null;
foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, '=')) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
$v = trim($v, " \t\n\r\0\x0B\"'");
if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
}
return [ $ipv4, $ipv6 ];
}
private function validIPv4(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}
private function validIPv6(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
}
/**
* Gibt [listedZones, meta] zurück.
* meta[zone] = ['status'=>'listed|clean|blocked|nx', 'txt'=>?string]
*/
private function queryRblLists(string $ip): array
{
if (!$this->validIPv4($ip)) return [[], []];
$rev = implode('.', array_reverse(explode('.', $ip)));
// Kuratierte, erreichbare Zonen (ohne kaputte Subdomains)
$zones = [
// Spamhaus ZEN rate-limitiert, liefert „blocked“ bei Open Resolver
'zen.spamhaus.org',
// PSBL
'psbl.surriel.com',
// UCEPROTECT Level 1
'dnsbl-1.uceprotect.net',
// s5h
'bl.s5h.net',
];
$listed = [];
$meta = [];
foreach ($zones as $zone) {
$q = "{$rev}.{$zone}.";
$txt = @dns_get_record($q, DNS_TXT) ?: [];
$a = @dns_get_record($q, DNS_A) ?: [];
// Spamhaus „blocked“ Heuristik
$blocked = false;
if ($zone === 'zen.spamhaus.org') {
foreach ($a as $rec) {
if (!empty($rec['ip']) && in_array($rec['ip'], ['127.255.255.254','127.255.255.255'], true)) {
$blocked = true; break;
}
}
if (!$blocked) {
foreach ($txt as $rec) {
$t = implode('', $rec['txt'] ?? []);
if (stripos($t, 'open resolver') !== false) { $blocked = true; break; }
}
}
}
if ($blocked) {
$meta[$zone] = ['status' => 'blocked', 'txt' => 'Spamhaus blockt nutze privaten Resolver'];
continue;
}
$hasA = !empty($a);
$hasTXT = !empty($txt);
if ($hasA || $hasTXT) {
$listed[] = $zone;
$meta[$zone] = ['status' => 'listed', 'txt' => $hasTXT ? ($txt[0]['txt'][0] ?? null) : null];
} else {
// NXDOMAIN / sauber
$meta[$zone] = ['status' => 'clean', 'txt' => null];
}
}
return [$listed, $meta];
}
}

View File

@ -7,7 +7,7 @@
//
//class ProvisionCert extends Command
//{
//// protected $signature = 'mailwolt:provision-cert
//// protected $signature = 'clubird:provision-cert
//// {domain : z.B. mail.example.com}
//// {--email= : E-Mail für Let\'s Encrypt}
//// {--self-signed : Statt LE ein self-signed Zertifikat erzeugen}';

View File

@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use Illuminate\Console\Command;
class RestoreRun extends Command
{
protected $signature = 'restore:run {backupJobId : ID des Quell-Backups} {token : Statusdatei-Token}';
protected $description = 'Stellt ein Backup wieder her und schreibt den Status in eine Temp-Datei';
public function handle(): int
{
$sourceJob = BackupJob::find($this->argument('backupJobId'));
$token = $this->argument('token');
$statusFile = sys_get_temp_dir() . '/' . $token . '.json';
$artifact = $sourceJob?->artifact_path;
if (!$artifact || !file_exists($artifact)) {
$this->writeStatus($statusFile, 'failed', ['✗ Archivdatei nicht gefunden: ' . $artifact]);
return self::FAILURE;
}
$this->writeStatus($statusFile, 'running', ['Archiv wird extrahiert…']);
$extractDir = sys_get_temp_dir() . '/mailwolt_restore_' . $token;
mkdir($extractDir, 0700, true);
$log = [];
$exitCode = 0;
// ── 1. Archiv extrahieren ─────────────────────────────────────────
$tarOut = [];
$tarExit = 0;
exec(
"tar --ignore-failed-read -xzf " . escapeshellarg($artifact)
. " -C " . escapeshellarg($extractDir) . " 2>&1",
$tarOut, $tarExit
);
if ($tarExit > 1) {
$log[] = '✗ Extraktion fehlgeschlagen: ' . implode('; ', array_slice($tarOut, -3));
$exitCode = 1;
} else {
$log[] = '✓ Archiv extrahiert';
}
$this->writeStatus($statusFile, 'running', $log);
// ── 2. Datenbank ─────────────────────────────────────────────────
$dbFiles = [];
exec("find " . escapeshellarg($extractDir) . " -name 'database.sql' -type f 2>/dev/null", $dbFiles);
if (!empty($dbFiles)) {
$log[] = 'Datenbank wird importiert…';
$this->writeStatus($statusFile, 'running', $log);
[$ok, $msg] = $this->importDatabase($dbFiles[0]);
$log[] = $ok ? '✓ Datenbank wiederhergestellt' : '✗ Datenbank: ' . $msg;
if (!$ok) $exitCode = 1;
} else {
$log[] = '— Kein Datenbank-Dump im Archiv';
}
$this->writeStatus($statusFile, 'running', $log);
// ── 3. E-Mails (Maildirs) ────────────────────────────────────────
foreach (["{$extractDir}/var/mail", "{$extractDir}/var/vmail"] as $mailSrc) {
if (is_dir($mailSrc)) {
$log[] = 'E-Mails werden wiederhergestellt…';
$this->writeStatus($statusFile, 'running', $log);
$destParent = '/' . implode('/', array_slice(explode('/', $mailSrc, -1 + substr_count($mailSrc, '/')), 1, -1));
$cpOut = [];
$cpExit = 0;
exec("cp -rp " . escapeshellarg($mailSrc) . " " . escapeshellarg($destParent) . "/ 2>&1", $cpOut, $cpExit);
$log[] = $cpExit === 0
? '✓ E-Mails wiederhergestellt'
: '✗ Mails: ' . implode('; ', array_slice($cpOut, -3));
if ($cpExit !== 0) $exitCode = 1;
}
}
// ── 4. Konfiguration ─────────────────────────────────────────────
$etcSrc = "{$extractDir}/etc";
if (is_dir($etcSrc)) {
$log[] = 'Konfiguration wird wiederhergestellt…';
$this->writeStatus($statusFile, 'running', $log);
foreach (scandir($etcSrc) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
$cpOut = [];
$cpExit = 0;
exec("cp -rp " . escapeshellarg("{$etcSrc}/{$entry}") . " /etc/ 2>&1", $cpOut, $cpExit);
$log[] = $cpExit === 0
? '✓ /etc/' . $entry
: '— /etc/' . $entry . ': ' . implode('; ', array_slice($cpOut, -1));
}
}
// ── 5. Dienste neu laden ─────────────────────────────────────────
foreach (['postfix', 'dovecot'] as $svc) {
if (is_dir("{$etcSrc}/{$svc}")) {
$restOut = [];
exec("systemctl reload-or-restart {$svc} 2>&1", $restOut, $restExit);
$log[] = $restExit === 0 ? '✓ ' . $svc . ' neu geladen' : '— ' . $svc . ': ' . implode('; ', $restOut);
}
}
// ── Aufräumen ────────────────────────────────────────────────────
exec("rm -rf " . escapeshellarg($extractDir));
$finalStatus = $exitCode === 0 ? 'ok' : 'failed';
$this->writeStatus($statusFile, $finalStatus, $log);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
private function importDatabase(string $sqlFile): array
{
$conn = config('database.connections.' . config('database.default'));
if (($conn['driver'] ?? '') !== 'mysql') {
return [false, 'Nur MySQL/MariaDB wird unterstützt.'];
}
$cmd = 'MYSQL_PWD=' . escapeshellarg($conn['password'] ?? '')
. ' mysql'
. ' -h ' . escapeshellarg($conn['host'] ?? '127.0.0.1')
. ' -P ' . escapeshellarg((string)($conn['port'] ?? '3306'))
. ' -u ' . escapeshellarg($conn['username'] ?? '')
. ' ' . escapeshellarg($conn['database'] ?? '')
. ' < ' . escapeshellarg($sqlFile)
. ' 2>&1';
$output = [];
$exit = 0;
exec($cmd, $output, $exit);
return $exit === 0
? [true, '']
: [false, implode('; ', array_slice($output, -3))];
}
private function writeStatus(string $file, string $status, array $log): void
{
file_put_contents($file, json_encode([
'status' => $status,
'log' => implode("\n", $log),
'timestamp' => time(),
]));
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use App\Services\SandboxMailParser;
use Illuminate\Console\Command;
class SandboxReceive extends Command
{
protected $signature = 'sandbox:receive {--to=* : Envelope recipients}';
protected $description = 'Receive a raw email from Postfix pipe and store in sandbox';
public function handle(SandboxMailParser $parser): int
{
$raw = '';
$stdin = fopen('php://stdin', 'r');
while (!feof($stdin)) {
$raw .= fread($stdin, 8192);
}
fclose($stdin);
if (empty(trim($raw))) {
return 1;
}
$recipients = $this->option('to') ?? [];
$parser->parseAndStore($raw, $recipients);
return 0;
}
}

View File

@ -1,5 +1,6 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
@ -8,25 +9,41 @@ use App\Models\Setting;
class StorageProbe extends Command
{
protected $signature = 'health:probe-disk {target=/}';
protected $description = 'Speichert Storage-Werte (inkl. Frei+5%) in settings:health.disk';
protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
// Quelle für vorberechnete Mail-Summen (kommt aus mail:update-stats)
private const MAILBOX_TOTALS_KEY = 'mailbox.totals';
// Wie lange dürfen Mail-Summen alt sein, bevor wir auf du-Fallback gehen (Sekunden)
private const MAILBOX_TOTALS_STALE = 900; // 15 Min
public function handle(): int
{
$target = $this->argument('target') ?: '/';
$data = $this->probe($target);
$data = $this->probe($target);
// Persistiert (DB + Redis) über dein Settings-Model
Setting::set('health.disk', $data);
Setting::set('health.disk_updated_at', now()->toIso8601String());
// hübsche Konsole
$hb = function (int $bytes): string {
$b = max(0, $bytes);
if ($b >= 1024 ** 3) return number_format($b / 1024 ** 3, 1) . ' GB';
if ($b >= 1024 ** 2) return number_format($b / 1024 ** 2, 2) . ' MiB';
if ($b >= 1024) return number_format($b / 1024, 0) . ' KiB';
return $b . ' B';
};
$bd = $data['breakdown_bytes'] ?? ['system' => 0, 'mails' => 0, 'backup' => 0];
$this->info(sprintf(
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d)',
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
$data['mount'],
$data['total_gb'],
$data['used_gb'],
$data['free_gb'],
$data['free_plus_reserve_gb'],
$data['percent_used_total'],
$hb((int)$bd['system']), $hb((int)$bd['mails']), $hb((int)$bd['backup'])
));
return self::SUCCESS;
@ -34,43 +51,360 @@ class StorageProbe extends Command
protected function probe(string $target): array
{
$line = trim((string) @shell_exec('df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
// ── 1) df lesen (Gesamt/Frei inkl. Reserve) ──────────────────────────
$line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
$device = $mount = '';
$totalKb = $usedKb = $availKb = 0;
if ($line !== '') {
$p = preg_split('/\s+/', $line);
if (count($p) >= 6) {
$device = $p[0];
$totalKb = (int) $p[1]; // TOTAL (inkl. Reserve)
$usedKb = (int) $p[2]; // Used
$availKb = (int) $p[3]; // Avail (User-sicht)
$mount = $p[5];
$device = $p[0];
$totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
$usedKb = (int)$p[2]; // Used
$availKb = (int)$p[3]; // Avail (User-sicht)
$mount = $p[5];
}
}
$toGiB = static fn($kb) => (int) round(max(0, (int)$kb) / (1024*1024));
$totalGb = $toGiB($totalKb);
$freeGb = $toGiB($availKb); // user-verfügbar
$usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
$res5Gb = (int) round($totalGb * 0.05); // 5% von Gesamt
$toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // Ganzzahl für Kopfzahlen
$totalGb = $toGiB_i($totalKb);
$freeGb = $toGiB_i($availKb);
$usedGb = max(0, $totalGb - $freeGb);
$res5Gb = (int)round($totalGb * 0.05);
$freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
$percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
$percentUsed = $totalGb > 0 ? (int) round($usedGb * 100 / $totalGb) : 0;
// Bytes für Breakdown rechnen
$totalBytes = (int)$totalKb * 1024;
$freeBytes = (int)$availKb * 1024;
$usedBytes = max(0, $totalBytes - $freeBytes);
// ── 2) Mails: bevorzugt aus Cache (mail:update-stats) ────────────────
[$mailUsersBytes, $mailSystemBytes] = $this->readMailTotals();
// ── 3) Backups schnell messen ────────────────────────────────────────
$bytesBackup = $this->duBytesDir('/var/backups/mailwolt');
// ── 4) Breakdown auflösen und konsistent machen ─────────────────────
// alles, was nicht Mails/Backups ist, dem System zuordnen
$bytesMails = max(0, (int)$mailUsersBytes); // nur user_mail in "Mails"
$bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
// Wenn Vorberechnung system_mail existiert, zähle sie explizit zum System.
$bytesSystem += max(0, (int)$mailSystemBytes);
// negativ verhindern (Messrauschen)
if ($bytesSystem < 0) {
$bytesSystem = 0;
}
return [
'device' => $device ?: 'unknown',
'mount' => $mount ?: $target,
'device' => $device ?: 'unknown',
'mount' => $mount ?: $target,
'total_gb' => $totalGb,
'used_gb' => $usedGb, // inkl. Reserve
'free_gb' => $freeGb, // User-sicht
'reserve5_gb' => $res5Gb, // Info
'free_plus_reserve_gb' => $freePlusReserveGb, // ← das willst du anzeigen
'total_gb' => $totalGb,
'used_gb' => $usedGb,
'free_gb' => $freeGb,
'reserve5_gb' => $res5Gb,
'free_plus_reserve_gb' => $freePlusReserveGb,
'percent_used_total' => $percentUsed, // fürs Donut (~15%)
'percent_used_total' => $percentUsed,
'breakdown_bytes' => [
'system' => $bytesSystem,
'mails' => $bytesMails,
'backup' => $bytesBackup,
],
];
}
/**
* Liest vorberechnete Mail-Summen aus settings: mail.totals.
* Fallback: wenn nicht vorhanden/zu alt, ermittelt Mails per du (nur dann).
*
* @return array{0:int,1:int} [users_bytes, system_bytes]
*/
private function readMailTotals(): array
{
$totals = (array)(Setting::get(self::MAILBOX_TOTALS_KEY, []) ?: []);
$ts = isset($totals['updated_at']) ? strtotime((string)$totals['updated_at']) : null;
$fresh = $ts && (time() - $ts) <= self::MAILBOX_TOTALS_STALE;
if ($fresh) {
$users = (int)($totals['users_bytes'] ?? 0);
$system = (int)($totals['system_bytes'] ?? 0);
return [max(0, $users), max(0, $system)];
}
// Fallback EINMAL: grob mails via detectMailRoot() messen
$root = $this->detectMailRoot();
$usersBytes = $root ? $this->duBytesDir($root) : 0;
return [max(0, $usersBytes), 0];
}
/**
* Versucht, das Wurzelverzeichnis der Maildaten zu finden (Dovecot/Postfix),
* ohne auf konkrete Setups festgenagelt zu sein.
*/
private function detectMailRoot(): ?string
{
// Dovecot bevorzugt
$ml = trim((string)@shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
if ($ml !== '') {
if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
$root = rtrim($m[1]);
foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
if (str_ends_with($root, $suffix)) {
$root = dirname($root);
break;
}
}
if (is_dir($root)) return $root;
}
}
// Postfix-Konfiguration
$vmb = trim((string)@shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
if ($vmb !== '' && is_dir($vmb)) return $vmb;
// Fallbacks
foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
if (is_dir($cand)) return $cand;
}
return null;
}
/** Summe in Bytes für ein Verzeichnisbaum; robust & schnell genug. */
private function duBytesDir(string $path): int
{
if (!is_dir($path)) return 0;
$out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
return max(0, (int)trim((string)$out));
}
}
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;
//use App\Models\Setting;
//
//class StorageProbe extends Command
//{
// protected $signature = 'health:probe-disk {target=/}';
// protected $description = 'Speichert Storage-Werte (inkl. Breakdown) in settings:health.disk';
//
// public function handle(): int
// {
// $target = $this->argument('target') ?: '/';
// $data = $this->probe($target);
//
// Setting::set('health.disk', $data);
// Setting::set('health.disk_updated_at', now()->toIso8601String());
//
// $hb = function (int $bytes): string {
// $b = max(0, $bytes);
// if ($b >= 1024**3) return number_format($b / 1024**3, 1).' GB';
// if ($b >= 1024**2) return number_format($b / 1024**2, 2).' MiB';
// if ($b >= 1024) return number_format($b / 1024, 0).' KiB';
// return $b.' B';
// };
//
// $this->info(sprintf(
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d) breakdown: system=%s mails=%s backups=%s',
// $data['mount'],
// $data['total_gb'],
// $data['used_gb'],
// $data['free_gb'],
// $data['free_plus_reserve_gb'],
// $data['percent_used_total'],
// $hb((int)$data['breakdown_bytes']['system']),
// $hb((int)$data['breakdown_bytes']['mails']),
// $hb((int)$data['breakdown_bytes']['backup']),
// ));
// return self::SUCCESS;
// }
//
// private function detectMailRoot(): ?string
// {
// // Dovecot
// $ml = trim((string) @shell_exec('doveconf -n 2>/dev/null | awk -F= \'/^mail_location/ {print $2}\''));
// if ($ml !== '') {
// if (preg_match('~^(?:maildir|mdbox|sdbox):([^%]+)~i', $ml, $m)) {
// $root = rtrim($m[1]);
// foreach (['/Maildir', '/mdbox', '/sdbox'] as $suffix) {
// if (str_ends_with($root, $suffix)) { $root = dirname($root); break; }
// }
// if (is_dir($root)) return $root;
// }
// }
// // Postfix
// $vmb = trim((string) @shell_exec('postconf -n 2>/dev/null | awk -F= \'/^virtual_mailbox_base/ {print $2}\''));
// if ($vmb !== '' && is_dir($vmb)) return $vmb;
//
// // Fallbacks
// foreach (['/var/vmail', '/var/mail/vhosts', '/srv/mail/vhosts', '/home/vmail'] as $cand) {
// if (is_dir($cand)) return $cand;
// }
// return null;
// }
//
// private function duBytesDir(string $path): int
// {
// if (!is_dir($path)) return 0;
// $out = @shell_exec('LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1');
// return max(0, (int) trim((string) $out));
// }
//
// protected function probe(string $target): array
// {
// // --- df: Gesamtdaten des Filesystems (inkl. Reserve) -----------------
// $line = trim((string)@shell_exec('LC_ALL=C df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
//
// $device = $mount = '';
// $totalKb = $usedKb = $availKb = 0;
//
// if ($line !== '') {
// $p = preg_split('/\s+/', $line);
// if (count($p) >= 6) {
// $device = $p[0];
// $totalKb = (int)$p[1]; // TOTAL (inkl. Reserve)
// $usedKb = (int)$p[2]; // Used
// $availKb = (int)$p[3]; // Avail (User-sicht)
// $mount = $p[5];
// }
// }
//
// $toGiB_i = static fn($kb) => (int)round(max(0, (int)$kb) / (1024 * 1024)); // ganzzahlig (UI: Gesamt/Genutzt/Frei)
// $toGiB_f = static fn($kb) => round(max(0, (int)$kb) / (1024 * 1024), 1); // eine Nachkommastelle (Breakdown/Legende)
//
// $totalGb = $toGiB_i($totalKb);
// $freeGb = $toGiB_i($availKb); // user-verfügbar
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
// $res5Gb = (int)round($totalGb * 0.05); // 5% von Gesamt
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
// $percentUsed = $totalGb > 0 ? (int)round($usedGb * 100 / $totalGb) : 0;
//
// $duBytes = function (string $path): int {
// if (!is_dir($path)) return 0;
// $b = (int) trim((string) @shell_exec(
// 'LC_ALL=C du -sb --apparent-size ' . escapeshellarg($path) . ' 2>/dev/null | cut -f1'
// ));
// return max(0, $b);
// };
//
// $mailRoot = $this->detectMailRoot();
// $bytesMails = $mailRoot ? $this->duBytesDir($mailRoot) : 0;
// $bytesBackup = $duBytes('/var/backups/mailwolt');
//
// $totalBytes = (int) $totalKb * 1024;
// $freeBytes = (int) $availKb * 1024;
// $usedBytes = max(0, $totalBytes - $freeBytes);
//
// $bytesSystem = max(0, $usedBytes - ($bytesMails + $bytesBackup));
//
// return [
// 'device' => $device ?: 'unknown',
// 'mount' => $mount ?: $target,
//
// 'total_gb' => $totalGb,
// 'used_gb' => $usedGb, // inkl. Reserve
// 'free_gb' => $freeGb, // User-sicht
// 'reserve5_gb' => $res5Gb, // Info
// 'free_plus_reserve_gb' => $freePlusReserveGb, // Anzeige „Frei“
//
// 'percent_used_total' => $percentUsed,
//
// // Reale Breakdown-Werte
// 'breakdown_bytes' => [
// 'system' => $bytesSystem,
// 'mails' => $bytesMails,
// 'backup' => $bytesBackup,
// ],
// ];
// }
//}
//
//namespace App\Console\Commands;
//
//use Illuminate\Console\Command;
//use App\Models\Setting;
//
//class StorageProbe extends Command
//{
// protected $signature = 'health:probe-disk {target=/}';
// protected $description = 'Speichert Storage-Werte (inkl. Frei+5%) in settings:health.disk';
//
// public function handle(): int
// {
// $target = $this->argument('target') ?: '/';
// $data = $this->probe($target);
//
// // Persistiert (DB + Redis) über dein Settings-Model
// Setting::set('health.disk', $data);
// Setting::set('health.disk_updated_at', now()->toIso8601String());
//
// $this->info(sprintf(
// 'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d)',
// $data['mount'],
// $data['total_gb'],
// $data['used_gb'],
// $data['free_gb'],
// $data['free_plus_reserve_gb'],
// $data['percent_used_total'],
// ));
//
// return self::SUCCESS;
// }
//
// protected function probe(string $target): array
// {
// $line = trim((string) @shell_exec('df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
//
// $device = $mount = '';
// $totalKb = $usedKb = $availKb = 0;
//
// if ($line !== '') {
// $p = preg_split('/\s+/', $line);
// if (count($p) >= 6) {
// $device = $p[0];
// $totalKb = (int) $p[1]; // TOTAL (inkl. Reserve)
// $usedKb = (int) $p[2]; // Used
// $availKb = (int) $p[3]; // Avail (User-sicht)
// $mount = $p[5];
// }
// }
//
// $toGiB = static fn($kb) => (int) round(max(0, (int)$kb) / (1024*1024));
//
// $totalGb = $toGiB($totalKb);
// $freeGb = $toGiB($availKb); // user-verfügbar
// $usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
// $res5Gb = (int) round($totalGb * 0.05); // 5% von Gesamt
// $freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
//
// $percentUsed = $totalGb > 0 ? (int) round($usedGb * 100 / $totalGb) : 0;
//
// return [
// 'device' => $device ?: 'unknown',
// 'mount' => $mount ?: $target,
//
// 'total_gb' => $totalGb,
// 'used_gb' => $usedGb, // inkl. Reserve
// 'free_gb' => $freeGb, // User-sicht
// 'reserve5_gb' => $res5Gb, // Info
// 'free_plus_reserve_gb' => $freePlusReserveGb, // ← das willst du anzeigen
//
// 'percent_used_total' => $percentUsed, // fürs Donut (~15%)
// 'breakdown' => [
// 'system_gb' => 5.2, // OS, App, Logs …
// 'mails_gb' => 2.8, // /var/mail/vhosts
// 'backup_gb' => 1.0, // /var/backups/mailwolt (oder wohin du sicherst)
// ],
// ];
// }
//}

View File

@ -15,103 +15,238 @@ class UpdateMailboxStats extends Command
protected $signature = 'mail:update-stats {--user=}';
protected $description = 'Aktualisiert Quota & Nachrichtenzahl (Settings/Redis; ohne DB-Spalten).';
// public function handle(): int
// {
// $log = Log::channel('mailstats');
// $onlyUser = trim((string)$this->option('user')) ?: null;
// $t0 = microtime(true);
//
// // Basis-Query: nur aktive, keine System-Mailboxen und keine System-Domains
// $base = MailUser::query()
// ->select(['id', 'domain_id', 'localpart', 'email', 'is_active', 'is_system'])
// ->with(['domain:id,domain,is_system'])
// ->where('is_active', true)
// ->where('is_system', false)
// ->whereHas('domain', fn($d) => $d->where('is_system', false));
//
// if ($onlyUser) {
// $base->where('email', $onlyUser);
// }
//
// $checked = 0;
// $changed = 0;
//
// $log->info('mail:update-stats START', ['only' => $onlyUser]);
//
// $base->orderBy('id')->chunkById(200, function ($users) use (&$checked, &$changed, $log) {
// foreach ($users as $u) {
// $checked++;
//
// // Email robust bestimmen (raw -> accessor -> zusammengesetzt)
// $raw = (string)($u->getRawOriginal('email') ?? '');
// $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null);
//
// if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) {
// // still kein Log-Spam
// continue;
// }
//
// [$local, $domain] = explode('@', $email, 2);
// $maildir = "/var/mail/vhosts/{$domain}/{$local}";
//
// // Größe in Bytes (rekursiv)
// $usedBytes = 0;
// if (is_dir($maildir)) {
// $it = new RecursiveIteratorIterator(
// new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
// );
// foreach ($it as $f) {
// if ($f->isFile()) $usedBytes += $f->getSize();
// }
// }
//
// // Message-Count
// $messageCount = $this->countViaDoveadm($email);
// if ($messageCount === null) {
// $messageCount = $this->countViaFilesystem($maildir);
// }
//
// $key = "mailbox.{$email}";
// $prev = (array)(Setting::get($key, []) ?: []);
// $new = [
// 'used_bytes' => (int)$usedBytes,
// 'message_count' => (int)$messageCount,
// 'updated_at' => now()->toDateTimeString(),
// ];
//
// if (($prev['used_bytes'] ?? null) !== $new['used_bytes']
// || ($prev['message_count'] ?? null) !== $new['message_count']) {
// Setting::set($key, $new);
// $changed++;
//
// // kurze Ausgabe & Info-Log NUR bei Änderung
// $this->line(sprintf("%-35s %7.1f MiB %5d msgs",
// $email, $usedBytes / 1048576, $messageCount));
// $log->info('updated', ['email' => $email, 'used_bytes' => $new['used_bytes'], 'message_count' => $new['message_count']]);
// }
// }
// });
//
// $ms = (int)((microtime(true) - $t0) * 1000);
// $log->info('mail:update-stats DONE', compact('checked', 'changed', 'ms'));
// $this->info('Mailbox-Statistiken aktualisiert.');
// return self::SUCCESS;
// }
public function handle(): int
{
$log = Log::channel('mailstats');
$onlyUser = trim((string)$this->option('user')) ?: null;
$t0 = microtime(true);
// Basis-Query: nur aktive, keine System-Mailboxen und keine System-Domains
// Summen
$sumUserBytes = 0;
$sumSystemBytes = 0;
// aktiver Benutzerbestand (inkl. Domains, um system/non-system zu unterscheiden)
$base = MailUser::query()
->select(['id', 'domain_id', 'localpart', 'email', 'is_active', 'is_system'])
->select(['id','domain_id','localpart','email','is_active','is_system'])
->with(['domain:id,domain,is_system'])
->where('is_active', true)
->where('is_system', false)
->whereHas('domain', fn($d) => $d->where('is_system', false));
->where('is_active', true);
if ($onlyUser) {
$base->where('email', $onlyUser);
}
$checked = 0;
$changed = 0;
$checked = 0; $changed = 0;
$log->info('mail:update-stats START', ['only' => $onlyUser]);
$base->orderBy('id')->chunkById(200, function ($users) use (&$checked, &$changed, $log) {
$base->orderBy('id')->chunkById(200, function ($users) use (&$checked,&$changed,&$sumUserBytes,&$sumSystemBytes,$log) {
foreach ($users as $u) {
$checked++;
// Email robust bestimmen (raw -> accessor -> zusammengesetzt)
$raw = (string)($u->getRawOriginal('email') ?? '');
$email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null);
if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) {
// still kein Log-Spam
continue;
}
[$local, $domain] = explode('@', $email, 2);
$maildir = "/var/mail/vhosts/{$domain}/{$local}";
// Größe in Bytes (rekursiv)
$usedBytes = 0;
if (is_dir($maildir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $usedBytes += $f->getSize();
}
$isSystemDomain = (bool)($u->domain->is_system ?? false);
if ($isSystemDomain || $u->is_system) {
$sumSystemBytes += $this->sizeViaDoveadm($email) ?? $this->sizeViaFilesystem($maildir) ?? 0;
continue;
}
// Message-Count
$messageCount = $this->countViaDoveadm($email);
// Alle Ordner zählen (nicht nur INBOX) — doveadm ist primäre Quelle
$messageCount = $this->countAllFoldersViaDoveadm($email);
$usedBytes = $this->sizeViaDoveadm($email);
if ($messageCount === null) {
$messageCount = $this->countViaFilesystem($maildir);
// Filesystem-Fallback nur wenn das Verzeichnis lesbar ist
$fsCount = $this->countViaFilesystem($maildir);
$fsSize = $this->sizeViaFilesystem($maildir);
if ($fsCount === 0 && $fsSize === null) {
$log->debug('skip (no access)', ['email' => $email]);
continue;
}
$messageCount = $fsCount;
$usedBytes = $fsSize ?? 0;
} else {
$usedBytes = $usedBytes ?? $this->sizeViaFilesystem($maildir) ?? 0;
}
$key = "mailbox.{$email}";
$prev = (array)(Setting::get($key, []) ?: []);
$new = [
'used_bytes' => (int)$usedBytes,
$sumUserBytes += $usedBytes;
// Immer schreiben wenn doveadm erfolgreich war — keine Change-Detection
Setting::set("mailbox.{$email}", [
'used_bytes' => (int)$usedBytes,
'message_count' => (int)$messageCount,
'updated_at' => now()->toDateTimeString(),
];
if (($prev['used_bytes'] ?? null) !== $new['used_bytes']
|| ($prev['message_count'] ?? null) !== $new['message_count']) {
Setting::set($key, $new);
$changed++;
// kurze Ausgabe & Info-Log NUR bei Änderung
$this->line(sprintf("%-35s %7.1f MiB %5d msgs",
$email, $usedBytes / 1048576, $messageCount));
$log->info('updated', ['email' => $email, 'used_bytes' => $new['used_bytes'], 'message_count' => $new['message_count']]);
}
'updated_at' => now()->toDateTimeString(),
]);
\Illuminate\Support\Facades\DB::table('mail_users')
->where('id', $u->id)
->update([
'used_bytes' => (int)$usedBytes,
'message_count' => (int)$messageCount,
'stats_refreshed_at' => now(),
]);
$changed++;
$this->line(sprintf("%-35s %7.2f MiB %5d msgs",
$email, $usedBytes / 1048576, $messageCount));
$log->info('updated', ['email' => $email, 'used_bytes' => $usedBytes, 'message_count' => $messageCount]);
}
});
$ms = (int)((microtime(true) - $t0) * 1000);
$log->info('mail:update-stats DONE', compact('checked', 'changed', 'ms'));
// Totals persistieren (Nutzen wir später im StorageProbe)
Setting::set('mailbox.totals', [
'users_bytes' => (int)$sumUserBytes, // alle nicht-systemischen Mailboxen
'system_bytes' => (int)$sumSystemBytes, // systemische Mailboxen
'updated_at' => now()->toIso8601String(),
]);
$ms = (int)((microtime(true)-$t0)*1000);
$log->info('mail:update-stats DONE', compact('checked','changed','ms','sumUserBytes','sumSystemBytes'));
$this->info('Mailbox-Statistiken aktualisiert.');
return self::SUCCESS;
}
private function countViaDoveadm(string $email): ?int
private function doveadmStatus(string $email, string $fields): array
{
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
. escapeshellarg($email) . " messages INBOX 2>&1";
. escapeshellarg($email) . " {$fields} INBOX 2>/dev/null";
$out = [];
$rc = 0;
$rc = 0;
exec($cmd, $out, $rc);
if ($rc !== 0) return null;
if ($rc !== 0) return [];
// header: "mailbox\tmessages" data: "INBOX\t4"
$header = null;
foreach ($out as $line) {
if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) {
return (int)$m[1];
$parts = explode("\t", trim($line));
if ($header === null) {
$header = $parts;
continue;
}
if (count($parts) !== count($header)) continue;
return array_combine($header, $parts);
}
return [];
}
private function countAllFoldersViaDoveadm(string $email): ?int
{
// Entwürfe, Papierkorb und Spam/Junk nicht mitzählen
static $exclude = ['Drafts', 'Trash', 'Junk', 'Spam'];
$cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u "
. escapeshellarg($email) . " messages '*' 2>/dev/null";
$out = [];
$rc = 0;
exec($cmd, $out, $rc);
if ($rc !== 0 || count($out) < 2) return null;
$total = 0;
$header = null;
foreach ($out as $line) {
$parts = explode("\t", trim($line));
if ($header === null) { $header = $parts; continue; }
if (count($parts) !== count($header)) continue;
$row = array_combine($header, $parts);
if (in_array($row['mailbox'] ?? '', $exclude, true)) continue;
$total += (int)($row['messages'] ?? 0);
}
return $total;
}
private function sizeViaDoveadm(string $email): ?int
{
$row = $this->doveadmStatus($email, 'vsize');
if (isset($row['vsize'])) return (int)$row['vsize'];
return null;
}
@ -121,7 +256,7 @@ class UpdateMailboxStats extends Command
foreach (['cur', 'new'] as $sub) {
$dir = "{$maildir}/{$sub}";
if (!is_dir($dir)) continue;
$h = opendir($dir);
$h = @opendir($dir);
if (!$h) continue;
while (($fn = readdir($h)) !== false) {
if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue;
@ -131,6 +266,23 @@ class UpdateMailboxStats extends Command
}
return $n;
}
private function sizeViaFilesystem(string $maildir): ?int
{
if (!is_dir($maildir) || !is_readable($maildir)) return null;
try {
$bytes = 0;
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $bytes += $f->getSize();
}
return $bytes;
} catch (\Throwable) {
return null;
}
}
}
//namespace App\Console\Commands;

View File

@ -0,0 +1,124 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class WizardDomains extends Command
{
protected $signature = 'clubird:wizard-domains
{--ui= : UI-Domain}
{--mail= : Mail-Domain}
{--webmail= : Webmail-Domain}
{--ssl=1 : SSL automatisch (1/0)}';
protected $aliases = ['mailwolt:wizard-domains'];
protected $description = 'Wizard: Domains einrichten mit Status-Dateien';
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function handle(): int
{
$ui = $this->option('ui');
$mail = $this->option('mail');
$webmail = $this->option('webmail');
$ssl = (bool)(int)$this->option('ssl');
@mkdir(self::STATE_DIR, 0755, true);
foreach (['ui', 'mail', 'webmail'] as $key) {
file_put_contents(self::STATE_DIR . "/{$key}", 'pending');
}
$domains = ['ui' => $ui, 'mail' => $mail, 'webmail' => $webmail];
$allOk = true;
// DNS prüfen
foreach ($domains as $key => $domain) {
if (!$domain) {
file_put_contents(self::STATE_DIR . "/{$key}", 'skip');
continue;
}
file_put_contents(self::STATE_DIR . "/{$key}", 'running');
$hasDns = checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
if (!$hasDns) {
file_put_contents(self::STATE_DIR . "/{$key}", 'nodns');
$allOk = false;
}
}
if (!$allOk) {
// Domains die noch auf "running" stehen wurden nie verarbeitet → error
foreach (['ui', 'mail', 'webmail'] as $key) {
$status = trim((string) @file_get_contents(self::STATE_DIR . "/{$key}"));
if ($status === 'running') {
file_put_contents(self::STATE_DIR . "/{$key}", 'error');
}
}
file_put_contents(self::STATE_DIR . '/done', '0');
Setting::set('ssl_configured', '0');
return self::SUCCESS;
}
// Nginx-Vhosts + optionales SSL via mailwolt-apply-domains
// Das Script erstellt erst die Vhosts (mit ACME-Location), dann certbot --webroot
$helper = '/usr/local/sbin/mailwolt-apply-domains';
$out = shell_exec(sprintf(
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d',
escapeshellarg($helper),
escapeshellarg($ui),
escapeshellarg($webmail),
escapeshellarg($mail),
$ssl ? 1 : 0,
));
// Shell-Script schreibt per-Domain-Status selbst in die State-Dateien.
// Fallback: Domains die noch auf running/pending stehen auf error setzen.
foreach (['ui', 'mail', 'webmail'] as $key) {
$status = trim((string) @file_get_contents(self::STATE_DIR . "/{$key}"));
if ($status === 'running' || $status === 'pending') {
file_put_contents(self::STATE_DIR . "/{$key}", 'error');
}
}
// done-Datei: Shell-Script schreibt "1"/"0"; Fallback wenn Script abstürzte.
$doneVal = trim((string) @file_get_contents(self::STATE_DIR . '/done'));
if ($doneVal === '') {
file_put_contents(self::STATE_DIR . '/done', '0');
$doneVal = '0';
}
// ssl_configured anhand tatsächlich ausgestellter LE-Zertifikate bestimmen
$hasAnyCert = false;
foreach ($domains as $domain) {
if ($domain && is_dir("/etc/letsencrypt/live/{$domain}")) {
$hasAnyCert = true;
break;
}
}
Setting::set('ssl_configured', $hasAnyCert ? '1' : '0');
// SESSION_SECURE_COOKIE wird nicht automatisch gesetzt —
// nginx leitet HTTP→HTTPS weiter, Secure-Flag wird im Admin gesetzt
return self::SUCCESS;
}
private function updateEnv(string $path, string $key, string $value): void
{
$content = @file_get_contents($path) ?: '';
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
$line = $key . '=' . $value;
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
file_put_contents($path, $content);
}
}

View File

@ -4,12 +4,35 @@ namespace App\Enums;
enum Role: string
{
case Member = 'member';
case Admin = 'admin';
case Admin = 'admin';
case Operator = 'operator';
case Viewer = 'viewer';
public function label(): string
{
return match($this) {
self::Admin => 'Admin',
self::Operator => 'Operator',
self::Viewer => 'Viewer',
};
}
public function badgeClass(): string
{
return match($this) {
self::Admin => 'role-badge-admin',
self::Operator => 'role-badge-op',
self::Viewer => 'mbx-badge-mute',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function options(): array
{
return array_map(fn($r) => ['value' => $r->value, 'label' => $r->label()], self::cases());
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Session\TokenMismatchException;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, \Throwable $e)
{
if ($e instanceof TokenMismatchException) {
if ($request->expectsJson()) {
return response()->json([
'message' => 'session_expired',
'redirect' => route('login'),
], 419);
}
return redirect()
->route('login')
->with('warning', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
return parent::render($request, $e);
}
}

View File

@ -31,7 +31,6 @@ if (!function_exists('webmail_host')) {
if (!function_exists('mta_host')) {
function mta_host(?int $domainId = null): string
{
// 1⃣ Vorrang: Datenbankwert (z. B. aus der domains-Tabelle)
if ($domainId) {
try {
$domain = \App\Models\Domain::find($domainId);
@ -39,17 +38,25 @@ if (!function_exists('mta_host')) {
return $domain->mta_host;
}
} catch (\Throwable $e) {
// DB evtl. noch nicht migriert — fallback auf env
// DB evtl. noch nicht migriert — fallback auf env
}
}
// 2⃣ ENV-Variante (z. B. MTA_SUB=mail01)
$sub = env('MTA_SUB');
if ($sub) {
return domain_host($sub);
}
// 3⃣ Notfall: statischer Fallback
return domain_host('mx');
}
}
if (! function_exists('countryFlag')) {
function countryFlag(string $code): string
{
$code = strtoupper($code);
return implode('', array_map(
fn($char) => mb_chr(ord($char) + 127397, 'UTF-8'),
str_split($code)
));
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailAlias;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AliasController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailAlias::with(['domain', 'recipients'])
->where('is_system', false);
if ($request->filled('domain')) {
$query->whereHas('domain', fn($q) => $q->where('domain', $request->domain));
}
$aliases = $query->orderBy('local')->paginate(100);
return response()->json([
'data' => $aliases->map(fn($a) => $this->format($a)),
'meta' => ['total' => $aliases->total(), 'per_page' => $aliases->perPage(), 'current_page' => $aliases->currentPage()],
]);
}
public function show(int $id): JsonResponse
{
$alias = MailAlias::with(['domain', 'recipients'])->where('is_system', false)->findOrFail($id);
return response()->json(['data' => $this->format($alias)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('local', 'domain')), 'sandbox' => true], 201);
}
$data = $request->validate([
'local' => 'required|string|max:64',
'domain' => 'required|string',
'recipients' => 'required|array|min:1',
'recipients.*'=> 'email',
'is_active' => 'nullable|boolean',
]);
$domain = Domain::where('domain', $data['domain'])->firstOrFail();
$alias = MailAlias::create([
'domain_id' => $domain->id,
'local' => $data['local'],
'type' => 'alias',
'is_active' => $data['is_active'] ?? true,
]);
foreach ($data['recipients'] as $i => $addr) {
$alias->recipients()->create(['address' => $addr, 'position' => $i]);
}
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('alias.created', $this->format($alias->load(['domain', 'recipients'])));
}
return response()->json(['data' => $this->format($alias->load(['domain', 'recipients']))], 201);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.');
$alias = MailAlias::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($alias->load(['domain', 'recipients']));
$alias->recipients()->delete();
$alias->delete();
app(WebhookService::class)->dispatch('alias.deleted', $formatted);
return response()->json(null, 204);
}
private function format(MailAlias $a): array
{
return [
'id' => $a->id,
'address' => $a->address,
'local' => $a->local,
'domain' => $a->domain?->domain,
'type' => $a->type,
'is_active' => $a->is_active,
'recipients' => $a->recipients->pluck('address'),
'created_at' => $a->created_at->toIso8601String(),
];
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DomainController extends Controller
{
public function index(): JsonResponse
{
$domains = Domain::where('is_system', false)
->where('is_server', false)
->orderBy('domain')
->get()
->map(fn($d) => $this->format($d));
return response()->json(['data' => $domains]);
}
public function show(int $id): JsonResponse
{
$domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id);
return response()->json(['data' => $this->format($domain)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('domain')), 'sandbox' => true], 201);
}
$data = $request->validate([
'domain' => 'required|string|max:253|unique:domains,domain',
'description' => 'nullable|string|max:255',
'max_aliases' => 'nullable|integer|min:0',
'max_mailboxes' => 'nullable|integer|min:0',
'default_quota_mb' => 'nullable|integer|min:0',
]);
$domain = Domain::create($data);
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('domain.created', $this->format($domain));
}
return response()->json(['data' => $this->format($domain)], 201);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.');
$domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($domain);
$domain->delete();
app(WebhookService::class)->dispatch('domain.deleted', $formatted);
return response()->json(null, 204);
}
private function format(Domain $d): array
{
return [
'id' => $d->id,
'domain' => $d->domain,
'description' => $d->description,
'is_active' => $d->is_active,
'max_aliases' => $d->max_aliases,
'max_mailboxes' => $d->max_mailboxes,
'default_quota_mb' => $d->default_quota_mb,
'created_at' => $d->created_at->toIso8601String(),
];
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailUser;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MailboxController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailUser::with('domain')
->where('is_system', false);
if ($request->filled('domain')) {
$query->whereHas('domain', fn($q) => $q->where('domain', $request->domain));
}
if ($request->filled('active')) {
$query->where('is_active', filter_var($request->active, FILTER_VALIDATE_BOOLEAN));
}
$mailboxes = $query->orderBy('email')->paginate(100);
return response()->json([
'data' => $mailboxes->map(fn($m) => $this->format($m)),
'meta' => ['total' => $mailboxes->total(), 'per_page' => $mailboxes->perPage(), 'current_page' => $mailboxes->currentPage()],
]);
}
public function show(int $id): JsonResponse
{
$mailbox = MailUser::with('domain')->where('is_system', false)->findOrFail($id);
return response()->json(['data' => $this->format($mailbox)]);
}
public function store(Request $request): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
if ($request->isSandbox ?? false) {
return response()->json(['data' => array_merge(['id' => 9999], $request->only('email')), 'sandbox' => true], 201);
}
$data = $request->validate([
'email' => 'required|email|unique:mail_users,email',
'password' => 'required|string|min:8',
'display_name'=> 'nullable|string|max:120',
'quota_mb' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
]);
[$local, $domainName] = explode('@', $data['email']);
$domain = Domain::where('domain', $domainName)->firstOrFail();
$mailbox = MailUser::create([
'domain_id' => $domain->id,
'localpart' => $local,
'email' => $data['email'],
'display_name' => $data['display_name'] ?? null,
'password_hash'=> '{ARGON2I}' . base64_encode(password_hash($data['password'], PASSWORD_ARGON2I)),
'quota_mb' => $data['quota_mb'] ?? $domain->default_quota_mb ?? 1024,
'is_active' => $data['is_active'] ?? true,
]);
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('mailbox.created', $this->format($mailbox->load('domain')));
}
return response()->json(['data' => $this->format($mailbox->load('domain'))], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
$mailbox = MailUser::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['data' => $this->format($mailbox), 'sandbox' => true]);
}
$data = $request->validate([
'display_name' => 'nullable|string|max:120',
'quota_mb' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'can_login' => 'nullable|boolean',
]);
$mailbox->update(array_filter($data, fn($v) => !is_null($v)));
if (!($request->isSandbox ?? false)) {
app(WebhookService::class)->dispatch('mailbox.updated', $this->format($mailbox->load('domain')));
}
return response()->json(['data' => $this->format($mailbox->load('domain'))]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.');
$mailbox = MailUser::where('is_system', false)->findOrFail($id);
if ($request->isSandbox ?? false) {
return response()->json(['sandbox' => true], 204);
}
$formatted = $this->format($mailbox->load('domain'));
$mailbox->delete();
app(WebhookService::class)->dispatch('mailbox.deleted', $formatted);
return response()->json(null, 204);
}
private function format(MailUser $m): array
{
return [
'id' => $m->id,
'email' => $m->email,
'display_name' => $m->display_name,
'domain' => $m->domain?->domain,
'quota_mb' => $m->quota_mb,
'is_active' => $m->is_active,
'can_login' => $m->can_login,
'last_login_at'=> $m->last_login_at?->toIso8601String(),
'created_at' => $m->created_at->toIso8601String(),
];
}
}

View File

@ -4,11 +4,20 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function show()
{
return view('auth.login'); // enthält @livewire('login-form')
return view('auth.login');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@ -3,14 +3,147 @@
namespace App\Http\Controllers\UI;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Domain;
use App\Models\MailUser;
class DashboardController extends Controller
{
public function index()
{
// ggf. Prefetch für Blade, sonst alles via Livewire
return view('ui.dashboard.index');
}
public function redesign()
{
$services = [
['name' => 'Postfix', 'type' => 'MTA', 'online' => $this->isRunning('postfix')],
['name' => 'Dovecot', 'type' => 'IMAP', 'online' => $this->isRunning('dovecot')],
['name' => 'Rspamd', 'type' => 'Spam', 'online' => $this->isRunning('rspamd')],
['name' => 'OpenDKIM', 'type' => 'DKIM', 'online' => $this->isRunning('opendkim')],
['name' => 'MariaDB', 'type' => 'DB', 'online' => $this->isRunning('mariadb')],
['name' => 'Redis', 'type' => 'Cache', 'online' => $this->isRunning('redis')],
['name' => 'Nginx', 'type' => 'Web', 'online' => $this->isRunning('nginx')],
['name' => 'ClamAV', 'type' => 'AV', 'online' => $this->isRunning('clamav')],
];
$servicesActive = count(array_filter($services, fn($s) => $s['online']));
$servicesTotal = count($services);
[$cpu, $cpuCores, $cpuMhz] = $this->getCpu();
[$ramPercent, $ramUsed, $ramTotal] = $this->getRam();
[$load1, $load5, $load15] = $this->getLoad();
[$uptimeDays, $uptimeHours] = $this->getUptime();
[$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->getDisk();
return view('ui.dashboard.redesign', [
'domainCount' => Domain::count(),
'mailboxCount' => MailUser::count(),
'servicesActive' => $servicesActive,
'servicesTotal' => $servicesTotal,
'alertCount' => 0,
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,
'cpu' => $cpu,
'cpuCores' => $cpuCores,
'cpuMhz' => $cpuMhz,
'ramPercent' => $ramPercent,
'ramUsed' => $ramUsed,
'ramTotal' => $ramTotal,
'load1' => $load1,
'load5' => $load5,
'load15' => $load15,
'uptimeDays' => $uptimeDays,
'uptimeHours' => $uptimeHours,
'diskUsedPercent' => $diskUsedPercent,
'diskUsedGb' => $diskUsedGb,
'diskFreeGb' => $diskFreeGb,
'diskTotalGb' => $diskTotalGb,
'bounceCount' => 0,
'spamCount' => 0,
'lastBackup' => '—',
'backupSize' => '—',
'backupDuration' => '—',
]);
}
private function isRunning(string $service): bool
{
exec("systemctl is-active --quiet " . escapeshellarg($service) . " 2>/dev/null", $out, $code);
return $code === 0;
}
private function getCpu(): array
{
$cores = (int) shell_exec("nproc 2>/dev/null") ?: 1;
$mhz = round((float) shell_exec("awk '/^cpu MHz/{sum+=$4; n++} END{if(n)print sum/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1);
$s1 = $this->readStat();
usleep(400000);
$s2 = $this->readStat();
// /proc/stat fields: user(0) nice(1) system(2) idle(3) iowait(4) irq(5) softirq(6) steal(7)
$idle1 = $s1[3] + ($s1[4] ?? 0);
$idle2 = $s2[3] + ($s2[4] ?? 0);
$total1 = array_sum($s1);
$total2 = array_sum($s2);
$dt = $total2 - $total1;
$di = $idle2 - $idle1;
$cpu = $dt > 0 ? max(0, min(100, round(($dt - $di) / $dt * 100))) : 0;
return [$cpu, $cores, $mhz ?: '—'];
}
private function readStat(): array
{
$raw = trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: '');
$parts = preg_split('/\s+/', $raw);
array_shift($parts); // remove 'cpu' label
return array_map('intval', $parts);
}
private function getRam(): array
{
$raw = shell_exec("cat /proc/meminfo 2>/dev/null") ?: '';
preg_match('/MemTotal:\s+(\d+)/', $raw, $mt);
preg_match('/MemAvailable:\s+(\d+)/', $raw, $ma);
$total = isset($mt[1]) ? (int)$mt[1] : 0;
$avail = isset($ma[1]) ? (int)$ma[1] : 0;
$used = $total - $avail;
$percent = $total > 0 ? round($used / $total * 100) : 0;
return [
$percent,
round($used / 1048576, 1),
round($total / 1048576, 1),
];
}
private function getLoad(): array
{
$raw = trim(shell_exec("cat /proc/loadavg 2>/dev/null") ?: '');
$p = explode(' ', $raw);
return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00'];
}
private function getUptime(): array
{
$secs = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime 2>/dev/null") ?: 0);
return [intdiv($secs, 86400), intdiv($secs % 86400, 3600)];
}
private function getDisk(): array
{
$raw = trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: '');
$p = preg_split('/\s+/', $raw);
$total = isset($p[1]) ? (int)$p[1] : 0;
$used = isset($p[2]) ? (int)$p[2] : 0;
$free = isset($p[3]) ? (int)$p[3] : 0;
$percent = $total > 0 ? round($used / $total * 100) : 0;
return [$percent, $used, $free, $total];
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\UI\V2\Mail;
use App\Http\Controllers\Controller;
class MailboxController extends Controller
{
public function index()
{
return view('ui.v2.mail.mailbox-index');
}
}

View File

@ -18,7 +18,7 @@ class GuestOnlyMiddleware
{
if (Auth::check()) {
// Eingeloggt → z. B. Dashboard weiterleiten
return redirect()->route('dashboard');
return redirect()->route('ui.dashboard');
}
return $next($request);

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InjectSandboxMode
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->user()?->currentAccessToken();
if ($token && $token->sandbox) {
$request->merge(['isSandbox' => true]);
}
return $next($request);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\TotpService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Require2FA
{
public function __construct(private TotpService $totp) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user) return $next($request);
if (!$this->totp->isEnabled($user)) return $next($request);
if ($request->session()->get('2fa_verified')) return $next($request);
if ($request->routeIs('auth.2fa*')) return $next($request);
return redirect()->route('auth.2fa');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user || !in_array($user->role?->value, $roles, true)) {
abort(403, 'Keine Berechtigung.');
}
return $next($request);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateHost
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
if ($this->isAllowed($host)) {
return $next($request);
}
abort(404);
}
private function isAllowed(string $host): bool
{
// Always allow localhost and loopback (health checks, artisan, etc.)
if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
return true;
}
$base = config('clubird.domain.base');
$uiSub = config('clubird.domain.ui');
$mtaSub = config('clubird.domain.mail');
$wmHost = config('clubird.domain.webmail_host');
$allowed = array_filter([
$wmHost,
$uiSub && $base ? "{$uiSub}.{$base}" : null,
$mtaSub && $base ? "{$mtaSub}.{$base}" : null,
// APP_HOST as fallback (e.g. during setup before domains are saved)
parse_url(config('app.url'), PHP_URL_HOST) ?: null,
]);
return in_array($host, $allowed, true);
}
}

25
app/Jobs/ClamavEnable.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ClamavEnable implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 120;
public function handle(): void
{
exec('sudo -n /usr/local/sbin/mailwolt-clamav enable 2>&1', $out, $rc);
\Log::info('ClamavEnable job', ['rc' => $rc, 'out' => $out]);
if ($rc !== 0) {
throw new \RuntimeException('mailwolt-clamav enable failed (rc=' . $rc . '): ' . implode(' ', $out));
}
}
}

View File

@ -21,7 +21,7 @@ class InstallDkimKey implements ShouldQueue
public int $dkimKeyId,
public string $privPath,
public string $dnsTxtContent,
public string $selector = 'mwl1',
public string $selector = 'clb1',
) {}
public function handle(): void

View File

@ -67,7 +67,7 @@ class ProvisionCertJob implements ShouldQueue
if ($this->useLetsEncrypt) {
$this->emit($task, 'running', 'Lets Encrypt wird ausgeführt…', $mode);
$exit = Artisan::call('mailwolt:provision-cert', [
$exit = Artisan::call('clubird:provision-cert', [
'domain' => $this->domain,
'--email' => $this->email ?? '',
]);
@ -83,14 +83,14 @@ class ProvisionCertJob implements ShouldQueue
// Fallback → self-signed
$mode = 'self-signed';
$exit = Artisan::call('mailwolt:provision-cert', [
$exit = Artisan::call('clubird:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
]);
}
} else {
$this->emit($task, 'running', 'Self-Signed Zertifikat wird erstellt…', $mode);
$exit = Artisan::call('mailwolt:provision-cert', [
$exit = Artisan::call('clubird:provision-cert', [
'domain' => $this->domain,
'--self-signed' => true,
]);
@ -120,7 +120,7 @@ class ProvisionCertJob implements ShouldQueue
// $task->update(['message' => 'Lets Encrypt wird ausgeführt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('mailwolt:provision-cert', [
// $exit = Artisan::call('clubird:provision-cert', [
// 'domain' => $this->domain,
// '--email' => $this->email ?? '',
// ]);
@ -131,7 +131,7 @@ class ProvisionCertJob implements ShouldQueue
// $this->syncCache($task);
//
// // Fallback: Self-Signed
// $exit = Artisan::call('mailwolt:provision-cert', [
// $exit = Artisan::call('clubird:provision-cert', [
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);
@ -140,7 +140,7 @@ class ProvisionCertJob implements ShouldQueue
// $task->update(['message' => 'Self-Signed wird erstellt…']);
// $this->syncCache($task);
//
// $exit = Artisan::call('mailwolt:provision-cert', [
// $exit = Artisan::call('clubird:provision-cert', [
// 'domain' => $this->domain,
// '--self-signed' => true,
// ]);

View File

@ -2,37 +2,35 @@
namespace App\Jobs;
use App\Models\Setting as SettingsModel;
use App\Support\CacheVer;
use App\Support\WoltGuard\Probes;
use App\Support\WoltGuard\MonitClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Finder\Finder;
class RunHealthChecks implements ShouldQueue
{
use Queueable, Probes;
use Queueable;
public int $timeout = 10; // safety
public int $timeout = 10;
public int $tries = 1;
public function handle(): void
{
$cards = config('woltguard.cards', []);
$svcRows = [];
foreach ($cards as $key => $card) {
$ok = false;
foreach ($card['sources'] as $src) {
if ($this->check($src)) { $ok = true; break; }
}
$svcRows[] = ['name' => $key, 'ok' => $ok]; // labels brauchst du im UI
$monit = new MonitClient();
$svcRows = $monit->services();
if (empty($svcRows)) {
Log::warning('WG: Monit nicht erreichbar kein Update');
return;
}
Cache::put(CacheVer::k('health:services'), $svcRows, 60);
Cache::forget('health:services');
$payload = ['ts' => time(), 'rows' => $svcRows];
Cache::put(CacheVer::k('health:services'), $payload, 300);
SettingsModel::set('woltguard.services', $payload);
Log::info('WG: services from Monit', ['count' => count($svcRows), 'key' => CacheVer::k('health:services'), 'names' => array_column($svcRows, 'name')]);
}
/** Wraps a probe; logs and returns fallback on error */
@ -68,9 +66,16 @@ class RunHealthChecks implements ShouldQueue
protected function queueWorkers(): array
{
$r = Process::run("systemctl is-active supervisor");
$raw = trim($r->output() ?: $r->errorOutput());
return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
$okQueue = $this->probeSystemd('mailwolt-queue.service');
$okSched = $this->probeSystemd('mailwolt-schedule.service');
$ok = $okQueue && $okSched;
$raw = sprintf('queue:%s sched:%s', $okQueue ? 'active' : 'down', $okSched ? 'active' : 'down');
return ['name' => 'queue', 'ok' => $ok, 'raw' => $raw];
// $r = Process::run("systemctl is-active supervisor");
// $raw = trim($r->output() ?: $r->errorOutput());
// return ['name'=>'queue', 'ok'=>$raw === 'active', 'raw'=>$raw ?: 'unknown'];
}
protected function diskUsage(): array

View File

@ -12,6 +12,7 @@ class LoginForm extends Component
public string $name = '';
public string $password = '';
public bool $remember = false;
public ?string $error = null;
public bool $show = false;
@ -45,6 +46,7 @@ class LoginForm extends Component
if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) {
request()->session()->regenerate();
Auth::user()->update(['last_login_at' => now()]);
return redirect()->intended(route('ui.dashboard'));
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Auth;
use App\Models\TwoFactorRecoveryCode;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class TwoFaChallenge extends Component
{
public string $code = '';
public bool $useRecovery = false;
public ?string $error = null;
public function verify(): mixed
{
$this->error = null;
$user = Auth::user();
if ($this->useRecovery) {
$this->validate(['code' => 'required|string']);
if (!TwoFactorRecoveryCode::verifyAndConsume($user->id, strtoupper(trim($this->code)))) {
$this->error = 'Ungültiger Recovery-Code.';
return null;
}
} else {
$this->validate(['code' => 'required|digits:6']);
$secret = app(TotpService::class)->getSecret($user);
if (!$secret || !app(TotpService::class)->verify($secret, $this->code)) {
$this->error = 'Ungültiger Code. Bitte erneut versuchen.';
return null;
}
}
session()->put('2fa_verified', true);
return redirect()->intended(route('ui.dashboard'));
}
public function render()
{
return view('livewire.auth.two-fa-challenge')
->layout('layouts.blank');
}
}

View File

@ -2,212 +2,243 @@
namespace App\Livewire\Setup;
use App\Jobs\ProvisionCertJob;
use App\Support\Setting;
use App\Models\SystemTask;
use App\Models\Setting;
use App\Models\User;
use App\Support\EnvWriter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
use Livewire\Attributes\Validate;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.setup')]
#[Title('Einrichtung · Mailwolt')]
class Wizard extends Component
{
public int $step = 1;
public int $step = 1;
public int $totalSteps = 5;
// Step 1
#[Validate('required|string|min:3')]
public string $form_domain = '';
// Schritt 1 — System
public string $instance_name = 'Mailwolt';
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
#[Validate('required|timezone')]
public string $form_timezone = 'UTC';
// Schritt 2 — Domains
public string $ui_domain = '';
public string $mail_domain = '';
public string $webmail_domain = '';
public bool $form_cert_force_https = true;
// Schritt 4 — Option
public bool $skipSsl = false;
// Step 2
#[Validate('required|string|min:3')]
public string $form_admin_name = '';
// Schritt 3 — Admin-Account
public string $admin_name = '';
public string $admin_email = '';
public string $admin_password = '';
public string $admin_password_confirmation = '';
#[Validate('required|email')]
public string $form_admin_email = '';
// Schritt 5 — Domain-Setup Status
public array $domainStatus = [
'ui' => 'pending',
'mail' => 'pending',
'webmail' => 'pending',
];
public bool $setupDone = false;
/** optional: wenn du Login auch über username erlauben willst */
public ?string $form_admin_username = null;
private const STATE_DIR = '/var/lib/mailwolt/wizard';
#[Validate('required|string|min:8|same:form_admin_password_confirmation')]
public string $form_admin_password = '';
public string $form_admin_password_confirmation = '';
// Step 3 (Zertifikat)
public bool $form_cert_create_now = false;
#[Validate('required_if:form_cert_create_now,true|email')]
public string $form_cert_email = '';
public function nextStep()
public function mount()
{
if ($this->step === 1) {
$this->validateOnly('form_domain');
$this->validateOnly('form_timezone');
} elseif ($this->step === 2) {
$this->validate([
'form_admin_name' => 'required|string|min:3',
'form_admin_email' => 'required|email',
'form_admin_password' => 'required|string|min:8|same:form_admin_password_confirmation',
]);
$this->instance_name = config('app.name', 'Mailwolt');
try {
$this->timezone = Setting::get('timezone', 'Europe/Berlin');
$this->locale = Setting::get('locale', 'de');
$this->ui_domain = Setting::get('ui_domain', '');
$this->mail_domain = Setting::get('mail_domain', '');
$this->webmail_domain = Setting::get('webmail_domain', '');
} catch (\Throwable) {
// DB noch nicht migriert — Standardwerte bleiben
}
$this->step = min($this->step + 1, 3);
}
public function prevStep()
public function updatedUiDomain(): void { $this->fillEmptyDomains($this->ui_domain); }
public function updatedMailDomain(): void { $this->fillEmptyDomains($this->mail_domain); }
public function updatedWebmailDomain(): void { $this->fillEmptyDomains($this->webmail_domain); }
private function fillEmptyDomains(string $value): void
{
if ($value === '') return;
if ($this->ui_domain === '') $this->ui_domain = $value;
if ($this->mail_domain === '') $this->mail_domain = $value;
if ($this->webmail_domain === '') $this->webmail_domain = $value;
}
public function next(): void
{
match ($this->step) {
1 => $this->validate([
'instance_name' => 'required|string|min:2|max:64',
'locale' => 'required|in:de,en,fr',
'timezone' => 'required|timezone',
]),
2 => $this->validate([
'ui_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
'mail_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
'webmail_domain' => ['required', 'regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
], [
'ui_domain.required' => 'Pflichtfeld.',
'mail_domain.required' => 'Pflichtfeld.',
'webmail_domain.required' => 'Pflichtfeld.',
'ui_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
'mail_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
'webmail_domain.regex' => 'Ungültige Domain — kein Schema (http://) erlaubt.',
]),
3 => $this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
'admin_password_confirmation' => 'required',
], [
'admin_password.min' => 'Mindestens 6 Zeichen.',
'admin_password.same' => 'Passwörter stimmen nicht überein.',
]),
default => null,
};
$this->step = min($this->step + 1, $this->totalSteps);
}
public function back(): void
{
$this->step = max($this->step - 1, 1);
}
public function finish()
public function finish(): void
{
// Step 3 Validierung (nur wenn sofort erstellen)
if ($this->form_cert_create_now) {
$this->validateOnly('form_cert_email');
}
// 1) Settings persistieren
Setting::set('app.domain', $this->form_domain);
Setting::set('app.timezone', $this->form_timezone);
Setting::set('app.force_https', (bool)$this->form_cert_force_https);
// Optional: .env spiegeln, damit URLs/HMR etc. sofort passen
$scheme = $this->form_cert_force_https ? 'https' : 'http';
EnvWriter::set([
'APP_HOST' => $this->form_domain,
'APP_URL' => "{$scheme}://{$this->form_domain}",
'APP_TIMEZONE' => $this->form_timezone,
$this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
]);
// 2) Admin anlegen/aktualisieren
$user = User::query()
->where('email', $this->form_admin_email)
->when($this->form_admin_username, fn($q) => $q->orWhere('username', $this->form_admin_username)
)
->first();
// Settings + .env speichern
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
'ui_domain' => $this->ui_domain,
'mail_domain' => $this->mail_domain,
'webmail_domain' => $this->webmail_domain,
'setup_completed' => '1',
]);
if (!$user) {
$user = new User();
$user->email = $this->form_admin_email;
if ($this->form_admin_username) {
$user->username = $this->form_admin_username;
}
} else {
// vorhandene Email/Username harmonisieren
$user->email = $this->form_admin_email;
if ($this->form_admin_username) {
$user->username = $this->form_admin_username;
}
}
$this->writeEnv([
'APP_NAME' => $this->instance_name,
'APP_HOST' => $this->ui_domain,
'APP_URL' => 'https://' . $this->ui_domain,
'MTA_SUB' => explode('.', $this->mail_domain)[0] ?? '',
'WEBMAIL_DOMAIN' => $this->webmail_domain,
]);
$user->name = $this->form_admin_name;
$user->is_admin = true;
$user->password = Hash::make($this->form_admin_password);
$user->required_change_password = true;
// Admin anlegen
$user = User::where('email', $this->admin_email)->first() ?? new User();
$user->name = $this->admin_name;
$user->email = $this->admin_email;
$user->password = Hash::make($this->admin_password);
$user->role = 'admin';
$user->save();
// 3) Zertifikat jetzt ausstellen (optional)
$taskKey = 'issue-cert:' . $this->form_domain;
if ($this->form_cert_create_now) {
SystemTask::updateOrCreate(
['key' => $taskKey],
[
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_email,
'mode' => 'letsencrypt'
],
]
);
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Redis::sadd('ui:toasts', $taskKey);
ProvisionCertJob::dispatch(
domain: $this->form_domain,
email: $this->form_cert_email,
taskKey: $taskKey,
useLetsEncrypt: true
);
session()->flash('task_key', $taskKey);
session()->flash('banner_ok', 'Lets Encrypt wird gestartet…');
} else {
// automatisch self-signed
SystemTask::updateOrCreate(
['key' => $taskKey],
[
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'mode' => 'self-signed'
],
]
);
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Cache::store('redis')->put($taskKey, [
'type' => 'issue-cert',
'status' => 'queued',
'message' => 'Warte auf Ausführung…',
'payload' => [
'domain' => $this->form_domain,
'email' => $this->form_cert_create_now ? $this->form_cert_email : null,
'mode' => $this->form_cert_create_now ? 'letsencrypt' : 'self-signed',
],
], now()->addMinutes(30));
Redis::sadd('ui:toasts', $taskKey);
ProvisionCertJob::dispatch(
domain: $this->form_domain,
email: null,
taskKey: $taskKey,
useLetsEncrypt: false
);
session()->flash('task_key', $taskKey);
session()->flash('banner_ok', 'Self-Signed Zertifikat wird erstellt…');
// Status-Verzeichnis leeren und Domain-Setup im Hintergrund starten
@mkdir(self::STATE_DIR, 0755, true);
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
return redirect()->route('dashboard');
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->ui_domain),
escapeshellarg($this->mail_domain),
escapeshellarg($this->webmail_domain),
$ssl,
);
@shell_exec($cmd);
$this->step = 5;
}
public function pollSetup(): void
{
if ($this->setupDone) return;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::STATE_DIR . "/{$key}";
$this->domainStatus[$key] = is_readable($file)
? trim(@file_get_contents($file))
: 'pending';
}
$done = @file_get_contents(self::STATE_DIR . '/done');
if ($done !== false) {
$this->setupDone = true;
}
}
public function retryDomains(): void
{
@unlink(self::STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$this->domainStatus = ['ui' => 'pending', 'mail' => 'pending', 'webmail' => 'pending'];
$this->setupDone = false;
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->ui_domain),
escapeshellarg($this->mail_domain),
escapeshellarg($this->webmail_domain),
$ssl,
);
@shell_exec($cmd);
}
public function goToLogin(): mixed
{
$sslOk = Setting::get('ssl_configured', '0') === '1' && $this->ui_domain;
$url = $sslOk
? 'https://' . $this->ui_domain . '/login'
: '/login';
return redirect()->to($url)->with('setup_done', true);
}
private function writeEnv(array $values): void
{
$path = base_path('.env');
$content = @file_get_contents($path) ?: '';
foreach ($values as $key => $value) {
$escaped = str_contains($value, ' ') ? '"' . $value . '"' : $value;
$line = $key . '=' . $escaped;
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
}
file_put_contents($path, $content);
}
public function render()
{
return view('livewire.setup.wizard');
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
return view('livewire.setup.wizard', compact('timezones'));
}
}

View File

@ -24,7 +24,7 @@ class DkimStatus extends Component
?: optional(
$domain->dkimKeys()->where('is_active', true)->latest()->first()
)->selector
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
}
/**
@ -46,7 +46,7 @@ class DkimStatus extends Component
// public function regenerate(?string $selector = null): void
// {
// $selector = $selector
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
// ?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
//
// Log::info('DKIM regenerate() CLICKED', [
// 'domain' => $this->domain->domain,
@ -79,7 +79,7 @@ class DkimStatus extends Component
public function regenerate(?string $selector = null): void
{
$selector = $selector
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'));
?: ($this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1'));
Log::info('DKIM regenerate() CLICKED', [
'domain' => $this->domain->domain,
@ -126,7 +126,7 @@ class DkimStatus extends Component
public function render(): View
{
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$sel = $this->selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
$dkimOk = $this->isDkimReady($this->domain->domain, $sel);
return view('livewire.ui.domain.dkim-status', compact('dkimOk'));

View File

@ -52,7 +52,7 @@ class DomainCreateModal extends ModalComponent
$this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb', 3072);
$this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240);
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$this->dkim_selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$this->dkim_bits = (int) config('mailpool.defaults.dkim_bits', 2048);
// Speicherpool-Grenze

File diff suppressed because it is too large Load Diff

View File

@ -30,14 +30,14 @@ class AliasList extends Component
{
// nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente)
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
$aliasId,
'aliasId' => $aliasId,
]);
}
public function openAliasDelete(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
$aliasId,
'aliasId' => $aliasId,
]);
}

View File

@ -5,39 +5,540 @@ namespace App\Livewire\Ui\Mail;
use Livewire\Component;
use App\Models\Domain;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class DnsHealthCard extends Component
{
public array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
public array $rows = []; // [{id,name,ok,missing:[...]}]
public string $mtaHost = '';
public bool $tlsa = false;
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.mail.dns-health-card'); }
public function refresh(): void { $this->load(true); }
protected function load(bool $force=false): void
public function mount(): void
{
$this->rows = Cache::remember('dash.dnshealth', $force ? 1 : 600, function () {
$rows = [];
$domains = Domain::query()->where('is_system', false)->where('is_active', true)->get(['domain']);
foreach ($domains as $d) {
$dom = $d->domain;
$dkim = $this->hasTxt("_domainkey.$dom"); // rough: just any dkim TXT exists
$dmarc = $this->hasTxt("_dmarc.$dom");
$tlsa = $this->hasTlsa("_25._tcp.$dom") || $this->hasTlsa("_465._tcp.$dom") || $this->hasTlsa("_587._tcp.$dom");
$rows[] = compact('dom','dkim','dmarc','tlsa');
$this->load();
}
public function render()
{
return view('livewire.ui.mail.dns-health-card');
}
public function refresh(): void
{
$this->load();
}
public function openDnsModal(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
}
// protected function load(bool $force = false): void
// {
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v2', $force ? 1 : 600, function () {
//
// $base = trim((string) env('BASE_DOMAIN', ''));
// $mtaSub = trim((string) env('MTA_SUB', 'mx'));
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
//
// // ▼ gewünschter Filter:
// $domains = Domain::query()
// ->where('is_active', true)
// ->where('is_server', false) // <<< Server-Domain sauber ausschließen
// ->orderBy('domain')
// ->get(['id', 'domain']);
//
// $rows = [];
// foreach ($domains as $d) {
// $dom = $d->domain;
//
// // DKIM-Selector ermitteln: .env > DB > Fallback null
// $selector = trim((string) env('DKIM_SELECTOR', ''));
// if ($selector === '') {
// $selector = (string) DB::table('dkim_keys')
// ->where('domain_id', $d->id)
// ->where('is_active', 1)
// ->orderByDesc('id')
// ->value('selector') ?? '';
// }
//
// $missing = [];
//
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
// if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
//
// $rows[] = [
// 'id' => (int) $d->id,
// 'name' => $dom,
// 'ok' => empty($missing),
// 'missing' => $missing,
// ];
// }
//
// // Hostweites TLSA (nur Hinweis)
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
//
// return [$mtaHost, $tlsa, $rows];
// });
// }
protected function load(): void
{
$base = trim((string) env('BASE_DOMAIN', ''));
$mtaSub = trim((string) env('MTA_SUB', 'mx'));
$mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub; // z.B. mx.nexlab.at
// nur aktive, NICHT-Server-Domains (System + Custom, solange is_server = false)
$domains = Domain::query()
->where('is_active', true)
->where('is_server', false)
->orderBy('domain')
->get(['id','domain']);
$rows = [];
foreach ($domains as $d) {
$dom = $d->domain;
// DKIM-Selector: .env > DB > leer
$selector = trim((string) env('DKIM_SELECTOR', ''));
if ($selector === '') {
$selector = (string) DB::table('dkim_keys')
->where('domain_id', $d->id)
->where('is_active', 1)
->orderByDesc('id')
->value('selector') ?? '';
}
return $rows;
});
$missing = [];
if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
if (!$this->hasSpf($dom)) $missing[] = 'SPF';
if (!$this->hasDkim($dom, $selector)) $missing[] = 'DKIM';
if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
$rows[] = [
'id' => (int) $d->id,
'name' => $dom,
'ok' => empty($missing),
'missing' => $missing,
];
}
// Hostweites TLSA (nur Info)
$tlsa = $this->hasTlsa("_25._tcp.$mtaHost")
|| $this->hasTlsa("_465._tcp.$mtaHost")
|| $this->hasTlsa("_587._tcp.$mtaHost");
$this->mtaHost = $mtaHost;
$this->tlsa = $tlsa;
$this->rows = $rows;
}
/* ── DNS Helpers ───────────────────────────────────────────────────── */
protected function digShort(string $type, string $name): string
{
$cmd = "timeout 2 dig +short " . escapeshellarg($name) . ' ' . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
return (string) @shell_exec($cmd) ?: '';
}
protected function hasTxt(string $name): bool
{
$out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
return is_string($out) && trim($out) !== '';
return trim($this->digShort('TXT', $name)) !== '';
}
protected function hasTlsa(string $name): bool
{
$out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
return is_string($out) && trim($out) !== '';
return trim($this->digShort('TLSA', $name)) !== '';
}
protected function hasSpf(string $domain): bool
{
$out = $this->digShort('TXT', $domain);
foreach (preg_split('/\R+/', trim($out)) as $line) {
if (stripos($line, 'v=spf1') !== false) return true;
}
return false;
}
// ▼ DKIM: bevorzugt konkreten Selector prüfen; wenn leer, versuche Policy (_domainkey)
protected function hasDkim(string $domain, string $selector = ''): bool
{
if ($selector !== '' && $this->hasTxt("{$selector}._domainkey.$domain")) {
return true;
}
// Fallback: irgendein _domainkey-TXT vorhanden
return $this->hasTxt("_domainkey.$domain");
}
// protected function hasDkim(string $domain, string $selector = ''): bool
// {
// if ($selector !== '') {
// return $this->hasTxt("{$selector}._domainkey.$domain");
// }
// // Manche Betreiber veröffentlichen eine Policy auf _domainkey.<dom>
// return $this->hasTxt("_domainkey.$domain");
// }
protected function mxPointsTo(string $domain, array $allowedHosts): bool
{
$out = $this->digShort('MX', $domain);
if ($out === '') return false;
$targets = [];
foreach (preg_split('/\R+/', trim($out)) as $line) {
if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
$targets[] = strtolower($m[1]);
}
}
if (!$targets) return false;
$allowed = array_map(fn ($h) => strtolower(rtrim($h, '.')), $allowedHosts);
foreach ($targets as $t) {
$t = rtrim($t, '.');
if (in_array($t, $allowed, true)) return true;
}
return false;
}
}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // [{id,name,ok,missing:[...]}]
// public string $mtaHost = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // hostweit
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.mail.dns-health-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDnsModal(int $domainId): void
// {
// $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $domainId]);
// }
//
// protected function load(bool $force = false): void
// {
// [$this->mtaHost, $this->tlsa, $this->rows] = Cache::remember('dash.dnshealth.v1', $force ? 1 : 600, function () {
//
// $base = trim((string)env('BASE_DOMAIN', ''));
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
// $mtaHost = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
//
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->orderBy('domain')
// ->get(['id', 'domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
//
// $missing = [];
//
// // Pflicht-Checks
// if (!$this->mxPointsTo($dom, [$mtaHost])) $missing[] = 'MX';
// if (!$this->hasSpf($dom)) $missing[] = 'SPF';
// if (!$this->hasDkim($dom)) $missing[] = 'DKIM';
// if (!$this->hasTxt("_dmarc.$dom")) $missing[] = 'DMARC';
//
// $rows[] = [
// 'id' => (int)$d->id,
// 'name' => $dom,
// 'ok' => empty($missing),
// 'missing' => $missing,
// ];
// }
//
// // TLSA (hostweit, nur Info)
// $tlsa = $this->hasTlsa("_25._tcp.$mtaHost") || $this->hasTlsa("_465._tcp.$mtaHost") || $this->hasTlsa("_587._tcp.$mtaHost");
//
// return [$mtaHost, $tlsa, $rows];
// });
// }
//
// /* ── DNS Helpers (mit Timeout, damit UI nicht hängt) ───────────────── */
//
// protected function digShort(string $type, string $name): string
// {
// $cmd = "timeout 2 dig +short " . escapeshellarg($name) . " " . escapeshellarg(strtoupper($type)) . " 2>/dev/null";
// return (string)@shell_exec($cmd) ?: '';
// }
//
// protected function hasTxt(string $name): bool
// {
// return trim($this->digShort('TXT', $name)) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// return trim($this->digShort('TLSA', $name)) !== '';
// }
//
// protected function hasSpf(string $domain): bool
// {
// $out = $this->digShort('TXT', $domain);
// foreach (preg_split('/\R+/', trim($out)) as $line) {
// if (stripos($line, 'v=spf1') !== false) return true;
// }
// return false;
// }
//
// // DKIM: wenn spezifischer Selector vorhanden → prüfe den, sonst akzeptiere _domainkey-Policy als “vorhanden”
// protected function hasDkim(string $domain): bool
// {
// $sel = trim((string)env('DKIM_SELECTOR', ''));
// if ($sel !== '' && $this->hasTxt("{$sel}._domainkey.$domain")) return true;
// return $this->hasTxt("_domainkey.$domain");
// }
//
// protected function mxPointsTo(string $domain, array $allowedHosts): bool
// {
// $out = $this->digShort('MX', $domain);
// if ($out === '') return false;
//
// $targets = [];
// foreach (preg_split('/\R+/', trim($out)) as $line) {
// // Format: "10 mx.example.com."
// if (preg_match('~\s+([A-Za-z0-9\.\-]+)\.?$~', trim($line), $m)) {
// $targets[] = strtolower($m[1]);
// }
// }
// if (!$targets) return false;
//
// $allowed = array_map('strtolower', $allowedHosts);
// foreach ($targets as $t) {
// if (in_array($t, $allowed, true)) return true;
// }
// return false;
// }
//}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $domains = []; // [['name'=>..., 'dkim'=>bool, 'dmarc'=>bool], ...]
// public string $host = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // hostbasiert (einmalig)
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.mail.dns-health-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// [$this->host, $this->tlsa, $this->domains, $this->ipv4, $this->ipv6] =
// Cache::remember('dash.dnshealth', $force ? 1 : 900, function () {
//
// // ── ENV lesen ────────────────────────────────────────────────
// $base = trim((string)env('BASE_DOMAIN', ''));
// $mtaSub = trim((string)env('MTA_SUB', 'mx'));
// $host = $base !== '' ? "{$mtaSub}.{$base}" : $mtaSub;
//
// $ipv4 = trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: null;
// $ipv6 = trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: null;
//
// // ── Domains laden (nur aktive, nicht-system) ────────────────
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->orderBy('domain')
// ->get(['domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
// $rows[] = [
// 'name' => $dom,
// 'dkim' => $this->hasTxt("_domainkey.$dom"),
// 'dmarc' => $this->hasTxt("_dmarc.$dom"),
// ];
// }
//
// // ── TLSA nur hostbasiert prüfen (25/465/587) ────────────────
// $tlsa = $this->hasTlsa("_25._tcp.$host")
// || $this->hasTlsa("_465._tcp.$host")
// || $this->hasTlsa("_587._tcp.$host");
//
// return [$host, $tlsa, $rows, $ipv4, $ipv6];
// });
// }
//
// /* ───────────────────────── DNS Helpers ───────────────────────── */
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("timeout 2 dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("timeout 2 dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // pro Domain: ['dom','dkim','dmarc']
// public string $host = ''; // z.B. mx.nexlab.at
// public bool $tlsa = false; // TLSA-Status für den Host
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force = false): void
// {
// // Werte aus .env
// $this->ipv4 = trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: null;
// $this->ipv6 = trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: null;
//
// $base = trim((string) env('BASE_DOMAIN', ''));
// $mta = trim((string) env('MTA_SUB', 'mx'));
// $host = $base ? "{$mta}.{$base}" : $mta; // z.B. mx.nexlab.at
// $this->host = $host;
//
// // Neuer Cache-Key, damit altes Format keinen Crash verursacht
// [$calcHost, $calcTlsa, $calcRows] = Cache::remember(
// 'dash.dnshealth.v2',
// $force ? 1 : 900,
// function () use ($host) {
// // TLSA: nur 1× pro Host prüfen
// $tlsa = $this->hasTlsa("_25._tcp.{$host}")
// || $this->hasTlsa("_465._tcp.{$host}")
// || $this->hasTlsa("_587._tcp.{$host}");
//
// // Domains: nur DKIM/DMARC
// $rows = [];
// $domains = Domain::query()
// ->where('is_system', false)
// ->where('is_active', true)
// ->get(['domain']);
//
// foreach ($domains as $d) {
// $dom = $d->domain;
// $dkim = $this->hasTxt("_domainkey.{$dom}");
// $dmarc = $this->hasTxt("_dmarc.{$dom}");
// $rows[] = compact('dom','dkim','dmarc');
// }
//
// return [$host, $tlsa, $rows];
// }
// );
//
// // Defensive: falls mal falsches Datenformat im Cache war
// if (!is_string($calcHost) || !is_bool($calcTlsa) || !is_array($calcRows)) {
// Cache::forget('dash.dnshealth.v2');
// $this->load(true);
// return;
// }
//
// $this->host = $calcHost;
// $this->tlsa = $calcTlsa;
// $this->rows = $calcRows;
// }
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("dig +short TXT " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("dig +short TLSA " . escapeshellarg($name) . " 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}
//
//namespace App\Livewire\Ui\Mail;
//
//use Livewire\Component;
//use App\Models\Domain;
//use Illuminate\Support\Facades\Cache;
//
//class DnsHealthCard extends Component
//{
// public array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.mail.dns-health-card'); }
// public function refresh(): void { $this->load(true); }
//
// protected function load(bool $force=false): void
// {
// $this->rows = Cache::remember('dash.dnshealth', $force ? 1 : 600, function () {
// $rows = [];
// $domains = Domain::query()->where('is_system', false)->where('is_active', true)->get(['domain']);
// foreach ($domains as $d) {
// $dom = $d->domain;
// $dkim = $this->hasTxt("_domainkey.$dom"); // rough: just any dkim TXT exists
// $dmarc = $this->hasTxt("_dmarc.$dom");
// $tlsa = $this->hasTlsa("_25._tcp.$dom") || $this->hasTlsa("_465._tcp.$dom") || $this->hasTlsa("_587._tcp.$dom");
// $rows[] = compact('dom','dkim','dmarc','tlsa');
// }
// return $rows;
// });
// }
//
// protected function hasTxt(string $name): bool
// {
// $out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
// protected function hasTlsa(string $name): bool
// {
// $out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
// return is_string($out) && trim($out) !== '';
// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@ class MailboxCreateModal extends ModalComponent
{
// alle Nicht-System-Domains in Select
$this->domains = Domain::query()
->where('is_system', false)
->where('is_system', false)->where('is_server', false)
->orderBy('domain')->get(['id', 'domain'])->toArray();
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
@ -291,251 +291,3 @@ class MailboxCreateModal extends ModalComponent
return view('livewire.ui.mail.modal.mailbox-create-modal');
}
}
//namespace App\Livewire\Ui\Mail\Modal;
//
//use App\Models\Domain;
//use App\Models\MailUser;
//use Illuminate\Database\QueryException;
//use Illuminate\Support\Facades\Hash;
//use Illuminate\Validation\Rule;
//use Livewire\Attributes\On;
//use LivewireUI\Modal\ModalComponent;
//
//class MailboxCreateModal extends ModalComponent
//{
// // optional vorselektierte Domain
// public ?int $domain_id = null;
//
// // Anzeige
// public string $domain_name = '';
// /** @var array<int,array{id:int,domain:string}> */
// public array $domains = [];
// public string $email_preview = '';
//
// public string $localpart = '';
// public ?string $display_name = null;
// public ?string $password = null;
// public int $quota_mb = 0;
// public ?int $rate_limit_per_hour = null;
// public bool $is_active = true;
// public bool $must_change_pw = true;
//
// // Limits / Status
// public ?int $limit_max_mailboxes = null;
// public ?int $limit_default_quota_mb = null;
// public ?int $limit_max_quota_per_mb = null;
// public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
// public ?int $limit_domain_rate_per_hour = null;
// public bool $allow_rate_limit_override = false;
//
// public int $mailbox_count_used = 0;
// public int $domain_storage_used_mb = 0;
//
// // Hints/Flags
// public string $quota_hint = '';
// public bool $rate_limit_readonly = false;
// public bool $no_mailbox_slots = false;
// public bool $no_storage_left = false;
// public bool $can_create = true;
// public string $block_reason = '';
//
// /* ---------- Validation ---------- */
// protected function rules(): array
// {
// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
// $remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
// ? PHP_INT_MAX
// : max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $cap = min($maxPerMailbox, $remainingByTotal);
//
// return [
// 'domain_id' => ['required', Rule::exists('domains', 'id')],
// 'localpart' => [
// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
// Rule::unique('mail_users', 'localpart')->where(fn($q) => $q->where('domain_id', $this->domain_id)),
// ],
// 'display_name' => ['nullable', 'max:191'],
// 'password' => ['nullable', 'min:8'],
// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
// 'is_active' => ['boolean'],
// 'must_change_pw' => ['boolean'],
// ];
// }
//
// /* ---------- Lifecycle ---------- */
// public function mount(?int $domainId = null): void
// {
// // alle Nicht-System-Domains in Select
// $this->domains = Domain::query()
// ->where('is_system', false)
// ->orderBy('domain')->get(['id', 'domain'])->toArray();
//
// // vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
// $this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
//
// // Limits + Anzeige laden
// $this->syncDomainContext();
// }
//
// public function updatedDomainId(): void
// {
// $this->resetErrorBag(); // scoped unique etc.
// $this->syncDomainContext();
// }
//
// public function updatedLocalpart(): void
// {
// $this->localpart = strtolower(trim($this->localpart));
// $this->rebuildEmailPreview();
// }
//
// public function updatedQuotaMb(): void
// {
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// /* ---------- Helpers ---------- */
// private function syncDomainContext(): void
// {
// if (!$this->domain_id) return;
//
// $d = Domain::query()
// ->withCount('mailUsers')
// ->withSum('mailUsers as used_storage_mb', 'quota_mb')
// ->findOrFail($this->domain_id);
//
// $this->domain_name = $d->domain;
// $this->limit_max_mailboxes = (int)$d->max_mailboxes;
// $this->limit_default_quota_mb = (int)$d->default_quota_mb;
// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
// $this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
// $this->allow_rate_limit_override = (bool)$d->rate_limit_override;
//
// $this->mailbox_count_used = (int)$d->mail_users_count;
// $this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
//
// // Defaults
// $this->quota_mb = $this->limit_default_quota_mb ?? 0;
// if (!$this->allow_rate_limit_override) {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = true;
// } else {
// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
// $this->rate_limit_readonly = false;
// }
//
// $this->rebuildEmailPreview();
// $this->recomputeQuotaHints();
// $this->recomputeBlockers();
// }
//
// private function rebuildEmailPreview(): void
// {
// $this->email_preview = $this->localpart && $this->domain_name
// ? ($this->localpart . '@' . $this->domain_name) : '';
// }
//
// private function recomputeQuotaHints(): void
// {
// $parts = [];
//
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
// $remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
// $parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
// $parts[] = "nach Speichern: {$remainingAfter} MiB";
// }
// if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
// if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
//
// $this->quota_hint = implode(' · ', $parts);
// }
//
// private function recomputeBlockers(): void
// {
// // Slots
// $this->no_mailbox_slots = false;
// if (!is_null($this->limit_max_mailboxes)) {
// $free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
// if ($free <= 0) $this->no_mailbox_slots = true;
// }
//
// // Speicher
// $this->no_storage_left = false;
// if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
// $remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
// if ($remaining <= 0) $this->no_storage_left = true;
// }
//
// $reasons = [];
// if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
// if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
// $this->block_reason = implode(' ', $reasons);
// $this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
// }
//
// /* ---------- Save ---------- */
// #[On('mailbox:create')]
// public function save(): void
// {
// $this->recomputeBlockers();
// if (!$this->can_create) {
// $this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
// return;
// }
//
// $data = $this->validate();
// $email = $data['localpart'] . '@' . $this->domain_name;
//
// try {
// $u = new MailUser();
// $u->domain_id = $data['domain_id'];
// $u->localpart = $data['localpart'];
// $u->email = $email;
// $u->display_name = $this->display_name ?: null;
// $u->password_hash = $this->password ? Hash::make($this->password) : null;
// $u->is_system = false;
// $u->is_active = (bool)$data['is_active'];
// $u->must_change_pw = (bool)$data['must_change_pw'];
// $u->quota_mb = (int)$data['quota_mb'];
// $u->rate_limit_per_hour = $data['rate_limit_per_hour'];
// $u->save();
// } catch (QueryException $e) {
// $msg = strtolower($e->getMessage());
// if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
// $this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
// return;
// }
// if (str_contains($msg, 'mail_users_email_unique')) {
// $this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
// return;
// }
// throw $e;
// }
//
// $this->dispatch('mailbox:created');
// $this->dispatch('closeModal');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Postfach',
// title: 'Postfach angelegt',
// text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
// duration: 6000
// );
//
// }
//
// public static function modalMaxWidth(): string
// {
// return '3xl';
// }
//
// public function render()
// {
// return view('livewire.ui.mail.modal.mailbox-create-modal');
// }
//}

View File

@ -0,0 +1,289 @@
<?php
namespace App\Livewire\Ui\Nx;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use App\Models\Domain;
use App\Models\MailUser;
use App\Models\SandboxRoute;
use App\Models\Setting as SettingModel;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Dashboard · Mailwolt')]
class Dashboard extends Component
{
public function render()
{
$services = $this->loadServices();
[$cpu, $cpuCores, $cpuMhz] = $this->cpu();
[$ramPercent, $ramUsed, $ramTotal] = $this->ram();
[$load1, $load5, $load15] = $this->load();
[$uptimeDays, $uptimeHours] = $this->uptime();
[$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->disk();
$servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online'));
// Echte Zertifikatsdateien prüfen — nicht nur DB-Wert (kann veraltet sein)
$uiDomain = (string) SettingModel::get('ui_domain', '');
$sslConfigured = !$uiDomain
|| file_exists("/etc/letsencrypt/renewal/{$uiDomain}.conf")
|| is_dir("/etc/letsencrypt/live/{$uiDomain}");
// DB-Wert nachziehen damit Banner nach einmaligem Check dauerhaft weg ist
if ($sslConfigured && SettingModel::get('ssl_configured', '0') !== '1') {
SettingModel::set('ssl_configured', '1');
}
return view('livewire.ui.nx.dashboard', [
'sslConfigured' => $sslConfigured,
'domainCount' => Domain::where('is_system', false)->where('is_server', false)->count(),
'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(),
'servicesActive' => $servicesActive,
'servicesTotal' => count($services),
'alertCount' => SandboxRoute::where('is_active', true)->count(),
'sandboxAlerts' => SandboxRoute::activeRoutes(),
'backup' => $this->backupData(),
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,
'cpu' => $cpu,
'cpuCores' => $cpuCores,
'cpuMhz' => $cpuMhz,
'ramPercent' => $ramPercent,
'ramUsed' => $ramUsed,
'ramTotal' => $ramTotal,
'load1' => $load1,
'load5' => $load5,
'load15' => $load15,
'uptimeDays' => $uptimeDays,
'uptimeHours' => $uptimeHours,
'diskUsedPercent' => $diskUsedPercent,
'diskUsedGb' => $diskUsedGb,
'diskFreeGb' => $diskFreeGb,
'diskTotalGb' => $diskTotalGb,
'updateLatest' => Cache::get('updates:latest_raw') ?: (Cache::get('updates:latest') ? 'v' . Cache::get('updates:latest') : null),
...$this->mailSecurity(),
'ports' => $this->ports(),
]);
}
private function loadServices(): array
{
$allCards = config('woltguard.cards', []);
$dashKeys = config('woltguard.dashboard', array_keys($allCards));
// 1) Monit-Cache für nicht-optionale Dienste
$cached = Cache::get(CacheVer::k('health:services'), []);
$monitRows = $cached['rows'] ?? [];
$monitIndex = [];
foreach ($monitRows as $r) {
$monitIndex[strtolower($r['name'] ?? '')] = $r;
}
$rows = [];
foreach ($dashKeys as $key) {
$card = $allCards[$key] ?? null;
if (!$card) continue;
$optional = $card['optional'] ?? false;
// Optionale Dienste (z.B. ClamAV) immer live prüfen Monit-Cache kann veraltet sein
if (!$optional && isset($monitIndex[strtolower($card['label'] ?? '')])) {
$rows[] = $monitIndex[strtolower($card['label'])];
continue;
}
$isOk = false;
foreach ($card['sources'] as $src) {
if ($this->probeSource($src)) { $isOk = true; break; }
}
if (!$isOk && $optional) continue;
$rows[] = ['label' => $card['label'], 'hint' => $card['hint'], 'ok' => $isOk];
}
// Fallback: wenn gar keine Daten, alle live proben
if (empty($rows) && !empty($monitRows)) {
$rows = $monitRows;
}
return array_map(fn($r) => [
'name' => $r['label'] ?? ucfirst($r['name'] ?? ''),
'type' => $r['hint'] ?? '',
'status' => ($r['ok'] ?? false) ? 'online' : 'offline',
], $rows);
}
private function probeSource(string $src): bool
{
if (str_starts_with($src, 'systemd:')) {
$unit = substr($src, 8);
$exit = null;
@exec("systemctl is-active --quiet " . escapeshellarg($unit) . " 2>/dev/null", $_, $exit);
return $exit === 0;
}
if (str_starts_with($src, 'tcp:')) {
[, $host, $port] = explode(':', $src, 3);
$fp = @fsockopen($host, (int)$port, $e1, $e2, 1);
if (is_resource($fp)) { fclose($fp); return true; }
return false;
}
if (str_starts_with($src, 'socket:')) {
return @file_exists(substr($src, 7));
}
if ($src === 'db') {
try { \Illuminate\Support\Facades\DB::connection()->getPdo(); return true; }
catch (\Throwable) { return false; }
}
return false;
}
private function ports(): array
{
$check = [25, 465, 587, 110, 143, 993, 995, 80, 443];
$out = trim(@shell_exec('ss -tlnH 2>/dev/null') ?? '');
$listening = [];
foreach (explode("\n", $out) as $line) {
if (preg_match('/:(\d+)\s/', $line, $m)) {
$listening[(int)$m[1]] = true;
}
}
$result = [];
foreach ($check as $port) {
$result[$port] = isset($listening[$port]);
}
return $result;
}
private function mailSecurity(): array
{
// rspamd metrics from cache (populated by spamav:collect every 5 min)
$av = Cache::get('dash.spamav') ?? SettingModel::get('spamav.metrics', []);
$spam = (int)($av['spam'] ?? 0);
$reject = (int)($av['reject'] ?? 0);
$ham = (int)($av['ham'] ?? 0);
$clamVer = $av['clamVer'] ?? '—';
// Postfix queue counts (active + deferred)
$queueOut = trim(@shell_exec('postqueue -p 2>/dev/null') ?? '');
$qActive = preg_match_all('/^[A-F0-9]{9,}\*?\s+/mi', $queueOut);
$qDeferred = substr_count($queueOut, '(deferred)');
$qTotal = $qActive + $qDeferred;
return [
'spamBlocked' => $spam + $reject,
'spamTagged' => $spam,
'spamRejected'=> $reject,
'hamCount' => $ham,
'clamVer' => $clamVer,
'queueTotal' => $qTotal,
'queueDeferred' => $qDeferred,
];
}
private function cpu(): array
{
$cores = (int)(shell_exec("nproc 2>/dev/null") ?: 1);
$mhz = round((float)shell_exec("awk '/^cpu MHz/{s+=$4;n++}END{if(n)print s/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1);
$s1 = $this->stat(); usleep(400000); $s2 = $this->stat();
$idle1 = $s1[3] + ($s1[4] ?? 0); $idle2 = $s2[3] + ($s2[4] ?? 0);
$dt = array_sum($s2) - array_sum($s1);
$cpu = $dt > 0 ? max(0, min(100, round(($dt - ($idle2 - $idle1)) / $dt * 100))) : 0;
return [$cpu, $cores, $mhz ?: '—'];
}
private function stat(): array
{
$p = preg_split('/\s+/', trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: ''));
array_shift($p);
return array_map('intval', $p);
}
private function ram(): array
{
preg_match('/MemTotal:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $mt);
preg_match('/MemAvailable:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $ma);
$total = (int)($mt[1] ?? 0); $avail = (int)($ma[1] ?? 0); $used = $total - $avail;
return [$total > 0 ? round($used / $total * 100) : 0, round($used / 1048576, 1), round($total / 1048576, 1)];
}
private function backupData(): array
{
$policy = BackupPolicy::first();
if (!$policy) {
return ['status' => 'unconfigured', 'last_at' => null, 'last_at_full' => null, 'size' => null, 'duration' => null, 'next_at' => null, 'enabled' => false];
}
$running = BackupJob::whereIn('status', ['queued', 'running'])->exists();
if ($running) {
$status = 'running';
} elseif ($policy->last_run_at === null) {
$status = 'pending';
} else {
$status = $policy->last_status ?? 'unknown';
}
$size = ($policy->last_size_bytes ?? 0) > 0 ? $this->fmtBytes($policy->last_size_bytes) : null;
$duration = null;
$lastJob = BackupJob::where('status', 'ok')->latest('finished_at')->first();
if ($lastJob && $lastJob->started_at && $lastJob->finished_at) {
$secs = $lastJob->started_at->diffInSeconds($lastJob->finished_at);
$duration = $secs >= 60
? round($secs / 60) . ' min'
: $secs . 's';
}
$next = null;
if ($policy->enabled && $policy->schedule_cron) {
try {
$cron = new \Cron\CronExpression($policy->schedule_cron);
$next = $cron->getNextRunDate()->format('d.m.Y H:i');
} catch (\Throwable) {}
}
return [
'status' => $status,
'last_at' => $policy->last_run_at?->diffForHumans(),
'last_at_full' => $policy->last_run_at?->format('d.m.Y H:i'),
'size' => $size,
'duration' => $duration,
'next_at' => $next,
'enabled' => (bool) $policy->enabled,
];
}
private function fmtBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$v = (float) $bytes;
while ($v >= 1024 && $i < 4) { $v /= 1024; $i++; }
return number_format($v, $i <= 1 ? 0 : 1) . ' ' . $units[$i];
}
private function load(): array
{
$p = explode(' ', trim(shell_exec("cat /proc/loadavg") ?: ''));
return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00'];
}
private function uptime(): array
{
$s = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime") ?: 0);
return [intdiv($s, 86400), intdiv($s % 86400, 3600)];
}
private function disk(): array
{
$p = preg_split('/\s+/', trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: ''));
$total = (int)($p[1] ?? 0); $used = (int)($p[2] ?? 0); $free = (int)($p[3] ?? 0);
return [$total > 0 ? round($used / $total * 100) : 0, $used, $free, $total];
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use App\Services\DkimService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domain · Mailwolt')]
class DnsDkim extends Component
{
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
public function openDns(int $id): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]);
}
public function openDelete(int $id): void
{
$domain = Domain::findOrFail($id);
if ($domain->is_system) {
$this->dispatch('toast', type: 'forbidden', badge: 'System-Domain',
title: 'Domain', text: 'System-Domains können nicht gelöscht werden.', duration: 4000);
return;
}
$this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]);
}
public function regenerateDkim(int $id): void
{
$domain = Domain::findOrFail($id);
$selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
try {
/** @var DkimService $svc */
$svc = app(DkimService::class);
$svc->generateForDomain($domain, 2048, $selector);
$this->dispatch('toast', type: 'done', badge: 'DKIM',
title: 'DKIM erneuert', text: "Schlüssel für <b>{$domain->domain}</b> wurde neu generiert.", duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'DKIM',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function dkimReady(string $domain, string $selector): bool
{
$path = storage_path("app/private/dkim/{$domain}/{$selector}.private");
return is_file($path) && filesize($path) > 0;
}
public function render()
{
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$mapped = Domain::where('is_server', false)
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->orderBy('domain')
->get()
->map(function (Domain $d) use ($defaultSelector) {
$key = $d->dkimKeys->first();
$selector = $key?->selector ?? $defaultSelector;
$d->setAttribute('dkim_selector', $selector);
$d->setAttribute('dkim_ready', $this->dkimReady($d->domain, $selector));
$d->setAttribute('dkim_txt', $key?->asTxtValue() ?? '');
return $d;
});
$systemDomains = $mapped->where('is_system', true)->values();
$userDomains = $mapped->where('is_system', false)->values();
return view('livewire.ui.nx.domain.dns-dkim', compact('systemDomains', 'userDomains'));
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domains · Mailwolt')]
class DomainList extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
public function openCreate(): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-create-modal');
}
public function openEdit(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-edit-modal', arguments: ['domainId' => $id]);
}
public function openLimits(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-limits-modal', arguments: ['domainId' => $id]);
}
public function openDns(int $id): void
{
$this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]);
}
public function openDelete(int $id): void
{
if ($this->isSystem($id)) return;
$this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]);
}
private function isSystem(int $id): bool
{
$domain = Domain::findOrFail($id);
if ($domain->is_system) {
$this->dispatch('toast', type: 'forbidden', badge: 'System-Domain',
title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann nicht bearbeitet werden.', duration: 0);
return true;
}
return false;
}
public function render()
{
$query = Domain::where('is_system', false)
->where('is_server', false)
->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count'])
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->orderBy('domain');
if ($this->search !== '') {
$query->where('domain', 'like', '%' . $this->search . '%');
}
$domains = $query->get()->map(function (Domain $d) {
$tags = is_array($d->tags) ? $d->tags : [];
$d->setAttribute('visible_tags', array_slice($tags, 0, 2));
$d->setAttribute('extra_tags', max(count($tags) - 2, 0));
return $d;
});
$systemDomain = Domain::where('is_system', true)
->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count'])
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])
->first();
$total = Domain::where('is_system', false)->where('is_server', false)->count();
return view('livewire.ui.nx.domain.domain-list', compact('domains', 'systemDomain', 'total'));
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Aliasse · Mailwolt')]
class AliasList extends Component
{
public string $search = '';
#[On('alias:created')]
#[On('alias:updated')]
#[On('alias:deleted')]
public function refreshAliasList(): void
{
$this->dispatch('$refresh');
}
public function openAliasCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'domainId' => $domainId,
]);
}
public function openAliasEdit(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'aliasId' => $aliasId,
]);
}
public function openAliasDelete(int $aliasId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [
'aliasId' => $aliasId,
]);
}
public function render()
{
$term = trim($this->search);
$hasTerm = $term !== '';
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$domains = Domain::query()
->where('is_system', false)
->where('is_server', false)
->when($hasTerm, function ($q) use ($needle) {
$q->where(function ($w) use ($needle) {
$w->where('domain', 'like', $needle)
->orWhereHas('mailAliases', fn($a) => $a
->where('is_system', false)
->where(fn($x) => $x
->where('local', 'like', $needle)
->orWhere('destination', 'like', $needle)
)
);
});
})
->withCount(['mailAliases as aliases_count' => fn($a) => $a->where('is_system', false)])
->with(['mailAliases' => function ($q) use ($hasTerm, $needle) {
$q->where('is_system', false);
if ($hasTerm) {
$q->where(fn($x) => $x
->where('local', 'like', $needle)
->orWhere('destination', 'like', $needle)
);
}
$q->orderBy('local');
}])
->orderBy('domain')
->get();
if ($hasTerm) {
$lower = Str::lower($term);
foreach ($domains as $d) {
if (Str::contains(Str::lower($d->domain), $lower)) {
$d->setRelation('mailAliases', $d->mailAliases()
->where('is_system', false)
->orderBy('local')
->get()
);
}
}
}
$totalAliases = $domains->sum('aliases_count');
return view('livewire.ui.nx.mail.alias-list', [
'domains' => $domains,
'totalAliases' => $totalAliases,
]);
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use App\Models\MailUser;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Postfächer · Mailwolt')]
class MailboxList extends Component
{
public string $search = '';
public function mount(): void
{
Artisan::call('mail:update-stats');
}
#[On('mailbox:updated')]
#[On('mailbox:deleted')]
#[On('mailbox:created')]
public function refreshMailboxList(): void
{
$this->dispatch('$refresh');
}
public function openMailboxCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [
'domainId' => $domainId,
]);
}
public function openMailboxEdit(int $mailUserId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [
$mailUserId,
]);
}
public function openMailboxDelete(int $mailUserId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [
$mailUserId,
]);
}
public function updateMailboxStats(): void
{
Artisan::call('mail:update-stats');
$this->dispatch('$refresh');
$this->dispatch('toast',
type: 'done',
badge: 'Mailbox',
title: 'Statistiken aktualisiert',
text: 'Alle Mailbox-Statistiken wurden neu berechnet.',
duration: 4000,
);
}
public function updateSingleStat(int $mailUserId): void
{
$u = MailUser::with('domain:id,domain')->find($mailUserId);
if (! $u) return;
$email = (string) ($u->getRawOriginal('email') ?? '');
if (! $email) return;
Artisan::call('mail:update-stats', ['--user' => $email]);
$this->dispatch('$refresh');
}
public function render()
{
$term = trim($this->search);
$hasTerm = $term !== '';
$needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%';
$domains = Domain::query()
->where('is_system', false)
->where('is_server', false)
->when($hasTerm, function ($q) use ($needle) {
$q->where(function ($w) use ($needle) {
$w->where('domain', 'like', $needle)
->orWhereHas('mailUsers', fn($u) => $u
->where('is_system', false)
->where('localpart', 'like', $needle)
);
});
})
->withCount(['mailUsers as mail_users_count' => fn($u) => $u
->where('is_system', false)
])
->with(['mailUsers' => function ($q) use ($hasTerm, $needle) {
$q->where('is_system', false);
if ($hasTerm) {
$q->where('localpart', 'like', $needle);
}
$q->orderBy('localpart');
}])
->orderBy('domain')
->get();
if ($hasTerm) {
$lower = Str::lower($term);
foreach ($domains as $d) {
if (Str::contains(Str::lower($d->domain), $lower)) {
$d->setRelation('mailUsers', $d->mailUsers()
->where('is_system', false)
->orderBy('localpart')
->get()
);
}
}
}
foreach ($domains as $d) {
$prepared = [];
$domainActive = (bool)($d->is_active ?? true);
foreach ($d->mailUsers as $u) {
$usedBytes = (int)($u->used_bytes ?? 0);
$messageCount = (int)($u->message_count ?? 0);
$quotaMiB = (int)($u->quota_mb ?? 0);
$usedMiB = round($usedBytes / 1048576, 2);
$usage = $quotaMiB > 0
? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100))
: 0;
$mailboxActive = (bool)($u->is_active ?? true);
$effective = $domainActive && $mailboxActive;
$reason = null;
if (!$effective) {
$reason = !$domainActive ? 'Domain inaktiv' : 'Postfach inaktiv';
}
$barClass = $usage > 85 ? 'mbx-bar-high' : ($usage > 60 ? 'mbx-bar-mid' : 'mbx-bar-low');
$prepared[] = [
'id' => $u->id,
'localpart' => (string)$u->localpart,
'quota_mb' => $quotaMiB,
'used_mb' => $usedMiB,
'usage_percent' => $usage,
'bar_class' => $barClass,
'message_count' => $messageCount,
'is_active' => $mailboxActive,
'is_effective_active' => $effective,
'inactive_reason' => $reason,
'last_login' => $u->last_login_at?->diffForHumans() ?? '—',
];
}
$d->prepared_mailboxes = $prepared;
}
return view('livewire.ui.nx.mail.mailbox-list', [
'domains' => $domains,
'totalMailboxes' => $domains->sum('mail_users_count'),
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QuarantineMessageModal extends ModalComponent
{
public string $msgId;
public array $message = [];
public function mount(string $msgId): void
{
$this->msgId = $msgId;
$this->message = $this->fetchMessage($msgId);
}
public function render()
{
return view('livewire.ui.nx.mail.modal.quarantine-message-modal');
}
private function fetchMessage(string $id): array
{
$password = env('RSPAMD_PASSWORD', '');
$opts = [
'http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => $password !== '' ? "Password: {$password}\r\n" : '',
],
];
$ctx = stream_context_create($opts);
$raw = @file_get_contents("http://127.0.0.1:11334/history?rows=500", false, $ctx);
if (!$raw) return $this->emptyMessage($id);
$data = json_decode($raw, true);
$items = $data['rows'] ?? (isset($data[0]) ? $data : []);
foreach ($items as $r) {
$scanId = $r['scan_id'] ?? ($r['id'] ?? '');
if ($scanId !== $id) continue;
$rcpt = $r['rcpt'] ?? [];
if (is_array($rcpt)) $rcpt = implode(', ', $rcpt);
$symbols = [];
foreach ($r['symbols'] ?? [] as $name => $sym) {
$symbols[] = [
'name' => $name,
'score' => round((float)($sym['score'] ?? 0), 3),
'description' => $sym['description'] ?? '',
];
}
usort($symbols, fn($a, $b) => abs($b['score']) <=> abs($a['score']));
return [
'id' => $id,
'msg_id' => $r['message-id'] ?? '—',
'from' => $r['from'] ?? $r['sender'] ?? '—',
'rcpt' => $rcpt ?: '—',
'subject' => $r['subject'] ?? '(kein Betreff)',
'score' => round((float)($r['score'] ?? 0), 2),
'required' => round((float)($r['required_score'] ?? 15), 2),
'action' => $r['action'] ?? 'unknown',
'time' => (int)($r['unix_time'] ?? 0),
'size' => (int)($r['size'] ?? 0),
'ip' => $r['ip'] ?? '—',
'symbols' => $symbols,
];
}
return $this->emptyMessage($id);
}
private function emptyMessage(string $id): array
{
return ['id' => $id, 'from' => '—', 'rcpt' => '—', 'subject' => '—', 'score' => 0, 'required' => 0, 'action' => '—', 'time' => 0, 'size' => 0, 'ip' => '—', 'symbols' => [], 'msg_id' => '—'];
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QueueMessageModal extends ModalComponent
{
public string $queueId;
public array $message = [];
public function mount(string $queueId): void
{
$this->queueId = $queueId;
$this->message = $this->fetchMessage($queueId);
}
public function delete(): void
{
@shell_exec('sudo postsuper -d ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->dispatch('toast', type: 'success', title: 'Nachricht gelöscht');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function hold(): void
{
@shell_exec('sudo postsuper -h ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->message['queue'] = 'hold';
$this->dispatch('toast', type: 'info', title: 'Nachricht zurückgestellt');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function release(): void
{
@shell_exec('sudo postsuper -H ' . escapeshellarg($this->queueId) . ' 2>&1');
$this->dispatch('toast', type: 'success', title: 'Nachricht freigegeben');
$this->dispatch('queue:updated');
$this->dispatch('closeModal');
}
public function render()
{
return view('livewire.ui.nx.mail.modal.queue-message-modal');
}
private function fetchMessage(string $id): array
{
// Get queue details from postqueue -j
$out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? '');
foreach (explode("\n", $out) as $line) {
$line = trim($line);
if ($line === '') continue;
$m = json_decode($line, true);
if (!is_array($m) || ($m['queue_id'] ?? '') !== $id) continue;
$recipients = $m['recipients'] ?? [];
$rcptList = array_map(fn($r) => [
'address' => $r['address'] ?? '',
'reason' => $r['delay_reason'] ?? '',
], $recipients);
// Try to get message headers
$header = trim(@shell_exec('sudo postcat -hq ' . escapeshellarg($id) . ' 2>/dev/null') ?? '');
return [
'id' => $id,
'queue' => $m['queue_name'] ?? 'deferred',
'sender' => $m['sender'] ?? '—',
'recipients' => $rcptList,
'size' => (int)($m['message_size'] ?? 0),
'arrival' => (int)($m['arrival_time'] ?? 0),
'header' => mb_substr($header, 0, 3000),
];
}
return ['id' => $id, 'queue' => '—', 'sender' => '—', 'recipients' => [], 'size' => 0, 'arrival' => 0, 'header' => ''];
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.dvx')]
#[Title('Quarantäne · Mailwolt')]
class QuarantineList extends Component
{
use WithPagination;
#[Url(as: 'filter', keep: true)]
public string $filter = 'suspicious';
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'limit', keep: true)]
public int $perPage = 25;
public int $rows = 500;
#[On('quarantine:updated')]
public function refresh(): void {}
// ── Löschen (lokal via Cache, RSpamd hat keine per-entry API) ────────────
private function hiddenKey(): string
{
return 'quarantine.hidden.' . session()->getId();
}
private function getHidden(): array
{
return \Cache::get($this->hiddenKey(), []);
}
private function saveHidden(array $ids): void
{
\Cache::put($this->hiddenKey(), array_values(array_unique($ids)), now()->addDays(7));
}
public function deleteEntry(string $id): void
{
$hidden = $this->getHidden();
$hidden[] = $id;
$this->saveHidden($hidden);
}
public function deleteCurrentTab(): void
{
$all = $this->fetchHistory();
$hidden = $this->getHidden();
$toHide = match($this->filter) {
'suspicious' => array_filter($all, fn($m) => $m['action'] !== 'no action'),
'all' => $all,
default => array_filter($all, fn($m) => $m['action'] === $this->filter),
};
foreach ($toHide as $m) {
$hidden[] = $m['id'];
}
$this->saveHidden($hidden);
$this->resetPage();
}
public function updatedFilter(): void { $this->resetPage(); }
public function updatedSearch(): void { $this->resetPage(); }
public function updatedPerPage(): void { $this->resetPage(); }
public function openMessage(string $msgId): void
{
$this->dispatch('openModal',
component: 'ui.nx.mail.modal.quarantine-message-modal',
arguments: ['msgId' => $msgId]
);
}
public function render()
{
$hidden = $this->getHidden();
$all = array_values(array_filter(
$this->fetchHistory(),
fn($m) => !in_array($m['id'], $hidden, true)
));
$suspicious = array_values(array_filter($all, fn($m) => $m['action'] !== 'no action'));
$counts = [
'all' => count($all),
'suspicious' => count($suspicious),
'reject' => count(array_filter($all, fn($m) => $m['action'] === 'reject')),
'add header' => count(array_filter($all, fn($m) => $m['action'] === 'add header')),
'greylist' => count(array_filter($all, fn($m) => $m['action'] === 'greylist')),
];
$messages = match($this->filter) {
'suspicious' => $suspicious,
'all' => $all,
default => array_values(array_filter($all, fn($m) => $m['action'] === $this->filter)),
};
if ($this->search !== '') {
$s = strtolower($this->search);
$messages = array_values(array_filter($messages, fn($m) =>
str_contains(strtolower($m['from'] ?? ''), $s) ||
str_contains(strtolower($m['rcpt'] ?? ''), $s) ||
str_contains(strtolower($m['subject'] ?? ''), $s)
));
}
$total = count($messages);
$currentPage = LengthAwarePaginator::resolveCurrentPage();
$paged = new LengthAwarePaginator(
array_slice($messages, ($currentPage - 1) * $this->perPage, $this->perPage),
$total,
$this->perPage,
$currentPage,
['path' => request()->url()]
);
return view('livewire.ui.nx.mail.quarantine-list', [
'messages' => $paged,
'counts' => $counts,
]);
}
// ── helpers ──────────────────────────────────────────────────────────────
public function fetchHistory(): array
{
$password = env('RSPAMD_PASSWORD', '');
$opts = [
'http' => [
'timeout' => 3,
'ignore_errors' => true,
'header' => $password !== '' ? "Password: {$password}\r\n" : '',
],
];
$ctx = stream_context_create($opts);
$raw = @file_get_contents("http://127.0.0.1:11334/history?rows={$this->rows}", false, $ctx);
if (!$raw) return [];
$data = json_decode($raw, true);
if (!is_array($data)) return [];
$items = $data['rows'] ?? (isset($data[0]) ? $data : []);
return array_map(function ($r) {
$rcpt = $r['rcpt'] ?? [];
if (is_array($rcpt)) $rcpt = implode(', ', $rcpt);
return [
'id' => $r['scan_id'] ?? ($r['id'] ?? uniqid('q_')),
'msg_id' => $r['message-id'] ?? '—',
'from' => $r['from'] ?? $r['sender'] ?? '—',
'rcpt' => $rcpt ?: '—',
'subject' => $r['subject'] ?? '(kein Betreff)',
'score' => round((float)($r['score'] ?? 0), 2),
'required' => round((float)($r['required_score'] ?? 15), 2),
'action' => $r['action'] ?? 'unknown',
'time' => (int)($r['unix_time'] ?? $r['time'] ?? 0),
'size' => (int)($r['size'] ?? 0),
'symbols' => array_keys($r['symbols'] ?? []),
'ip' => $r['ip'] ?? '—',
];
}, $items);
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.dvx')]
#[Title('Mail-Queue · Mailwolt')]
class QueueList extends Component
{
use WithPagination;
#[Url(as: 'filter', keep: true)]
public string $filter = 'all';
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'limit', keep: true)]
public int $perPage = 25;
public array $selected = [];
public bool $selectAll = false;
#[On('queue:updated')]
public function refresh(): void {}
public function updatedFilter(): void { $this->resetPage(); $this->selected = []; $this->selectAll = false; }
public function updatedSearch(): void { $this->resetPage(); }
public function updatedPerPage(): void { $this->resetPage(); $this->selected = []; $this->selectAll = false; }
public function updatedSelectAll(bool $val): void
{
$messages = $this->fetchQueue();
$this->selected = $val ? array_column($messages, 'id') : [];
}
public function flush(): void
{
@shell_exec('sudo postqueue -f >/dev/null 2>&1 &');
$this->dispatch('toast', type: 'info', title: 'Queue-Flush gestartet');
}
public function deleteSelected(): void
{
$count = count($this->selected);
foreach ($this->selected as $id) {
@shell_exec('sudo postsuper -d ' . escapeshellarg($id) . ' 2>&1');
}
$this->selected = [];
$this->selectAll = false;
$this->dispatch('toast', type: 'success', title: "{$count} Nachricht(en) gelöscht");
}
public function deleteAll(): void
{
@shell_exec('sudo postsuper -d ALL 2>&1');
$this->selected = [];
$this->selectAll = false;
$this->dispatch('toast', type: 'warning', title: 'Alle Queue-Nachrichten gelöscht');
}
public function openMessage(string $queueId): void
{
$this->dispatch('openModal',
component: 'ui.nx.mail.modal.queue-message-modal',
arguments: ['queueId' => $queueId]
);
}
public function render()
{
$all = $this->fetchQueue();
$counts = [
'all' => count($all),
'active' => count(array_filter($all, fn($m) => $m['queue'] === 'active')),
'deferred' => count(array_filter($all, fn($m) => $m['queue'] === 'deferred')),
'hold' => count(array_filter($all, fn($m) => $m['queue'] === 'hold')),
];
$messages = $all;
if ($this->filter !== 'all') {
$messages = array_values(array_filter($messages, fn($m) => $m['queue'] === $this->filter));
}
if ($this->search !== '') {
$s = strtolower($this->search);
$messages = array_values(array_filter($messages, fn($m) =>
str_contains(strtolower($m['sender'] ?? ''), $s) ||
str_contains(strtolower($m['recipient'] ?? ''), $s) ||
str_contains(strtolower($m['id'] ?? ''), $s)
));
}
$total = count($messages);
$currentPage = LengthAwarePaginator::resolveCurrentPage();
$paged = new LengthAwarePaginator(
array_slice($messages, ($currentPage - 1) * $this->perPage, $this->perPage),
$total,
$this->perPage,
$currentPage,
['path' => request()->url()]
);
return view('livewire.ui.nx.mail.queue-list', [
'messages' => $paged,
'counts' => $counts,
]);
}
// ── helpers ──────────────────────────────────────────────────────────────
public function fetchQueue(): array
{
$out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? '');
if ($out !== '') {
return $this->parseJson($out);
}
return $this->parseText(trim(@shell_exec('postqueue -p 2>/dev/null') ?? ''));
}
private function parseJson(string $out): array
{
$rows = [];
foreach (explode("\n", $out) as $line) {
$line = trim($line);
if ($line === '') continue;
$m = json_decode($line, true);
if (!is_array($m)) continue;
$recipients = $m['recipients'] ?? [];
$rcptList = array_column($recipients, 'address');
$reason = $recipients[0]['delay_reason'] ?? '';
$rows[] = [
'id' => $m['queue_id'] ?? '',
'queue' => $m['queue_name'] ?? 'deferred',
'sender' => $m['sender'] ?? '',
'recipient' => implode(', ', $rcptList),
'size' => (int)($m['message_size'] ?? 0),
'arrival' => (int)($m['arrival_time'] ?? 0),
'reason' => $this->trimReason($reason),
];
}
return $rows;
}
private function parseText(string $out): array
{
$rows = [];
$current = null;
foreach (explode("\n", $out) as $line) {
if (preg_match('/^([A-F0-9]{9,})\*?\s+(\d+)\s+\S+\s+\S+\s+\d+\s+\d{1,2}:\d{2}:\d{2}\s+(.*)/i', $line, $m)) {
if ($current) $rows[] = $current;
$current = [
'id' => $m[1],
'queue' => 'active',
'sender' => trim($m[3]),
'recipient' => '',
'size' => (int)$m[2],
'arrival' => 0,
'reason' => '',
];
} elseif ($current && preg_match('/^\s+([\w.+%-]+@[\w.-]+)/', $line, $m)) {
$current['recipient'] = $m[1];
} elseif ($current && preg_match('/^\s+\((.+)\)/', $line, $m)) {
$current['reason'] = $this->trimReason($m[1]);
$current['queue'] = 'deferred';
}
}
if ($current) $rows[] = $current;
return $rows;
}
private function trimReason(string $r): string
{
$r = preg_replace('/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+/', '', trim($r));
return mb_strlen($r) > 100 ? mb_substr($r, 0, 100) . '…' : $r;
}
}

View File

@ -126,21 +126,20 @@ class SearchPaletteModal extends ModalComponent
public function go(string $type, int $id): void
{
// Schließe die Palette …
$this->dispatch('closeModal');
// … und navigiere / öffne Kontext:
// - Domain → scrolle/markiere Domainkarte
// - Mailbox → öffne Bearbeiten-Modal
// Passe an, was du bevorzugst:
if ($type === 'domain') {
$this->dispatch('focus:domain', id: $id);
$component = 'ui.domain.modal.domain-edit-modal';
$arguments = ['domainId' => $id];
} elseif ($type === 'mailbox') {
// direkt Edit-Modal auf
$this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]);
} elseif ($type === 'user') {
$this->dispatch('focus:user', id: $id);
$component = 'ui.mail.modal.mailbox-edit-modal';
$arguments = ['mailboxId' => $id];
} else {
return;
}
// Search palette schließen, dann nach dem State-Reset (300 ms) das Ziel-Modal öffnen
$this->forceClose()->closeModal();
$payload = json_encode(['component' => $component, 'arguments' => $arguments]);
$this->js("setTimeout(()=>Livewire.dispatch('openModal',{$payload}),350)");
}
public static function modalMaxWidth(): string

View File

@ -2,12 +2,82 @@
namespace App\Livewire\Ui\Security;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Audit-Logs · Mailwolt')]
class AuditLogsTable extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'lvl', keep: true)]
public string $level = '';
public int $limit = 200;
public function loadMore(): void
{
$this->limit += 200;
}
private function parseLogs(): array
{
$logFile = storage_path('logs/laravel.log');
if (!is_readable($logFile)) return [];
$fp = @fopen($logFile, 'r');
if (!$fp) return [];
$size = filesize($logFile);
$chunk = 250_000;
fseek($fp, max(0, $size - $chunk));
$raw = fread($fp, $chunk);
fclose($fp);
if (!$raw) return [];
$lines = explode("\n", $raw);
$lines = array_slice($lines, -2000);
$entries = [];
$current = null;
foreach ($lines as $line) {
if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.+)/', $line, $m)) {
if ($current !== null) $entries[] = $current;
$current = [
'time' => str_replace('T', ' ', $m[1]),
'level' => strtolower($m[2]),
'message' => trim($m[3]),
];
} elseif ($current !== null) {
$current['message'] .= "\n" . trim($line);
}
}
if ($current !== null) $entries[] = $current;
$entries = array_reverse($entries);
$filtered = [];
foreach ($entries as $e) {
if ($this->level && $e['level'] !== $this->level) continue;
if ($this->search !== '' && !str_contains(strtolower($e['message']), strtolower($this->search))) continue;
$e['message'] = Str::limit(preg_replace('/\s+/', ' ', $e['message']), 300);
$filtered[] = $e;
if (count($filtered) >= $this->limit) break;
}
return $filtered;
}
public function render()
{
return view('livewire.ui.security.audit-logs-table');
$logs = $this->parseLogs();
$levels = ['', 'info', 'debug', 'warning', 'error', 'critical'];
return view('livewire.ui.security.audit-logs-table', compact('logs', 'levels'));
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Livewire\Ui\Security;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Virenschutz · Mailwolt')]
class ClamavManager extends Component
{
public bool $running = false;
public bool $enabled = false;
public string $dbDate = '—';
public string $dbVersion = '—';
public ?int $ramMb = null;
public string $lastError = '';
public string $lastSuccess = '';
public bool $starting = false;
public int $startSecs = 0;
private const STARTING_FLAG = '/tmp/mw-clamav-starting';
public function mount(): void
{
$this->refresh();
$this->restoreStartingState();
}
private function restoreStartingState(): void
{
if (!file_exists(self::STARTING_FLAG)) return;
if ($this->running) {
@unlink(self::STARTING_FLAG);
return;
}
$elapsed = time() - (int) @file_get_contents(self::STARTING_FLAG);
if ($elapsed > 180) {
@unlink(self::STARTING_FLAG);
return;
}
$this->starting = true;
$this->startSecs = max(0, $elapsed);
}
public function refresh(): void
{
$this->lastError = '';
$this->lastSuccess = '';
$this->running = $this->serviceActive();
$this->enabled = $this->serviceEnabled();
$this->ramMb = $this->running ? $this->readRamMb() : null;
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
}
public function enable(): void
{
$this->lastError = '';
$this->lastSuccess = '';
[$ok, $msg] = $this->runCmd('enable');
if (!$ok) {
$this->lastError = $msg;
return;
}
file_put_contents(self::STARTING_FLAG, time());
$this->enabled = true;
$this->starting = true;
$this->startSecs = 0;
}
public function pollStatus(): void
{
if (!$this->starting) return;
$this->startSecs += 3;
$this->running = $this->serviceActive();
if ($this->running) {
@unlink(self::STARTING_FLAG);
$this->starting = false;
$this->startSecs = 0;
$this->enabled = $this->serviceEnabled();
$this->ramMb = $this->readRamMb();
$this->lastSuccess = 'ClamAV ist aktiv und läuft.';
Cache::forget(CacheVer::k('health:services'));
}
}
public function disable(): void
{
[$ok, $msg] = $this->runCmd('disable');
if ($ok) $this->lastSuccess = 'ClamAV wurde gestoppt und deaktiviert.';
else $this->lastError = $msg;
$this->running = $this->serviceActive();
$this->enabled = $this->serviceEnabled();
Cache::forget(CacheVer::k('health:services'));
}
public function updateDb(): void
{
[$ok, $msg] = $this->runCmd('freshclam');
if ($ok) $this->lastSuccess = 'Virensignaturen wurden aktualisiert.';
else $this->lastError = $msg;
[$this->dbDate, $this->dbVersion] = $this->readDbInfo();
}
private function runCmd(string $action): array
{
$out = []; $rc = null;
exec('sudo -n /usr/local/sbin/mailwolt-clamav ' . escapeshellarg($action) . ' 2>&1', $out, $rc);
\Log::info('ClamAV ' . $action, ['rc' => $rc, 'out' => $out]);
$msg = implode(' ', $out) ?: 'Unbekannter Fehler (rc=' . $rc . ')';
return [$rc === 0, $msg];
}
private function isInstalled(): bool
{
return file_exists('/usr/sbin/clamd')
|| file_exists('/lib/systemd/system/clamav-daemon.service')
|| file_exists('/usr/lib/systemd/system/clamav-daemon.service');
}
private function serviceActive(): bool
{
$exit = null;
@exec('systemctl is-active --quiet clamav-daemon 2>/dev/null', $_, $exit);
return $exit === 0;
}
private function serviceEnabled(): bool
{
$exit = null;
@exec('systemctl is-enabled --quiet clamav-daemon 2>/dev/null', $_, $exit);
return $exit === 0;
}
private function readRamMb(): ?int
{
$out = [];
@exec("ps -C clamd -o rss= 2>/dev/null", $out);
$kb = array_sum(array_map('intval', array_filter($out)));
return $kb > 0 ? (int) round($kb / 1024) : null;
}
private function readDbInfo(): array
{
$paths = [
'/var/lib/clamav/main.cvd',
'/var/lib/clamav/main.cld',
'/var/lib/clamav/daily.cvd',
'/var/lib/clamav/daily.cld',
];
$latest = 0;
foreach ($paths as $p) {
if (@file_exists($p)) {
$mtime = @filemtime($p);
if ($mtime > $latest) $latest = $mtime;
}
}
if ($latest === 0) return ['—', '—'];
$date = date('d.m.Y H:i', $latest);
$ver = '—';
$out = [];
@exec('sigtool --info /var/lib/clamav/daily.cld 2>/dev/null | grep "^Version:" | head -1', $out);
if (empty($out)) {
@exec('sigtool --info /var/lib/clamav/daily.cvd 2>/dev/null | grep "^Version:" | head -1', $out);
}
if (!empty($out[0])) {
$ver = trim(str_replace('Version:', '', $out[0]));
}
return [$date, $ver];
}
public function render()
{
return view('livewire.ui.security.clamav-manager');
}
}

View File

@ -1,34 +1,964 @@
<?php
namespace App\Livewire\Ui\Security;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2BanCard extends Component
{
public bool $available = true;
public bool $permDenied = false;
public bool $error = false;
public int $activeBans = 0;
public array $topIps = []; // [['ip'=>'1.2.3.4','count'=>12],...]
public array $jails = [];
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.security.fail2-ban-card'); }
public function refresh(): void { $this->load(true); }
protected function load(bool $force=false): void
public function mount(): void
{
$status = @shell_exec('fail2ban-client status 2>/dev/null') ?? '';
$bans = preg_match('/Currently banned:\s+(\d+)/i', $status, $m) ? (int)$m[1] : 0;
$this->activeBans = $bans;
$this->load();
}
// quick & rough: last 1000 lines auth/mail logs → top IPs
$log = @shell_exec('tail -n 1000 /var/log/auth.log /var/log/mail.log 2>/dev/null | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | sort | uniq -c | sort -nr | head -5');
$rows = [];
if ($log) {
foreach (preg_split('/\R+/', trim($log)) as $l) {
if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
$rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
}
}
public function render()
{
return view('livewire.ui.security.fail2-ban-card');
}
#[On('f2b:refresh-banlist')]
public function refresh(): void
{
$this->load(true);
}
public function openDetails(string $jail): void
{
$this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
}
/* ---------------- intern ---------------- */
protected function load(bool $force = false): void
{
$this->available = $this->permDenied = $this->error = false;
$this->activeBans = 0;
$this->jails = [];
$bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
if ($bin === '') {
$this->available = false;
return;
}
$this->topIps = $rows;
$this->available = true;
[, $ping] = $this->f2b('ping');
if ($this->looksDenied($ping)) {
$this->permDenied = true;
return;
}
[, $status] = $this->f2b('status');
if ($this->looksDenied($status)) {
$this->permDenied = true;
return;
}
if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
$this->error = true;
Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
return;
}
$jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
$sum = 0;
$rows = [];
foreach ($jails as $j) {
$jEsc = escapeshellarg($j);
[, $s] = $this->f2b("status {$jEsc}");
if ($this->looksDenied($s)) {
$this->permDenied = true;
return;
}
$banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
$bantime = $this->getBantime($j);
$rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime, 'ips' => []];
$sum += $banned;
}
$this->activeBans = $sum;
$this->jails = $rows;
}
private function f2b(string $args): array
{
$sudo = $this->bin('sudo');
$f2b = $this->bin('fail2ban-client');
$cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
$out = (string)@shell_exec($cmd);
$ok = stripos($out, 'Status') !== false
|| stripos($out, 'Jail list') !== false
|| stripos($out, 'pong') !== false;
return [$ok, $out];
}
private function getBantime(string $jail): int
{
[, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
if ($this->looksDenied($out)) {
$this->permDenied = true;
return 600;
}
if (preg_match('/-?\d+/', trim($out), $m)) return (int)$m[0];
return 600;
}
private function looksDenied(string $out): bool
{
return (bool)preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out);
}
private function firstMatch(string $pattern, string $haystack): ?string
{
return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
}
private function bin(string $name): string
{
$p = trim((string)@shell_exec("command -v " . escapeshellarg($name) . " 2>/dev/null"));
return $p !== '' ? $p : $name;
}
}
//namespace App\Livewire\Ui\Security;
//
//use Illuminate\Support\Facades\Log;
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // sudo / Socket-Rechte fehlen?
// public bool $error = false; // anderer Fehler (Output unerwartet)
// public int $activeBans = 0;
// public array $jails = []; // [['name','banned','bantime','ips'=>[...]]]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// #[On('f2b:refresh-banlist')]
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDetails(string $jail): void
// {
// // wire-elements/modal (v2): Event-Namen + Component + Params
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
// }
//
// /* ------------------- intern ------------------- */
//
// protected function load(bool $force = false): void
// {
// $this->available = true;
// $this->permDenied = false;
// $this->error = false;
// $this->activeBans = 0;
// $this->jails = [];
//
// // existiert fail2ban-client?
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// return;
// }
//
// // Rechte / Erreichbarkeit
// [, $ping] = $this->f2b('ping');
// if ($this->looksDenied($ping)) {
// $this->permDenied = true;
// return;
// }
//
// // Jails lesen
// [, $status] = $this->f2b('status');
// if ($this->looksDenied($status)) {
// $this->permDenied = true;
// return;
// }
// if (!preg_match('/Jail list:\s*(.+)$/mi', $status, $mm)) {
// // etwas stimmt nicht loggen und „error“ zeigen
// $this->error = true;
// Log::warning('Fail2BanCard: unexpected status output', ['status' => $status]);
// return;
// }
//
// $jails = array_filter(array_map('trim', preg_split('/\s*,\s*/', $mm[1] ?? '')));
// $sum = 0;
// $rows = [];
//
// foreach ($jails as $j) {
// $jEsc = escapeshellarg($j);
// [, $s] = $this->f2b("status {$jEsc}");
// if ($this->looksDenied($s)) {
// $this->permDenied = true;
// return;
// }
//
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j);
// $ipLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipLine)))) : [];
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'bantime' => $bantime,
// // wir zeigen IPs NICHT mehr in der Card; Details sind im Modal
// 'ips' => [],
// ];
// $sum += $banned;
// }
//
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// if ($this->looksDenied($out)) {
// $this->permDenied = true;
// return 600;
// }
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600;
// }
//
// private function looksDenied(string $out): bool
// {
// return preg_match('/(permission denied|not allowed to execute|a password is required)/i', $out) === 1;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true;
// public bool $permDenied = false;
// public int $activeBans = 0;
// public array $jails = [];
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// #[On('f2b:refresh-banlist')]
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function openDetails(string $jail): void
// {
// // KORREKTER DISPATCH für wire-elements/modal
// $this->dispatch('openModal', component: 'ui.security.modal.fail2-ban-jail-modal', arguments: ['jail' => $jail]);
// }
//
// /* ------------------- intern ------------------- */
//
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Rechte prüfen
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Jail-Liste
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
//
// foreach ($jails as $j) {
// $jEsc = escapeshellarg($j);
// [, $s] = $this->f2b("status {$jEsc}");
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j);
// $ipListLine = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipListLine !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipListLine)))) : [];
//
// // Details inkl. Restzeit je IP
// $ipDetails = $this->buildIpDetails($j, $ips, $bantime);
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'bantime' => $bantime, // Sek. (-1 = permanent)
// 'ips' => $ipDetails, // [['ip'=>..., 'remaining'=>..., 'until'=>...], ...]
// ];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 3 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// /** konfig. Bantime des Jails in Sekunden (-1 = permanent) */
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600; // konservativer Fallback
// }
//
// /** Letzten Ban-Zeitpunkt (Unix-Timestamp) aus /var/log/fail2ban.log ermitteln. */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// $file = '/var/log/fail2ban.log';
// if (!is_readable($file)) return null;
//
// // nur das Ende der Datei lesen (Performance, auch bei Rotation groß genug wählen)
// $tailBytes = 400000; // 400 KB
// $size = @filesize($file) ?: 0;
// $seek = max(0, $size - $tailBytes);
//
// $fh = @fopen($file, 'rb');
// if (!$fh) return null;
// if ($seek > 0) fseek($fh, $seek);
// $data = stream_get_contents($fh) ?: '';
// fclose($fh);
//
// // Beispielzeile:
// // 2025-10-30 22:34:20,797 fail2ban.actions [...] NOTICE [sshd] Ban 193.46.255.244
// $j = preg_quote($jail, '/');
// $p = preg_quote($ip, '/');
// $pattern = '/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}),\d+.*\['.$j.'\]\s+Ban\s+'.$p.'\s*$/m';
//
// if (preg_match_all($pattern, $data, $m) && !empty($m[1])) {
// $date = end($m[1]); // YYYY-MM-DD
// $time = end($m[2]); // HH:MM:SS
// $dt = \DateTime::createFromFormat('Y-m-d H:i:s', "$date $time", new \DateTimeZone(date_default_timezone_get()));
// return $dt ? $dt->getTimestamp() : null;
// }
// return null;
// }
//
// /** Baut Details inkl. Restzeit (Sekunden; -1 = permanent). */
// private function buildIpDetails(string $jail, array $ips, int $bantime): array
// {
// $now = time();
// $out = [];
//
// foreach ($ips as $ip) {
// $banAt = $this->lastBanTimestamp($jail, $ip);
// $remaining = null;
// $until = null;
//
// if ($bantime === -1) {
// $remaining = -1; // permanent
// } elseif ($banAt !== null) {
// $remaining = max(0, $bantime - ($now - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantime) : null;
// }
//
// $out[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, null = Ban-Zeitpunkt nicht gefunden, >=0 = Sekunden
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
// return $out;
// }
//
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs
// /** @var array<int,array{name:string,banned:int,bantime:int}> */
// public array $jails = [];
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// // Optional: öffnet später dein Detail-Modal/Tab
// public function openDetails(string $jail): void
// {
// $this->dispatch('openModal', 'ui.security.modal.fail2-ban-jail-modal', ['jail' => $jail]);
// }
// /* ---------------- intern ---------------- */
//
// protected function load(bool $force = false): void
// {
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Rechtecheck
// [$ok, $raw] = $this->f2b('ping');
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// return;
// }
//
// // Jails laden
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $rows = [];
// $sum = 0;
//
// foreach ($jails as $j) {
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $bantime = $this->getBantime($j); // Sek.; -1 = permanent
// $rows[] = ['name' => $j, 'banned' => $banned, 'bantime' => $bantime];
// $sum += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $sum;
// $this->jails = $rows;
// }
//
// /** sudo + fail2ban-client ausführen; [ok, output] */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $out = (string)@shell_exec("timeout 2 $sudo -n $f2b $args 2>&1");
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) return (int)$m[0];
// return 600; // defensiver Default
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name','banned','bantime','ips'=>[['ip','remaining','until'],...]],...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// /** Button „Neu prüfen“ */
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string)@shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0;
// $rows = [];
//
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // Sek., -1 = permanent
//
// [, $s] = $this->f2b('status ' . escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails,
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string)@shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get ' . escapeshellarg($jail) . ' bantime');
// $val = trim($out);
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// return 600; // defensiver Default
// }
//
// /** letzte Ban-Zeile aus /var/log/fail2ban.log → Unix-Timestamp */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = trim((string)@shell_exec($cmd));
// if ($line === '') return null;
//
// // "YYYY-MM-DD HH:MM:SS,mmm ..."
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1] . ' ' . $m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Top-IPs grob zählen (aus der aktuellen Jail-Liste; Fallback: Log) */
// private function collectTopIps(): array
// {
// $map = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $row) {
// $ip = $row['ip'] ?? null;
// if (!$ip) continue;
// $map[$ip] = ($map[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($map)) {
// arsort($map);
// $out = [];
// foreach (array_slice($map, 0, 5, true) as $ip => $count) {
// $out[] = ['ip' => $ip, 'count' => $count];
// }
// return $out;
// }
//
// // Fallback: aus fail2ban.log
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string)@shell_exec($cmd);
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip' => $m[2], 'count' => (int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//
//class Fail2BanCard extends Component
//{
// public bool $available = true; // fail2ban-client vorhanden?
// public bool $permDenied = false; // Socket/Root-Rechte fehlen?
// public int $activeBans = 0; // Summe gebannter IPs über alle Jails
// public array $jails = []; // [['name'=>'sshd','banned'=>2,'ips'=>['1.2.3.4',...]], ...]
// public array $topIps = []; // [['ip'=>'x.x.x.x','count'=>N], ...]
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2-ban-card');
// }
//
// // Wird vom Button "Neu prüfen" genutzt
// public function refresh(): void
// {
// $this->load(true);
// }
//
// /* --------------------- intern --------------------- */
//
// protected function load(bool $force = false): void
// {
// // existiert fail2ban-client?
// $bin = trim((string) @shell_exec('command -v fail2ban-client 2>/dev/null')) ?: '';
// if ($bin === '') {
// $this->available = false;
// $this->permDenied = false;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = [];
// return;
// }
//
// // ping → prüft zugleich Rechte (bei Permission-Fehler kommt Klartext)
// [$ok, $raw] = $this->f2b('ping'); // ok == "pong" erkannt
// if (!$ok && stripos($raw, 'permission denied') !== false) {
// $this->available = true;
// $this->permDenied = true;
// $this->activeBans = 0;
// $this->jails = [];
// $this->topIps = $this->collectTopIps();
// return;
// }
//
// // Jails auflisten
// [, $status] = $this->f2b('status');
// $jailsLn = $this->firstMatch('/Jail list:\s*(.+)$/mi', $status);
// $jails = $jailsLn ? array_filter(array_map('trim', preg_split('/\s*,\s*/', $jailsLn))) : [];
//
// $total = 0; $rows = [];
//// ... in load() NACH dem Einlesen der Jail-Liste:
// $rows = [];
// foreach ($jails as $j) {
// $bantimeSecs = $this->getBantime($j); // konfigurierter Wert (Sekunden, -1 = permanent)
//
// [, $s] = $this->f2b('status '.escapeshellarg($j));
// $banned = (int)($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//
// // Restzeiten je IP bestimmen (aus /var/log/fail2ban.log)
// $ipDetails = [];
// foreach (array_slice($ips, 0, 50) as $ip) {
// $banAt = $this->lastBanTimestamp($j, $ip); // Unix-Timestamp oder null
// $remaining = null;
// $until = null;
//
// if ($banAt !== null) {
// if ((int)$bantimeSecs === -1) {
// $remaining = -1; // permanent
// } else {
// $remaining = max(0, $bantimeSecs - (time() - $banAt));
// $until = $remaining > 0 ? ($banAt + $bantimeSecs) : null;
// }
// }
//
// $ipDetails[] = [
// 'ip' => $ip,
// 'remaining' => $remaining, // -1 = permanent, 0 = abgelaufen, >0 Sek.
// 'until' => $until, // Unix-Timestamp oder null
// ];
// }
//
// $rows[] = [
// 'name' => $j,
// 'banned' => $banned,
// 'ips' => $ipDetails, // jetzt mit Details
// 'bantime' => (int)$bantimeSecs,
// ];
// $total += $banned;
// }
//
// // foreach ($jails as $j) {
//// [, $s] = $this->f2b('status '.escapeshellarg($j));
//// $banned = (int) ($this->firstMatch('/Currently banned:\s+(\d+)/i', $s) ?: 0);
//// $ipList = $this->firstMatch('/Banned IP list:\s*(.+)$/mi', $s) ?: '';
//// $ips = $ipList !== '' ? array_values(array_filter(array_map('trim', preg_split('/\s+/', $ipList)))) : [];
//// $rows[] = ['name'=>$j,'banned'=>$banned,'ips'=>array_slice($ips, 0, 8)];
//// $total += $banned;
//// }
//
//
//
// $this->available = true;
// $this->permDenied = false;
// $this->activeBans = $total;
// $this->jails = $rows;
// $this->topIps = $this->collectTopIps();
// }
//
// /** führt fail2ban-client via sudo aus; gibt [ok, output] zurück */
// private function f2b(string $args): array
// {
// $sudo = '/usr/bin/sudo';
// $f2b = '/usr/bin/fail2ban-client';
// $cmd = "timeout 2 $sudo -n $f2b $args 2>&1";
// $out = (string) @shell_exec($cmd);
//
// $ok = stripos($out, 'Status') !== false
// || stripos($out, 'Jail list') !== false
// || stripos($out, 'pong') !== false;
//
// return [$ok, $out];
// }
//
// private function getBantime(string $jail): int
// {
// [, $out] = $this->f2b('get '.escapeshellarg($jail).' bantime');
// // fail2ban liefert Seconds als Zahl (oder mit Newline)
// $val = trim($out);
// // Fallback: manche Versionen geben nur Zahl ohne Kontext zurück,
// // sonst aus jail.local ermitteln wäre overkill -> einfache Zahl extrahieren:
// if (preg_match('/-?\d+/', $val, $m)) {
// return (int)$m[0];
// }
// // wenn nicht ermittelbar: 600 Sekunden als conservative default
// return 600;
// }
//
// /** Sucht die letzte "Ban <IP>"-Zeile für Jail in /var/log/fail2ban.log und gibt Unix-Timestamp zurück. */
// private function lastBanTimestamp(string $jail, string $ip): ?int
// {
// // Beispiel-Logzeilen:
// // 2025-10-29 18:07:11,436 fail2ban.actions [12345]: NOTICE [sshd] Ban 1.2.3.4
// // Wir holen die letzte passende Zeile (tail mit grep), dann parsen Datum.
// $pattern = escapeshellarg(sprintf('\\[%s\\] Ban %s', $jail, $ip));
// $cmd = "grep -F \"[{$jail}] Ban {$ip}\" /var/log/fail2ban.log 2>/dev/null | tail -n 1";
// $line = (string)@shell_exec($cmd);
// $line = trim($line);
// if ($line === '') {
// return null;
// }
// // Datumsformat am Anfang: "YYYY-MM-DD HH:MM:SS,mmm"
// if (preg_match('/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/', $line, $m)) {
// $ts = strtotime($m[1].' '.$m[2]);
// return $ts ?: null;
// }
// return null;
// }
//
// private function firstMatch(string $pattern, string $haystack): ?string
// {
// return preg_match($pattern, $haystack, $m) ? trim($m[1]) : null;
// }
//
// /** Zählt die häufigsten IPs aus den letzten Fail2Ban-Logs (ban/unban Events) */
// private function collectTopIps(): array
// {
// // 1. Versuch: IPs direkt aus den Jails
// $rows = [];
// foreach ($this->jails as $jail) {
// foreach ($jail['ips'] as $ip) {
// $rows[$ip] = ($rows[$ip] ?? 0) + 1;
// }
// }
//
// if (!empty($rows)) {
// arsort($rows);
// return collect($rows)
// ->map(fn($count, $ip) => ['ip' => $ip, 'count' => $count])
// ->values()
// ->take(5)
// ->toArray();
// }
//
// // 2. Fallback: Falls keine Jails/IPs → Logdatei
// $cmd = 'grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" /var/log/fail2ban.log 2>/dev/null'
// . ' | sort | uniq -c | sort -nr | head -5';
// $log = (string) @shell_exec($cmd);
//
// $rows = [];
// if ($log !== '') {
// foreach (preg_split('/\R+/', trim($log)) as $l) {
// if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
// $rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
// }
// }
// }
// return $rows;
// }
//}

View File

@ -0,0 +1,210 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\On;
use Livewire\Component;
class Fail2banBanlist extends Component
{
/**
* null oder '*' => alle Jails
* 'recidive' => nur dieses Jail
* 'mailwolt-blacklist' etc.
*/
public ?string $jail = null;
/**
* @var array<int,array{
* ip:string,jail:string,service:string,permanent:bool,label:string,
* remaining:string,box:string,badge:string,dot:string,btn:string
* }>
*/
public array $rows = [];
#[On('f2b:refresh')]
public function refreshList(): void
{
$this->loadBanned();
}
public function mount(?string $jail = null): void
{
$this->jail = $jail;
$this->loadBanned();
}
public function render()
{
return view('livewire.ui.security.fail2ban-banlist');
}
/* ================= core ================= */
private function loadBanned(): void
{
$jails = $this->jailList();
// ggf. nur ein bestimmtes Jail
if (is_string($this->jail) && $this->jail !== '' && $this->jail !== '*') {
$jails = in_array($this->jail, $jails, true) ? [$this->jail] : [];
}
$rows = [];
foreach ($jails as $j) {
$out = $this->f2b("status " . escapeshellarg($j));
if (!preg_match('/IP list:\s*(.+)$/mi', $out, $m)) {
continue;
}
$ips = preg_split('/\s+/', trim($m[1])) ?: [];
foreach ($ips as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
continue;
}
$banInfo = $this->getBanInfo($j, $ip);
$permanent = $banInfo['permanent'];
$remaining = $banInfo['remaining'];
if ($permanent) {
$box = 'border-rose-400/30 bg-rose-500/5';
$badge = 'border-rose-400/30 bg-rose-500/10 text-rose-200';
$label = 'Permanent';
$style = 'permanent';
$dot = 'bg-rose-500';
} else {
$box = 'border-amber-400/20 bg-white/3';
$badge = 'border-amber-400/30 bg-amber-500/10 text-amber-200';
$label = 'Temporär';
$style = 'temporary';
$dot = 'bg-amber-400';
}
$rows[] = [
'ip' => $ip,
'jail' => $j,
'service' => $this->serviceLabel($j),
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'remaining' => $remaining,
'box' => $box,
'badge' => $badge,
'dot' => $dot,
'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
];
}
}
// Sortierung: permanent oben, dann nach Jail, dann IP
usort($rows, function ($a, $b) {
if ($a['permanent'] !== $b['permanent']) return $a['permanent'] ? -1 : 1;
if ($a['jail'] !== $b['jail']) return strcmp($a['jail'], $b['jail']);
return strcmp($a['ip'], $b['ip']);
});
$this->rows = $rows;
}
/** Entbannt eine IP **im angegebenen Jail** (Button gibt Jail mit) */
public function unban(string $ip, string $jail): void
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) return;
$cmd = sprintf(
'sudo -n /usr/bin/fail2ban-client set %s unbanip %s 2>&1',
escapeshellarg($jail),
escapeshellarg($ip)
);
@shell_exec($cmd);
$this->loadBanned();
$this->dispatch('toast',
type: 'done',
badge: 'Fail2Ban',
title: 'IP entbannt',
text: "IP {$ip} in Jail „{$jail}“ entbannt.",
duration: 5000,
);
}
/* ================= helpers ================= */
/** Gibt permanent-Flag und verbleibende Zeit für (jail, ip) zurück. */
private function getBanInfo(string $jail, string $ip): array
{
$fallbackPermanent = ($jail === 'mailwolt-blacklist');
$cmd = sprintf('sudo -n /usr/local/sbin/clubird-f2b-baninfo %s %s 2>&1',
escapeshellarg($jail), escapeshellarg($ip));
$out = trim((string)@shell_exec($cmd));
if ($out !== '') {
[$timeofban, $bantime] = array_pad(explode('|', $out), 2, '0');
$timeofban = (int)$timeofban;
$bantime = (int)$bantime;
if ($bantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
$remaining = ($timeofban + $bantime) - time();
if ($remaining > 0) {
return ['permanent' => false, 'remaining' => $this->formatRemaining($remaining)];
}
}
// Fallback: DB-Eintrag ist abgelaufen (fail2ban-Neustart setzt Timer intern neu,
// ohne die DB zu aktualisieren) → konfigurierte Jail-Banzeit als Näherung
$cfgBantime = (int)trim($this->f2b('get ' . escapeshellarg($jail) . ' bantime'));
if ($cfgBantime < 0) {
return ['permanent' => true, 'remaining' => ''];
}
if ($cfgBantime > 0) {
return ['permanent' => false, 'remaining' => '≤ ' . $this->formatRemaining($cfgBantime)];
}
return ['permanent' => $fallbackPermanent, 'remaining' => ''];
}
private function formatRemaining(int $seconds): string
{
if ($seconds <= 0) return 'läuft ab';
if ($seconds < 60) return "noch {$seconds} Sek.";
if ($seconds < 3600) return 'noch ' . (int)ceil($seconds / 60) . ' Min.';
if ($seconds < 86400) return 'noch ' . round($seconds / 3600, 1) . ' Std.';
return 'noch ' . (int)ceil($seconds / 86400) . ' Tage';
}
private function serviceLabel(string $jail): string
{
return match(true) {
$jail === 'sshd' => 'SSH',
$jail === 'dovecot' => 'IMAP/POP3',
str_starts_with($jail, 'postfix') => 'SMTP',
str_starts_with($jail, 'nginx') => 'Web',
$jail === 'recidive' => 'Recidive',
str_contains($jail, 'blacklist') => 'Blacklist',
default => $jail,
};
}
/** Liste aller Jails */
private function jailList(): array
{
$out = $this->f2b('status');
if (preg_match('/Jail list:\s*(.+)$/mi', $out, $m)) {
$jails = array_map('trim', preg_split('/\s*,\s*/', trim($m[1])));
return array_values(array_filter($jails, fn($v) => $v !== ''));
}
return [];
}
/** fail2ban-client über sudo aufrufen */
private function f2b(string $args): string
{
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
}
}

View File

@ -0,0 +1,547 @@
<?php
namespace App\Livewire\Ui\Security;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use App\Models\Fail2banSetting;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
#[Layout('layouts.dvx')]
#[Title('Fail2Ban · Mailwolt')]
class Fail2banSettings extends Component
{
// Formfelder
public int $bantime;
public int $max_bantime;
public bool $bantime_increment;
public float $bantime_factor;
public int $max_retry;
public int $findtime;
public int $cidr_v4;
public int $cidr_v6;
public bool $external_mode;
public array $whitelist = [];
public array $blacklist = [];
public Fail2banSetting $settings;
#[On('f2b:refresh')]
public function refreshLists(): void
{
$this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
$this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
}
public function mount(): void
{
$this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
'bantime' => 3600,
'max_bantime' => 43200,
'bantime_increment' => true,
'bantime_factor' => 1.5,
'max_retry' => 3,
'findtime' => 600,
'cidr_v4' => 32,
'cidr_v6' => 128,
'external_mode' => false,
]);
$this->fill([
'bantime' => (int)$this->settings->bantime,
'max_bantime' => (int)$this->settings->max_bantime,
'bantime_increment' => (bool)$this->settings->bantime_increment,
'bantime_factor' => (float)$this->settings->bantime_factor,
'max_retry' => (int)$this->settings->max_retry,
'findtime' => (int)$this->settings->findtime,
'cidr_v4' => (int)$this->settings->cidr_v4,
'cidr_v6' => (int)$this->settings->cidr_v6,
'external_mode' => (bool)$this->settings->external_mode,
]);
$this->refreshLists();
}
public function save(): void
{
$this->validate([
'bantime' => 'required|integer|min:60',
'max_bantime' => 'required|integer|min:60',
'bantime_factor' => 'required|numeric|min:1',
'max_retry' => 'required|integer|min:1',
'findtime' => 'required|integer|min:60',
'cidr_v4' => 'required|integer|min:8|max:32',
'cidr_v6' => 'required|integer|min:8|max:128',
]);
try {
// Einstellungen speichern
$this->settings->update([
'bantime' => $this->bantime,
'max_bantime' => $this->max_bantime,
'bantime_increment' => $this->bantime_increment,
'bantime_factor' => $this->bantime_factor,
'max_retry' => $this->max_retry,
'findtime' => $this->findtime,
'cidr_v4' => $this->cidr_v4,
'cidr_v6' => $this->cidr_v6,
'external_mode' => $this->external_mode,
]);
// Config-Dateien schreiben
$this->writeDefaultsConfig();
$this->writeWhitelistConfig();
// Fail2Ban reload
$this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Einstellungen gespeichert',
text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
duration: 6000,
);
} catch (\Throwable $e) {
$this->dispatch('toast',
type: 'error',
badge: 'Fail2Ban',
title: 'Fehler beim Anwenden',
text: 'Die neuen Einstellungen konnten nicht angewendet werden: ' . $e->getMessage(),
duration: 8000,
);
}
}
/* ---------------- Config-Dateien ---------------- */
protected function writeDefaultsConfig(): void
{
$s = $this->settings;
$content = <<<CONF
[DEFAULT]
bantime = {$s->bantime}
findtime = {$s->findtime}
maxretry = {$s->max_retry}
bantime.increment = {$this->boolToStr($s->bantime_increment)}
bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
}
protected function writeWhitelistConfig(): void
{
// zieht System + User-Whitelist
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
}
/* ---------------- Helper ---------------- */
private function writeRootFileViaTee(string $target, string $content): void
{
if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
throw new \RuntimeException("Illegal path: $target");
}
$cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $desc, $pipes);
if (!is_resource($proc)) {
throw new \RuntimeException('tee start fehlgeschlagen');
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
throw new \RuntimeException("tee failed writing to {$target}");
}
}
private function runCommand(string $cmd): void
{
$output = [];
$return = 0;
exec($cmd . ' 2>&1', $output, $return);
if ($return !== 0) {
throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
}
}
private function boolToStr(bool $v): string
{
return $v ? 'true' : 'false';
}
public function render()
{
return view('livewire.ui.security.fail2ban-settings');
}
}
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[On('f2b:refresh')]
// public function refreshLists(): void
// {
// $this->whitelist = Fail2banIpList::visibleWhitelist()->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::visibleBlacklist()->pluck('ip')->toArray();
// }
//
// public function mount(): void
// {
// // Setting holen oder Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600,
// 'max_bantime' => 43200,
// 'bantime_increment' => true,
// 'bantime_factor' => 1.5,
// 'max_retry' => 3,
// 'findtime' => 600,
// 'cidr_v4' => 32,
// 'cidr_v6' => 128,
// 'external_mode' => false,
// ]);
//
// // Properties befüllen
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->refreshLists();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// // Einstellungen speichern
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// // Config-Dateien schreiben
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// // Fail2Ban reload
// $this->runCommand('sudo -n /usr/bin/fail2ban-client reload');
//
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
//
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
//
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// /**
// * Schreibt Root-Dateien sicher via `sudo tee`
// */
// private function writeRootFileViaTee(string $target, string $content): void
// {
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'],
// 1 => ['pipe', 'w'],
// 2 => ['pipe', 'w'],
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// stream_get_contents($pipes[1]);
// stream_get_contents($pipes[2]);
// $exitCode = proc_close($proc);
//
// if ($exitCode !== 0) {
// throw new \RuntimeException("tee failed writing to {$target}");
// }
// }
//
// /**
// * Führt Systembefehle aus und wirft Exception bei Fehlern
// */
// private function runCommand(string $cmd): void
// {
// $output = [];
// $return = 0;
// exec($cmd . ' 2>&1', $output, $return);
//
// if ($return !== 0) {
// throw new \RuntimeException("Command failed ($return): {$cmd}\n" . implode("\n", $output));
// }
// }
//
// private function boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Attributes\On;
//use Livewire\Component;
//use App\Models\Fail2banSetting;
//use App\Models\Fail2banIpList;
//
//class Fail2banSettings extends Component
//{
// // Formfelder
// public int $bantime;
// public int $max_bantime;
// public bool $bantime_increment;
// public float $bantime_factor;
// public int $max_retry;
// public int $findtime;
// public int $cidr_v4;
// public int $cidr_v6;
// public bool $external_mode;
//
// public array $whitelist = [];
// public array $blacklist = [];
//
// public Fail2banSetting $settings;
//
// #[On('f2b:refresh')]
// public function refreshLists(): void
// {
// $this->whitelist = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::where('type', 'blacklist')->pluck('ip')->toArray();
// }
//
// public function mount(): void
// {
// // Setting holen oder mit Defaults anlegen
// $this->settings = Fail2banSetting::first() ?? Fail2banSetting::create([
// 'bantime' => 3600, 'max_bantime' => 43200, 'bantime_increment' => true,
// 'bantime_factor' => 1.5, 'max_retry' => 3, 'findtime' => 600,
// 'cidr_v4' => 32, 'cidr_v6' => 128, 'external_mode' => false,
// ]);
//
// // Properties füllen (KEINE Mixed-Objekte in Inputs binden)
// $this->fill([
// 'bantime' => (int)$this->settings->bantime,
// 'max_bantime' => (int)$this->settings->max_bantime,
// 'bantime_increment' => (bool)$this->settings->bantime_increment,
// 'bantime_factor' => (float)$this->settings->bantime_factor,
// 'max_retry' => (int)$this->settings->max_retry,
// 'findtime' => (int)$this->settings->findtime,
// 'cidr_v4' => (int)$this->settings->cidr_v4,
// 'cidr_v6' => (int)$this->settings->cidr_v6,
// 'external_mode' => (bool)$this->settings->external_mode,
// ]);
//
// $this->whitelist = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $this->blacklist = Fail2banIpList::where('type','blacklist')->pluck('ip')->toArray();
// }
//
// public function save(): void
// {
// $this->validate([
// 'bantime' => 'required|integer|min:60',
// 'max_bantime' => 'required|integer|min:60',
// 'bantime_factor' => 'required|numeric|min:1',
// 'max_retry' => 'required|integer|min:1',
// 'findtime' => 'required|integer|min:60',
// 'cidr_v4' => 'required|integer|min:8|max:32',
// 'cidr_v6' => 'required|integer|min:8|max:128',
// ]);
//
// $this->settings->update([
// 'bantime' => $this->bantime,
// 'max_bantime' => $this->max_bantime,
// 'bantime_increment' => $this->bantime_increment,
// 'bantime_factor' => $this->bantime_factor,
// 'max_retry' => $this->max_retry,
// 'findtime' => $this->findtime,
// 'cidr_v4' => $this->cidr_v4,
// 'cidr_v6' => $this->cidr_v6,
// 'external_mode' => $this->external_mode,
// ]);
//
// $this->writeDefaultsConfig();
// $this->writeWhitelistConfig();
//
// @shell_exec('sudo fail2ban-client reload');
// $this->dispatch('notify', message: 'Gespeichert & Fail2Ban neu geladen.');
// }
//
// protected function writeDefaultsConfig(): void
// {
// $s = $this->settings;
// $content = <<<CONF
//[DEFAULT]
//bantime = {$s->bantime}
//findtime = {$s->findtime}
//maxretry = {$s->max_retry}
//bantime.increment = {$this->boolToStr($s->bantime_increment)}
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
// file_put_contents('/etc/fail2ban/jail.d/00-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type','whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
// file_put_contents('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
// {
// // Nur erlaubte Pfade (Hardening)
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
//
// $descriptorspec = [
// 0 => ['pipe', 'r'], // stdin -> tee
// 1 => ['pipe', 'w'], // stdout
// 2 => ['pipe', 'w'], // stderr
// ];
//
// $proc = proc_open($cmd, $descriptorspec, $pipes, null, null);
// if (!is_resource($proc)) {
// throw new \RuntimeException('Failed to start tee');
// }
//
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// $stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
// $stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
//
// $code = proc_close($proc);
// if ($code !== 0) {
// throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
// }
// }
//
// private function boolToStr(bool $v): string
// {
// return $v ? 'true' : 'false';
// }
//
// public function render()
// {
// return view('livewire.ui.security.fail2ban-settings');
// }
//}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Ui\Security\Modal\Fail2BanJailModal;
use Livewire\Component;
class Php extends Component
{
public function render()
{
return view('livewire.ui.security.modal.fail2-ban-jail-modal.php');
}
}

View File

@ -0,0 +1,567 @@
<?php
namespace App\Livewire\Ui\Security\Modal;
use LivewireUI\Modal\ModalComponent;
use App\Models\Fail2banIpList;
use Illuminate\Validation\ValidationException;
class Fail2banIpModal extends ModalComponent
{
/** 'whitelist' | 'blacklist' */
public string $type = 'whitelist';
/** 'add' | 'remove' */
public string $mode = 'add';
/** IP/CIDR im Formular */
public string $ip = '';
/** Für "remove" vorbefüllt */
public ?string $prefill = null;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
{
$type = strtolower($type);
$mode = strtolower($mode);
if (!in_array($type, ['whitelist', 'blacklist'], true)) {
throw new \InvalidArgumentException('Invalid type');
}
if (!in_array($mode, ['add', 'remove'], true)) {
throw new \InvalidArgumentException('Invalid mode');
}
$this->type = $type;
$this->mode = $mode;
$this->ip = $ip ?? '';
$this->prefill = $ip;
}
public function render()
{
return view('livewire.ui.security.modal.fail2ban-ip-modal');
}
/* ---------------- actions ---------------- */
public function save(): void
{
$this->assertAddMode();
$ip = trim($this->ip);
if (!Fail2banIpList::isValidIpOrCidr($ip)) {
throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
}
// Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen
if (Fail2banIpList::isLoopback($ip)) {
throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']);
}
// Duplikate abfangen
$exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists();
if ($exists) {
throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']);
}
// DB schreiben
Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
if ($this->type === 'whitelist') {
// Whitelist-Datei aktualisieren + Fail2Ban reload
$this->writeWhitelistConfig();
$this->reloadFail2ban();
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'success',
badge: 'Fail2Ban',
title: 'Whitelist aktualisiert',
text: 'Die IP wurde erfolgreich zur Whitelist hinzugefügt und ist nun freigegeben.',
duration: 6000,
);
} else {
// Blacklist = sofort bannen
$this->banIp($ip);
// UI aktualisieren & Toast
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'warning',
badge: 'Fail2Ban',
title: 'Blacklist aktualisiert',
text: 'Die IP wurde zur Blacklist hinzugefügt und umgehend blockiert.',
duration: 6000,
);
}
// Modal bewusst am Ende schließen (Toast bleibt sichtbar)
$this->closeModal();
}
public function remove(): void
{
$this->assertRemoveMode();
$ip = trim($this->prefill ?? $this->ip);
if ($ip === '') return;
// System-Whitelist darf nicht entfernt werden
$row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first();
if ($row && $row->is_system) {
throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']);
}
Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
if ($this->type === 'whitelist') {
$this->writeWhitelistConfig();
$this->reloadFail2ban();
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Whitelist geändert',
text: 'Die IP wurde aus der Whitelist entfernt.',
duration: 6000,
);
} else {
$this->unbanIp($ip);
$this->dispatch('f2b:refresh');
$this->dispatch('toast',
type: 'info',
badge: 'Fail2Ban',
title: 'Blacklist geändert',
text: 'Die IP wurde aus der Blacklist entfernt und ist wieder freigegeben.',
duration: 6000,
);
}
$this->closeModal();
}
/* ---------------- helper ---------------- */
private function assertAddMode(): void
{
if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
}
private function assertRemoveMode(): void
{
if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
}
private function writeWhitelistConfig(): void
{
// WICHTIG: inkl. System-IPs (unsichtbar in der UI)
$ips = Fail2banIpList::allWhitelistForConfig();
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
}
private function writeRootFileViaTee(string $target, string $content): void
{
if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
throw new \RuntimeException("Illegal path: $target");
}
$cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
$desc = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $desc, $pipes);
if (!is_resource($proc)) {
throw new \RuntimeException('tee start fehlgeschlagen');
}
fwrite($pipes[0], $content);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($proc);
if ($code !== 0) {
throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
}
}
private function reloadFail2ban(): void
{
@shell_exec('sudo -n /usr/bin/fail2ban-client reload 2>&1');
}
private function banIp(string $ip): void
{
$ipEsc = escapeshellarg($ip);
@shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
}
private function unbanIp(string $ip): void
{
$ipEsc = escapeshellarg($ip);
@shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
}
}
//namespace App\Livewire\Ui\Security\Modal;
//
//use LivewireUI\Modal\ModalComponent;
//use App\Models\Fail2banIpList;
//use Illuminate\Validation\ValidationException;
//
//class Fail2banIpModal extends ModalComponent
//{
// /** 'whitelist' | 'blacklist' */
// public string $type = 'whitelist';
//
// /** 'add' | 'remove' */
// public string $mode = 'add';
//
// /** IP/CIDR im Formular */
// public string $ip = '';
//
// /** Für "remove" vorbefüllt */
// public ?string $prefill = null;
//
// public static function modalMaxWidth(): string
// {
// return 'lg';
// }
//
// public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
// {
// $type = strtolower($type);
// $mode = strtolower($mode);
//
// if (!in_array($type, ['whitelist', 'blacklist'], true)) {
// throw new \InvalidArgumentException('Invalid type');
// }
// if (!in_array($mode, ['add', 'remove'], true)) {
// throw new \InvalidArgumentException('Invalid mode');
// }
//
// $this->type = $type;
// $this->mode = $mode;
// $this->ip = $ip ?? '';
// $this->prefill = $ip;
// }
//
// public function render()
// {
// return view('livewire.ui.security.modal.fail2ban-ip-modal');
// }
//
// /* ---------------- actions ---------------- */
//
// public function save(): void
// {
// $this->assertAddMode();
// $ip = trim($this->ip);
//
// if (!Fail2banIpList::isValidIpOrCidr($ip)) {
// throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
// }
//
// // Schutz: System-/Loopback-IPs darf der User nicht manuell pflegen
// if (Fail2banIpList::isLoopback($ip)) {
// throw ValidationException::withMessages(['ip' => 'Loopback/localhost ist bereits systemseitig erlaubt und kann nicht geändert werden.']);
// }
//
// // Duplikate abfangen (es gibt einen Unique-Index ip+type; trotzdem user-freundlich)
// $exists = Fail2banIpList::where('ip', $ip)->where('type', $this->type)->exists();
// if ($exists) {
// throw ValidationException::withMessages(['ip' => ucfirst($this->type) . ' enthält diese IP bereits.']);
// }
//
// // DB schreiben
// Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/whitelist.local
// $this->reloadFail2ban(); // f2b neu laden
// } else {
// // Blacklist = sofort bannen im dedizierten Jail
// $this->banIp($ip);
// }
//
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// }
//
// public function remove(): void
// {
// $this->assertRemoveMode();
// $ip = trim($this->prefill ?? $this->ip);
// if ($ip === '') return;
//
// // System-Whitelist darf nicht entfernt werden
// $row = Fail2banIpList::where('type', $this->type)->where('ip', $ip)->first();
// if ($row && $row->is_system) {
// throw ValidationException::withMessages(['ip' => 'Systemeintrag kann nicht entfernt werden.']);
// }
//
// Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// $this->unbanIp($ip);
// }
//
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// $this->dispatch('toast',
// type: 'done',
// badge: 'Fail2Ban',
// title: 'Einstellungen gespeichert',
// text: 'Die Fail2Ban-Konfiguration wurde erfolgreich übernommen und ist jetzt aktiv.',
// duration: 6000,
// );
// }
//
// /* ---------------- helper ---------------- */
//
// private function assertAddMode(): void
// {
// if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
// }
//
// private function assertRemoveMode(): void
// {
// if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
// }
//
// private function writeWhitelistConfig(): void
// {
// // WICHTIG: inkl. System-IPs
// $ips = Fail2banIpList::allWhitelistForConfig();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// // sicher in Root-Pfad schreiben (sudo tee)
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
// {
// if (!preg_match('#^/etc/fail2ban/jail\.d/[A-Za-z0-9._-]+\.local$#', $target)) {
// throw new \RuntimeException("Illegal path: $target");
// }
//
// $cmd = sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target));
// $desc = [
// 0 => ['pipe', 'r'],
// 1 => ['pipe', 'w'],
// 2 => ['pipe', 'w'],
// ];
// $proc = proc_open($cmd, $desc, $pipes);
// if (!is_resource($proc)) {
// throw new \RuntimeException('tee start fehlgeschlagen');
// }
// fwrite($pipes[0], $content);
// fclose($pipes[0]);
// $stdout = stream_get_contents($pipes[1]);
// fclose($pipes[1]);
// $stderr = stream_get_contents($pipes[2]);
// fclose($pipes[2]);
// $code = proc_close($proc);
// if ($code !== 0) {
// throw new \RuntimeException("tee failed (code $code): $stderr $stdout");
// }
// }
//
// private function reloadFail2ban(): void
// {
// @shell_exec('sudo -n /usr/bin/fail2ban-client reload 2>&1');
// }
//
// private function banIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
// }
//
// private function unbanIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo -n /usr/bin/fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
// }
//}
//
//namespace App\Livewire\Ui\Security\Modal;
//
//use LivewireUI\Modal\ModalComponent;
//use App\Models\Fail2banIpList;
//use Illuminate\Validation\ValidationException;
//
//class Fail2banIpModal extends ModalComponent
//{
// /** 'whitelist' | 'blacklist' */
// public string $type = 'whitelist';
//
// /** 'add' | 'remove' */
// public string $mode = 'add';
//
// /** IP/CIDR im Formular */
// public string $ip = '';
//
// /** Für "remove" vorbefüllt */
// public ?string $prefill = null;
//
// public static function modalMaxWidth(): string { return 'lg'; }
//
// public function mount(string $type = 'whitelist', string $mode = 'add', ?string $ip = null): void
// {
// $type = strtolower($type);
// $mode = strtolower($mode);
//
// if (!in_array($type, ['whitelist', 'blacklist'], true)) {
// throw new \InvalidArgumentException('Invalid type');
// }
// if (!in_array($mode, ['add', 'remove'], true)) {
// throw new \InvalidArgumentException('Invalid mode');
// }
//
// $this->type = $type;
// $this->mode = $mode;
// $this->ip = $ip ?? '';
// $this->prefill = $ip;
// }
//
// public function render()
// {
// return view('livewire.ui.security.modal.fail2ban-ip-modal');
// }
//
// /* ---------------- actions ---------------- */
//
// public function save(): void
// {
// $this->assertAddMode();
// $ip = trim($this->ip);
//
// if (!$this->isValidIpOrCidr($ip)) {
// throw ValidationException::withMessages(['ip' => 'Ungültige IP oder CIDR.']);
// }
//
// // DB schreiben
// Fail2banIpList::firstOrCreate(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// // Blacklist = sofort bannen im dedizierten Jail
// $this->banIp($ip);
// }
//
// $this->dispatch('f2b:refresh');
// $this->dispatch('notify', message: ucfirst($this->type).' aktualisiert.');
// $this->closeModal();
// $this->dispatch('f2b:refresh'); // falls du eine Liste neu laden willst
// }
//
// public function remove(): void
// {
// $this->assertRemoveMode();
// $ip = trim($this->prefill ?? $this->ip);
//
// if ($ip === '') return;
//
// Fail2banIpList::where('type', $this->type)->where('ip', $ip)->delete();
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig();
// $this->reloadFail2ban();
// } else {
// // aus Blacklist-Jail entbannen, falls noch aktiv
// $this->unbanIp($ip);
// }
//
// $this->dispatch('f2b:refresh');
// $this->dispatch('notify', message: ucfirst($this->type).' Eintrag entfernt.');
// $this->closeModal();
// $this->dispatch('f2b:refresh');
// }
//
// /* ---------------- helper ---------------- */
//
// private function assertAddMode(): void
// {
// if ($this->mode !== 'add') throw new \LogicException('Wrong mode');
// }
//
// private function assertRemoveMode(): void
// {
// if ($this->mode !== 'remove') throw new \LogicException('Wrong mode');
// }
//
// private function isValidIpOrCidr(string $s): bool
// {
// // IP
// if (filter_var($s, FILTER_VALIDATE_IP)) return true;
//
// // CIDR
// if (strpos($s, '/') !== false) {
// [$ip, $mask] = explode('/', $s, 2);
// if (!filter_var($ip, FILTER_VALIDATE_IP)) return false;
// if (strpos($ip, ':') !== false) {
// // IPv6
// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 128;
// }
// // IPv4
// return ctype_digit($mask) && (int)$mask >= 8 && (int)$mask <= 32;
// }
// return false;
// }
//
// private function writeWhitelistConfig(): void
// {
// $ips = Fail2banIpList::where('type', 'whitelist')->pluck('ip')->toArray();
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $file = '/etc/fail2ban/jail.d/whitelist.local';
// $tmp = $file.'.tmp';
// @file_put_contents($tmp, $content, LOCK_EX);
// @chmod($tmp, 0644);
// @rename($tmp, $file);
// }
//
// private function reloadFail2ban(): void
// {
// @shell_exec('sudo fail2ban-client reload 2>&1');
// }
//
// private function banIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo fail2ban-client set mailwolt-blacklist banip {$ipEsc} 2>&1");
// // optional: in DB zusätzlich behalten, damit UI konsistent ist (bereits oben getan)
// }
//
// private function unbanIp(string $ip): void
// {
// $ipEsc = escapeshellarg($ip);
// @shell_exec("sudo fail2ban-client set mailwolt-blacklist unbanip {$ipEsc} 2>&1");
// }
//}

View File

@ -2,90 +2,54 @@
namespace App\Livewire\Ui\Security\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
use Vectorface\GoogleAuthenticator;
class TotpSetupModal extends ModalComponent
{
public string $secret;
public string $otp = '';
public string $qrPng; // PNG Data-URI
public bool $alreadyActive = false;
public string $step = 'scan';
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
// << Wichtig: je Modal eigene Breite >>
public static function modalMaxWidth(): string
{
// mögliche Werte: 'sm','md','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl'
return 'xl'; // kompakt für TOTP
}
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$user = Auth::user();
$ga = new GoogleAuthenticator();
// Falls User schon Secret hat: wiederverwenden, sonst neues anlegen
$this->secret = $user->totp_secret ?: $ga->createSecret();
$issuer = config('app.name', 'MailWolt');
// getQRCodeUrl(accountName, secret, issuer) => PNG Data-URI
$this->qrPng = $ga->getQRCodeUrl($user->email, $this->secret, $issuer);
$this->alreadyActive = (bool) ($user->two_factor_enabled ?? false);
$totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
}
#[On('security:totp:enable')]
public function verifyAndEnable(string $code): void
public function verify(): void
{
$code = preg_replace('/\D/', '', $code ?? '');
if (strlen($code) !== 6) {
$this->dispatch('toast', body: 'Bitte 6-stelligen Code eingeben.');
$this->validate(['code' => 'required|digits:6']);
$totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
return;
}
$ga = new GoogleAuthenticator();
$ok = $ga->verifyCode($this->secret, $code, 2); // 2 × 30 s Toleranz
if (!$ok) {
$this->dispatch('toast', body: 'Code ungültig. Versuche es erneut.');
return;
}
$user = Auth::user();
$user->totp_secret = $this->secret;
$user->two_factor_enabled = true;
$user->save();
$this->dispatch('totp-enabled');
$this->dispatch('toast', body: 'TOTP aktiviert.');
$this->dispatch('closeModal');
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$this->step = 'codes';
}
public function disable(): void
public function done(): void
{
$user = Auth::user();
$user->totp_secret = null;
$user->two_factor_enabled = false;
$user->save();
$this->dispatch('totp-disabled');
$this->dispatch('toast', body: 'TOTP deaktiviert.');
$this->dispatch('closeModal');
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP aktiviert',
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$this->dispatch('2fa-status-changed');
$this->closeModal();
}
public function saveAccount() { /* $this->validate(..); user->update([...]) */ }
public function changePassword() { /* validate & set */ }
public function changeEmail() { /* validate, send verify link, etc. */ }
public function openRecovery() { /* optional modal or page */ }
public function logoutOthers() { /* … */ }
public function logoutSession(string $id) { /* … */ }
public function render()
{
return view('livewire.ui.security.modal.totp-setup-modal');
return view('livewire.ui.system.modal.totp-setup-modal');
}
}

View File

@ -1,156 +1,343 @@
<?php
declare(strict_types=1);
// App\Livewire\Ui\Security\RblCard.php
namespace App\Livewire\Ui\Security;
use Livewire\Component;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Artisan;
use App\Models\Setting;
class RblCard extends Component
{
public string $ip = '';
public int $hits = 0;
public array $lists = [];
public string $ip = '';
public ?string $ipv4 = null;
public ?string $ipv6 = null;
public function mount(): void
{
$this->load();
}
public int $hits = 0;
public array $lists = []; // nur gelistete Zonen
public array $meta = []; // status je Zone
public ?string $checkedAt = null;
public ?string $validUntil = null;
public function render()
{
return view('livewire.ui.security.rbl-card');
}
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.security.rbl-card'); }
public function refresh(): void
{
Cache::forget('dash.rbl');
// Manuelles Re-Check via Command (asynchron, damit UI nicht blockiert)
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
// Sofortige UI-Aktualisierung aus Settings (altes Ergebnis) …
$this->load(true);
// … und kurzer Hinweis
$this->dispatch('toast', type:'info', title:'RBL-Prüfung gestartet', text:'Ergebnis wird aktualisiert, sobald verfügbar.', duration:2500);
}
protected function load(bool $force = false): void
{
// 1) IPv4/IPv6 bevorzugt aus /etc/mailwolt/installer.env
[$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
$payload = $force
? (array) Setting::get('health.rbl', []) // direkt aus DB
: (array) (Cache::get('health.rbl') ?: Setting::get('health.rbl', []));
// 2) Fallback auf .env
$this->ipv4 = $ip4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: '';
$this->ipv6 = $ip6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: '';
// 3) RBL-Ermittlung (cached)
$data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
// bevorzugt eine valide IPv4 für den RBL-Check
$candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
if (!$candidate) {
$fromFile = @file_get_contents('/etc/mailwolt/public_ip') ?: '';
$fromFile = trim($fromFile);
if ($this->validIPv4($fromFile)) {
$candidate = $fromFile;
}
}
if (!$candidate) {
// letzter Fallback kann auf Hardened-Systemen geblockt sein
$curl = @shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?: '';
$curl = trim($curl);
if ($this->validIPv4($curl)) {
$candidate = $curl;
}
}
$ip = $candidate ?: '0.0.0.0';
$lists = $this->queryRblLists($ip);
return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
});
// 4) Werte ins Component-State
foreach ($data as $k => $v) {
$this->$k = $v;
}
}
/** Bevorzugt Installer-ENV; gibt [ipv4, ipv6] zurück oder [null, null]. */
private function resolvePublicIpsFromInstallerEnv(): array
{
$file = '/etc/mailwolt/installer.env';
if (!is_readable($file)) {
return [null, null];
}
$ipv4 = null;
$ipv6 = null;
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
// Kommentare überspringen
if (preg_match('/^\s*#/', $line)) {
continue;
}
// KEY=VALUE (VALUE evtl. in "..." oder '...')
if (!str_contains($line, '=')) {
continue;
}
[$k, $v] = array_map('trim', explode('=', $line, 2));
$v = trim($v, " \t\n\r\0\x0B\"'");
if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) {
$ipv4 = $v;
} elseif ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) {
$ipv6 = $v;
}
}
return [$ipv4, $ipv6];
}
private function validIPv4(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
}
private function validIPv6(?string $ip): bool
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
}
/**
* Prüft die IP gegen ein paar gängige RBLs.
* Nutzt PHP-DNS (checkdnsrr), keine externen Tools.
*
* @return array<string> gelistete RBL-Zonen
*/
private function queryRblLists(string $ip): array
{
// Nur IPv4 prüfen (die meisten Listen hier sind v4)
if (!$this->validIPv4($ip)) {
return [];
}
$rev = implode('.', array_reverse(explode('.', $ip)));
$sources = [
'zen.spamhaus.org',
'bl.spamcop.net',
'dnsbl.sorbs.net',
'b.barracudacentral.org',
];
$listed = [];
foreach ($sources as $zone) {
$qname = "{$rev}.{$zone}";
// A-Record oder TXT deuten auf Listing hin
if (@checkdnsrr($qname . '.', 'A') || @checkdnsrr($qname . '.', 'TXT')) {
$listed[] = $zone;
}
}
return $listed;
$this->ip = (string)($payload['ip'] ?? '');
$this->ipv4 = $payload['ipv4'] ?? null;
$this->ipv6 = $payload['ipv6'] ?? null;
$this->hits = (int)($payload['hits'] ?? 0);
$this->lists = (array)($payload['lists'] ?? []);
$this->meta = (array)($payload['meta'] ?? []);
$this->checkedAt = $payload['checked_at'] ?? null;
$this->validUntil = $payload['valid_until'] ?? null;
}
}
//namespace App\Livewire\Ui\Security;
//
//use Illuminate\Support\Facades\Cache;
//use Livewire\Component;
//
//class RblCard extends Component
//{
// public string $ip = '';
// public int $hits = 0;
// public array $lists = [];
//
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// // Schalte registrierungspflichtige Listen (Barracuda etc.) optional zu
// private bool $includeRegistered = false; // env('RBL_INCLUDE_REGISTERED', false) wenn du willst
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.rbl-card');
// }
//
// public function refresh(): void
// {
// Cache::forget('dash.rbl');
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
//
// $this->ipv4 = $ip4 ?: trim((string)env('SERVER_PUBLIC_IPV4', '')) ?: '';
// $this->ipv6 = $ip6 ?: trim((string)env('SERVER_PUBLIC_IPV6', '')) ?: '';
//
// $data = Cache::remember('dash.rbl', $force ? 1 : 6 * 3600, function () {
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
//
// if (!$candidate) {
// $fromFile = trim((string)@file_get_contents('/etc/mailwolt/public_ip'));
// if ($this->validIPv4($fromFile)) $candidate = $fromFile;
// }
// if (!$candidate) {
// $curl = trim((string)@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null"));
// if ($this->validIPv4($curl)) $candidate = $curl;
// }
//
// $ip = $candidate ?: '0.0.0.0';
// $lists = $this->queryRblLists($ip);
//
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
// });
//
// foreach ($data as $k => $v) $this->$k = $v;
// }
//
// /** bevorzugt Installer-ENV */
// private function resolvePublicIpsFromInstallerEnv(): array
// {
// $file = '/etc/mailwolt/installer.env';
// if (!is_readable($file)) return [null, null];
//
// $ipv4 = $ipv6 = null;
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
// foreach ($lines as $line) {
// if (preg_match('/^\s*#/', $line) || !str_contains($line, '=')) continue;
// [$k, $v] = array_map('trim', explode('=', $line, 2));
// $v = trim($v, " \t\n\r\0\x0B\"'");
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) $ipv4 = $v;
// if ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) $ipv6 = $v;
// }
// return [$ipv4, $ipv6];
// }
//
// private function validIPv4(?string $ip): bool
// {
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
// }
//
// private function validIPv6(?string $ip): bool
// {
// return (bool)filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// }
//
// /**
// * Prüft die IP gegen gängige **öffentliche** RBLs.
// * @return array<string> gelistete RBL-Zonen
// */
// private function queryRblLists(string $ip): array
// {
// if (!$this->validIPv4($ip)) return [];
//
// $rev = implode('.', array_reverse(explode('.', $ip)));
//
// // nur Zonen prüfen, die es wirklich gibt
// $zones = [
// 'zen.spamhaus.org',
// 'psbl.surriel.com',
// 'dnsbl-1.uceprotect.net',
// 'all.s5h.net',
// ];
// $zones = array_values(array_filter($zones, fn($z) => @checkdnsrr($z.'.','NS')));
//
// $listed = [];
// foreach ($zones as $zone) {
// $q = "{$rev}.{$zone}.";
//
// $a = @dns_get_record($q, DNS_A) ?: [];
// if (!count($a)) continue;
//
// $ips = array_column($a, 'ip');
//
// // --- WICHTIG: Spamhaus "blocked" / Ratelimit ignorieren
// if (array_intersect($ips, ['127.255.255.254','127.255.255.255'])) {
// // optional: merk dir, dass Spamhaus blockt -> UI-Hinweis
// $listed[] = ['zone'=>$zone, 'code'=>'blocked', 'txt'=>null];
// continue;
// }
//
// $txtRecs = @dns_get_record($q, DNS_TXT) ?: [];
// $txt = $txtRecs[0]['txt'] ?? null;
//
// $listed[] = ['zone'=>$zone, 'code'=>$ips[0] ?? null, 'txt'=>$txt];
// }
//
// // Nur echte Treffer zurückgeben; „blocked“ separat signalisieren
// $real = array_values(array_filter($listed, fn($e) => ($e['code'] ?? null) !== 'blocked'));
//
// // Falls alles nur "blocked" war, gib leere Liste zurück
// return array_map(fn($e) => $e['zone'].($e['code'] ? " ({$e['code']})" : ''), $real);
// }
//}
////declare(strict_types=1);
//
//namespace App\Livewire\Ui\Security;
//
//use Livewire\Component;
//use Illuminate\Support\Facades\Cache;
//
//class RblCard extends Component
//{
// public string $ip = '';
// public int $hits = 0;
// public array $lists = [];
//
// public ?string $ipv4 = null;
// public ?string $ipv6 = null;
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.security.rbl-card');
// }
//
// public function refresh(): void
// {
// Cache::forget('dash.rbl');
// $this->load(true);
// }
//
// protected function load(bool $force = false): void
// {
// // 1) IPv4/IPv6 bevorzugt aus /etc/mailwolt/installer.env
// [$ip4, $ip6] = $this->resolvePublicIpsFromInstallerEnv();
//
// // 2) Fallback auf .env
// $this->ipv4 = $ip4 ?: trim((string) env('SERVER_PUBLIC_IPV4', '')) ?: '';
// $this->ipv6 = $ip6 ?: trim((string) env('SERVER_PUBLIC_IPV6', '')) ?: '';
//
// // 3) RBL-Ermittlung (cached)
// $data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
// // bevorzugt eine valide IPv4 für den RBL-Check
// $candidate = $this->validIPv4($this->ipv4 ?? '') ? $this->ipv4 : null;
//
// if (!$candidate) {
// $fromFile = @file_get_contents('/etc/mailwolt/public_ip') ?: '';
// $fromFile = trim($fromFile);
// if ($this->validIPv4($fromFile)) {
// $candidate = $fromFile;
// }
// }
//
// if (!$candidate) {
// // letzter Fallback kann auf Hardened-Systemen geblockt sein
// $curl = @shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?: '';
// $curl = trim($curl);
// if ($this->validIPv4($curl)) {
// $candidate = $curl;
// }
// }
//
// $ip = $candidate ?: '0.0.0.0';
// $lists = $this->queryRblLists($ip);
//
// return ['ip' => $ip, 'hits' => count($lists), 'lists' => $lists];
// });
//
// // 4) Werte ins Component-State
// foreach ($data as $k => $v) {
// $this->$k = $v;
// }
// }
//
// /** Bevorzugt Installer-ENV; gibt [ipv4, ipv6] zurück oder [null, null]. */
// private function resolvePublicIpsFromInstallerEnv(): array
// {
// $file = '/etc/mailwolt/installer.env';
// if (!is_readable($file)) {
// return [null, null];
// }
//
// $ipv4 = null;
// $ipv6 = null;
//
// $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
// foreach ($lines as $line) {
// // Kommentare überspringen
// if (preg_match('/^\s*#/', $line)) {
// continue;
// }
// // KEY=VALUE (VALUE evtl. in "..." oder '...')
// if (!str_contains($line, '=')) {
// continue;
// }
// [$k, $v] = array_map('trim', explode('=', $line, 2));
// $v = trim($v, " \t\n\r\0\x0B\"'");
//
// if ($k === 'SERVER_PUBLIC_IPV4' && $this->validIPv4($v)) {
// $ipv4 = $v;
// } elseif ($k === 'SERVER_PUBLIC_IPV6' && $this->validIPv6($v)) {
// $ipv6 = $v;
// }
// }
//
// return [$ipv4, $ipv6];
// }
//
// private function validIPv4(?string $ip): bool
// {
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
// }
//
// private function validIPv6(?string $ip): bool
// {
// return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// }
//
// /**
// * Prüft die IP gegen ein paar gängige RBLs.
// * Nutzt PHP-DNS (checkdnsrr), keine externen Tools.
// *
// * @return array<string> gelistete RBL-Zonen
// */
// private function queryRblLists(string $ip): array
// {
// // Nur IPv4 prüfen (die meisten Listen hier sind v4)
// if (!$this->validIPv4($ip)) {
// return [];
// }
//
// $rev = implode('.', array_reverse(explode('.', $ip)));
// $sources = [
// 'zen.spamhaus.org',
// 'bl.spamcop.net',
// 'dnsbl.sorbs.net',
// 'b.barracudacentral.org',
// ];
//
// $listed = [];
// foreach ($sources as $zone) {
// $qname = "{$rev}.{$zone}";
// // A-Record oder TXT deuten auf Listing hin
// if (@checkdnsrr($qname . '.', 'A') || @checkdnsrr($qname . '.', 'TXT')) {
// $listed[] = $zone;
// }
// }
//
// return $listed;
// }
//}
//
//namespace App\Livewire\Ui\Security;
//

View File

@ -2,12 +2,85 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Rspamd · Mailwolt')]
class RspamdForm extends Component
{
public float $spam_score = 5.0;
public float $greylist_score = 4.0;
public float $reject_score = 15.0;
public bool $enabled = true;
public function mount(): void
{
$this->spam_score = (float) Setting::get('rspamd.spam_score', 5.0);
$this->greylist_score = (float) Setting::get('rspamd.greylist_score', 4.0);
$this->reject_score = (float) Setting::get('rspamd.reject_score', 15.0);
$this->enabled = (bool) Setting::get('rspamd.enabled', true);
}
public function save(): void
{
$this->validate([
'spam_score' => 'required|numeric|min:1|max:50',
'greylist_score' => 'required|numeric|min:0|max:50',
'reject_score' => 'required|numeric|min:1|max:100',
]);
Setting::setMany([
'rspamd.spam_score' => $this->spam_score,
'rspamd.greylist_score' => $this->greylist_score,
'rspamd.reject_score' => $this->reject_score,
'rspamd.enabled' => $this->enabled,
]);
try {
$this->writeRspamdConfig();
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload-or-restart', 'rspamd']);
$this->dispatch('toast', type: 'done', badge: 'Rspamd',
title: 'Einstellungen gespeichert',
text: 'Rspamd-Konfiguration wurde übernommen und neu geladen.', duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'Rspamd',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function writeRspamdConfig(): void
{
$target = '/etc/rspamd/local.d/actions.conf';
$content = <<<CONF
actions {
reject = {$this->reject_score};
add_header = {$this->spam_score};
greylist = {$this->greylist_score};
}
CONF;
$proc = proc_open(
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen');
fwrite($pipes[0], $content);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed writing to {$target}");
}
public function render()
{
return view('livewire.ui.security.rspamd-form');
$r = Process::run(['systemctl', 'is-active', 'rspamd']);
$running = trim($r->output()) === 'active';
return view('livewire.ui.security.rspamd-form', compact('running'));
}
}

View File

@ -2,10 +2,182 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('SSL/TLS · Mailwolt')]
class SslCertificatesTable extends Component
{
public array $certs = [];
// Domains (aus Einstellungen)
public string $uiDomain = '';
public string $webmailDomain = '';
public string $mailDomain = '';
// Provisioning
public bool $sslProvisioning = false;
public bool $sslDone = false;
public array $sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
public function mount(): void
{
$this->uiDomain = (string) Setting::get('ui_domain', '');
$this->webmailDomain = (string) Setting::get('webmail_domain', '');
$this->mailDomain = (string) Setting::get('mail_domain', '');
$this->certs = $this->loadCertificates();
$this->restoreSslProvisioningState();
}
public function refresh(): void
{
$this->certs = $this->loadCertificates();
$this->dispatch('toast', type: 'done', badge: 'SSL', title: 'Aktualisiert',
text: 'Zertifikatsliste wurde neu geladen.', duration: 3000);
}
public function startSslProvisioning(): void
{
if (! ($this->uiDomain && $this->webmailDomain && $this->mailDomain)) {
$this->dispatch('toast', type: 'warn', badge: 'SSL',
title: 'Domains fehlen',
text: 'Bitte erst alle Domains unter Einstellungen speichern.', duration: 6000);
return;
}
@mkdir(self::SSL_STATE_DIR, 0755, true);
@unlink(self::SSL_STATE_DIR . '/done');
foreach (['ui', 'mail', 'webmail'] as $k) {
file_put_contents(self::SSL_STATE_DIR . "/{$k}", 'pending');
}
$this->sslProgress = ['ui' => 'pending', 'webmail' => 'pending', 'mail' => 'pending'];
$this->sslDone = false;
$this->sslProvisioning = true;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s clubird:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=1 > /dev/null 2>&1 &',
escapeshellarg($artisan),
escapeshellarg($this->uiDomain),
escapeshellarg($this->mailDomain),
escapeshellarg($this->webmailDomain),
);
@shell_exec($cmd);
}
public function pollSsl(): void
{
if (! $this->sslProvisioning || $this->sslDone) return;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::SSL_STATE_DIR . "/{$key}";
$this->sslProgress[$key] = is_readable($file)
? trim((string) @file_get_contents($file))
: 'pending';
}
$done = @file_get_contents(self::SSL_STATE_DIR . '/done');
if ($done !== false) {
$this->sslDone = true;
$this->sslProvisioning = false;
$this->certs = $this->loadCertificates();
}
}
public function renew(string $name): void
{
$safe = preg_replace('/[^a-z0-9._-]/i', '', $name);
if ($safe === '') return;
$out = (string) @shell_exec(
"sudo -n /usr/bin/certbot renew --cert-name {$safe} --force-renewal 2>&1"
);
$this->certs = $this->loadCertificates();
if (str_contains($out, 'Successfully renewed') || str_contains($out, 'success')) {
$this->dispatch('toast', type: 'done', badge: 'SSL',
title: 'Zertifikat erneuert', text: "Zertifikat <b>{$safe}</b> wurde erfolgreich erneuert.", duration: 5000);
} else {
$this->dispatch('toast', type: 'error', badge: 'SSL',
title: 'Fehler', text: nl2br(htmlspecialchars(substr($out, 0, 300))), duration: 0);
}
}
private function restoreSslProvisioningState(): void
{
$doneFile = self::SSL_STATE_DIR . '/done';
if (! file_exists($doneFile)) return;
$this->sslDone = true;
$this->sslProvisioning = false;
foreach (['ui', 'mail', 'webmail'] as $key) {
$file = self::SSL_STATE_DIR . "/{$key}";
if (is_readable($file)) {
$this->sslProgress[$key] = trim((string) @file_get_contents($file));
}
}
}
private function loadCertificates(): array
{
$out = (string) @shell_exec('sudo -n /usr/bin/certbot certificates 2>&1');
if (empty(trim($out))) return [['_error' => 'unavailable']];
if (str_contains($out, 'No certificates found')) return [];
$certs = [];
$blocks = preg_split('/\n(?=\s*Certificate Name:)/m', $out);
foreach ($blocks as $block) {
if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue;
preg_match('/Domains:\s*(.+)/i', $block, $domainsM);
preg_match('/Expiry Date:\s*(.+)/i', $block, $expiryM);
preg_match('/Certificate Path:\s*(.+)/i', $block, $certM);
$expiryRaw = trim($expiryM[1] ?? '');
$daysLeft = null;
$expired = false;
$expiryDate = null;
// Datum extrahieren (Format: "2026-07-24 10:30:00+00:00 (VALID: 88 days)")
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $expiryRaw, $dateM)) {
$ts = strtotime($dateM[1]);
$expiryDate = $ts ? date('d.m.Y', $ts) : null;
}
if (preg_match('/VALID: (\d+) days/i', $expiryRaw, $dM)) {
$daysLeft = (int) $dM[1];
} elseif (preg_match('/INVALID/i', $expiryRaw)) {
$expired = true;
$daysLeft = 0;
}
$domainsRaw = trim($domainsM[1] ?? '');
$domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : [];
$certs[] = [
'name' => trim($nameM[1]),
'domains' => $domains,
'expiry_date' => $expiryDate,
'days_left' => $daysLeft,
'expired' => $expired,
'cert_path' => trim($certM[1] ?? ''),
];
}
usort($certs, fn($a, $b) => ($a['days_left'] ?? 999) <=> ($b['days_left'] ?? 999));
return $certs;
}
public function render()
{
return view('livewire.ui.security.ssl-certificates-table');

View File

@ -2,12 +2,133 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('TLS-Ciphers · Mailwolt')]
class TlsCiphersForm extends Component
{
public string $preset = 'intermediate';
public string $postfix_protocols = '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1';
public string $postfix_ciphers = 'medium';
public string $dovecot_min_proto = 'TLSv1.2';
public string $dovecot_ciphers = 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS';
private const PRESETS = [
'modern' => [
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2',
'postfix_ciphers' => 'high',
'dovecot_min_proto' => 'TLSv1.3',
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:!aNULL:!MD5',
],
'intermediate' => [
'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
'postfix_ciphers' => 'medium',
'dovecot_min_proto' => 'TLSv1.2',
'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS',
],
'old' => [
'postfix_protocols' => '!SSLv2, !SSLv3',
'postfix_ciphers' => 'low',
'dovecot_min_proto' => 'TLSv1',
'dovecot_ciphers' => 'HIGH:MEDIUM:!aNULL:!MD5',
],
];
public function mount(): void
{
$this->preset = Setting::get('tls.preset', 'intermediate');
$this->postfix_protocols = Setting::get('tls.postfix_protocols', self::PRESETS['intermediate']['postfix_protocols']);
$this->postfix_ciphers = Setting::get('tls.postfix_ciphers', self::PRESETS['intermediate']['postfix_ciphers']);
$this->dovecot_min_proto = Setting::get('tls.dovecot_min_proto', self::PRESETS['intermediate']['dovecot_min_proto']);
$this->dovecot_ciphers = Setting::get('tls.dovecot_ciphers', self::PRESETS['intermediate']['dovecot_ciphers']);
}
public function applyPreset(string $preset): void
{
if (!isset(self::PRESETS[$preset])) return;
$p = self::PRESETS[$preset];
$this->preset = $preset;
$this->postfix_protocols = $p['postfix_protocols'];
$this->postfix_ciphers = $p['postfix_ciphers'];
$this->dovecot_min_proto = $p['dovecot_min_proto'];
$this->dovecot_ciphers = $p['dovecot_ciphers'];
}
public function save(): void
{
$this->validate([
'postfix_protocols' => 'required|string|max:200',
'postfix_ciphers' => 'required|string|max:500',
'dovecot_min_proto' => 'required|string|max:50',
'dovecot_ciphers' => 'required|string|max:500',
]);
Setting::setMany([
'tls.preset' => $this->preset,
'tls.postfix_protocols' => $this->postfix_protocols,
'tls.postfix_ciphers' => $this->postfix_ciphers,
'tls.dovecot_min_proto' => $this->dovecot_min_proto,
'tls.dovecot_ciphers' => $this->dovecot_ciphers,
]);
try {
$this->writePostfixConfig();
$this->writeDovecotConfig();
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'postfix']);
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'dovecot']);
$this->dispatch('toast', type: 'done', badge: 'TLS',
title: 'TLS-Konfiguration übernommen',
text: 'Postfix und Dovecot wurden neu geladen.', duration: 5000);
} catch (\Throwable $e) {
$this->dispatch('toast', type: 'error', badge: 'TLS',
title: 'Fehler', text: $e->getMessage(), duration: 0);
}
}
private function writePostfixConfig(): void
{
$target = '/etc/postfix/tls.cf';
$content = "smtpd_tls_protocols = {$this->postfix_protocols}\n"
. "smtp_tls_protocols = {$this->postfix_protocols}\n"
. "smtpd_tls_ciphers = {$this->postfix_ciphers}\n"
. "smtp_tls_ciphers = {$this->postfix_ciphers}\n";
$this->tee($target, $content);
}
private function writeDovecotConfig(): void
{
$target = '/etc/dovecot/conf.d/99-tls.conf';
$content = "ssl_min_protocol = {$this->dovecot_min_proto}\n"
. "ssl_cipher_list = {$this->dovecot_ciphers}\n";
$this->tee($target, $content);
}
private function tee(string $target, string $content): void
{
$proc = proc_open(
sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)),
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen');
fwrite($pipes[0], $content);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed: {$target}");
}
public function render()
{
return view('livewire.ui.security.tls-ciphers-form');
return view('livewire.ui.security.tls-ciphers-form', ['presets' => array_keys(self::PRESETS)]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('API Keys · Mailwolt')]
class ApiKeyTable extends Component
{
public function deleteToken(int $id): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($id);
$token->delete();
$this->dispatch('notify', type: 'success', message: 'API Key gelöscht.');
}
public function render()
{
$tokens = Auth::user()
->tokens()
->latest()
->get();
return view('livewire.ui.system.api-key-table', compact('tokens'));
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Backups · Mailwolt')]
class BackupJobList extends Component
{
public function runNow(): void
{
$policy = BackupPolicy::first();
if (!$policy) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Kein Zeitplan', text: 'Bitte zuerst einen Backup-Zeitplan konfigurieren.', duration: 4000);
return;
}
// Reset stale jobs (stuck > 30 min with no running process)
BackupJob::whereIn('status', ['queued', 'running'])
->where('started_at', '<', now()->subMinutes(30))
->update(['status' => 'failed', 'finished_at' => now(), 'error' => 'Timeout — Prozess nicht mehr aktiv.']);
$running = BackupJob::whereIn('status', ['queued', 'running'])->exists();
if ($running) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Läuft bereits', text: 'Ein Backup-Job ist bereits aktiv.', duration: 3000);
return;
}
$job = BackupJob::create([
'policy_id' => $policy->id,
'status' => 'queued',
'started_at' => now(),
]);
$artisan = base_path('artisan');
exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &");
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $job->id]
);
}
public function openProgress(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $id]
);
}
public function openDeleteConfirm(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-delete-modal',
arguments: ['jobId' => $id]
);
}
public function openRestoreConfirm(int $id): void
{
$this->dispatch('openModal',
component: 'ui.system.modal.backup-restore-confirm-modal',
arguments: ['jobId' => $id]
);
}
#[On('backup-list-refresh')]
public function refresh(): void {}
#[On('backup:do-restore')]
public function onRestoreConfirmed(int $jobId): void
{
$this->restore($jobId);
}
public function restore(int $id): void
{
$sourceJob = BackupJob::findOrFail($id);
if (!$sourceJob->artifact_path || !file_exists($sourceJob->artifact_path)) {
$this->dispatch('toast', type: 'warn', badge: 'Backup',
title: 'Datei nicht gefunden', text: 'Das Backup-Archiv wurde nicht gefunden.', duration: 4000);
return;
}
$token = 'mailwolt_restore_' . uniqid();
$artisan = base_path('artisan');
exec("nohup php {$artisan} restore:run {$sourceJob->id} {$token} > /dev/null 2>&1 &");
$this->dispatch('openModal',
component: 'ui.system.modal.backup-progress-modal',
arguments: ['jobId' => $sourceJob->id, 'restoreToken' => $token]
);
}
public function delete(int $id): void
{
$job = BackupJob::findOrFail($id);
if ($job->artifact_path && file_exists($job->artifact_path)) {
@unlink($job->artifact_path);
}
$job->delete();
$this->dispatch('toast', type: 'done', badge: 'Backup',
title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000);
}
public function render()
{
$jobs = BackupJob::where('checksum', '!=', 'restore')->orWhereNull('checksum')->latest('started_at')->paginate(20);
$policy = BackupPolicy::first();
$hasRunning = BackupJob::whereIn('status', ['queued', 'running'])
->where('started_at', '>=', now()->subMinutes(30))
->exists();
return view('livewire.ui.system.backup-job-list', compact('jobs', 'policy', 'hasRunning'));
}
}

View File

@ -3,36 +3,748 @@
namespace App\Livewire\Ui\System;
use Livewire\Component;
use Illuminate\Support\Str;
use Carbon\Carbon;
class BackupStatusCard extends Component
{
public ?string $lastAt = null;
public ?string $lastSize = null;
public ?string $lastDuration = null;
// Anzeige-Felder (nur Ausgabe im Blade)
public ?string $lastAt = null; // "27.10.2025 18:27:35"
public ?string $lastSize = null; // "93.0 MB"
public ?string $lastDuration = null; // "11s" / "2m 03s"
public ?bool $ok = null;
public function mount(): void { $this->load(); }
public function render() { return view('livewire.ui.system.backup-status-card'); }
public function refresh(): void { $this->load(true); }
// Progress (nur wenn state=running)
public bool $running = false;
public int $percent = 0;
public ?string $step = null; // z.B. "compress"
protected string $statusFile = '/var/lib/mailwolt/backup.status';
public function mount(): void
{
$this->load();
}
public function render()
{
return view('livewire.ui.system.backup-status-card');
}
public function refresh(): void
{
$this->load(true);
}
public function runNow(): void
{
@shell_exec('nohup /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
$this->dispatch('toast', type:'info', title:'Backup gestartet');
// asynchron starten (sudoers vorausgesetzt)
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// Sofort UI auf „läuft“ setzen Poll holt Echtstatus
$this->running = true;
$this->percent = 1;
$this->step = 'start';
}
protected function load(bool $force=false): void
private function load(bool $force = false): void
{
// Example: parse a tiny status file your backup script writes.
$f = '/var/lib/mailwolt/backup.status';
if (is_file($f)) {
$lines = @file($f, FILE_IGNORE_NEW_LINES) ?: [];
foreach ($lines as $ln) {
if (str_starts_with($ln,'time=')) $this->lastAt = substr($ln,5);
if (str_starts_with($ln,'size=')) $this->lastSize = substr($ln,5);
if (str_starts_with($ln,'dur=')) $this->lastDuration = substr($ln,4);
if (str_starts_with($ln,'ok=')) $this->ok = (substr($ln,3) === '1');
}
$state = $this->readStatus();
// Progress
$this->running = ($state['state'] ?? null) === 'running';
$this->percent = (int)($state['percent'] ?? 0);
$this->step = $state['step'] ?? null;
// Abschlusswerte
$fin = $state['finished_at'] ?? $state['start_at'] ?? null;
$this->lastAt = $fin ? $this->fmtDate($fin) : null;
$sizeB = isset($state['size']) ? (int)$state['size'] : null;
$this->lastSize = $sizeB !== null ? $this->fmtBytes($sizeB) : null;
$durS = isset($state['duration']) ? (int)$state['duration'] : null;
$this->lastDuration = $durS !== null ? $this->fmtDuration($durS) : null;
$this->ok = isset($state['ok']) ? ((string)$state['ok'] === '1') : null;
// Wenn fertig → Balken aus
if (!$this->running) {
$this->percent = 0;
$this->step = null;
}
}
private function readStatus(): array
{
if (!is_readable($this->statusFile)) {
return [];
}
$out = [];
foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, '=')) continue;
[$k, $v] = array_map('trim', explode('=', $line, 2));
// nur erwartete Keys
if (in_array($k, ['state', 'pid', 'start_at', 'finished_at', 'step', 'percent', 'size', 'duration', 'ok'], true)) {
$out[$k] = $v;
}
}
return $out;
}
private function fmtDate(string $iso): string
{
// in App-Zeitzone anzeigen
$tz = config('app.timezone', 'UTC');
try {
return Carbon::parse($iso)->setTimezone($tz)->format('d.m.Y H:i:s');
} catch (\Throwable) {
return $iso;
}
}
private function fmtBytes(int $bytes): string
{
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$n = (float)$bytes;
while ($n >= 1024 && $i < count($u) - 1) {
$n /= 1024;
$i++;
}
return number_format($n, ($i <= 1 ? 0 : 1), ',', '.') . ' ' . $u[$i];
}
private function fmtDuration(int $sec): string
{
if ($sec < 60) return $sec . 's';
$m = intdiv($sec, 60);
$s = $sec % 60;
return sprintf('%dm %02ds', $m, $s);
}
}
//namespace App\Livewire\Ui\System;
//
//use Illuminate\Support\Str;
//use Livewire\Component;
//use Carbon\Carbon;
//
//class BackupStatusCard extends Component
//{
// // Anzeige-Felder (fertig formatiert)
// public ?string $lastAt = null;
// public ?string $lastSize = null;
// public ?string $lastDuration = null;
// public ?bool $ok = null;
//
// // Laufstatus für Progress (nur zur Sichtbarkeit)
// public bool $running = false;
// public int $percent = 0;
// public string $progressText = '';
//
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.system.backup-status-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function runNow(): void
// {
// // asynchron starten sudoers wie bereits gesetzt
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// // UI direkt "laufend" schalten; echte Werte kommen über Poll
// $this->running = true;
// $this->percent = 1;
// $this->progressText = 'Backup gestartet …';
// }
//
// protected function load(bool $force = false): void
// {
// $s = $this->readStatus();
//
// // Progress
// $state = $s['state'] ?? null;
// $this->running = in_array($state, ['running'], true);
// $this->percent = (int)($s['percent'] ?? 0);
// $step = $s['step'] ?? '';
// $this->progressText = $this->mapStepText($step, $state);
//
// // Abschlusswerte
// $finishedAt = $s['finished_at'] ?? ($state === 'done' ? ($s['start_at'] ?? null) : null);
// $sizeBytes = isset($s['size']) ? (int)$s['size'] : null;
// $durSec = isset($s['duration']) ? (int)$s['duration'] : null;
// $okStr = $s['ok'] ?? null;
//
// $this->lastAt = $finishedAt ? $this->fmtDate($finishedAt) : null;
// $this->lastSize = $sizeBytes !== null ? $this->fmtBytes($sizeBytes) : null;
// $this->lastDuration = $durSec !== null ? $this->fmtDuration($durSec) : null;
// $this->ok = $okStr !== null ? ($okStr === '1' || $okStr === 'true') : null;
// }
//
// protected function readStatus(): array
// {
// if (!is_readable($this->statusFile)) {
// return [];
// }
// $lines = @file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [];
// $out = [];
// foreach ($lines as $ln) {
// if (!str_contains($ln, '=')) continue;
// [$k, $v] = array_map('trim', explode('=', $ln, 2));
// $out[$k] = $v;
// }
// return $out;
// }
//
// private function mapStepText(string $step, ?string $state): string
// {
// if ($state === 'done') return 'Backup abgeschlossen.';
// if ($state === 'failed') return 'Backup fehlgeschlagen.';
// return match ($step) {
// 'start' => 'Backup wird vorbereitet …',
// 'mysqldump' => 'Datenbank wird gesichert …',
// 'maildir' => 'Maildir wird gesichert …',
// 'app' => 'Applikation wird gesichert …',
// 'configs' => 'Konfigurationen werden gesichert …',
// 'compress' => 'Archiv wird komprimiert …',
// 'retention' => 'Alte Backups werden aufgeräumt …',
// default => $step ? Str::headline($step) . ' …' : 'Backup läuft …',
// };
// }
//
// private function fmtDate(string $iso): string
// {
// return Carbon::parse($iso)->timezone(config('app.timezone', 'Europe/Berlin'))->format('d.m.Y H:i:s');
// }
//
// private function fmtBytes(int $b): string
// {
// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
// $i = 0;
// $val = $b;
// while ($val >= 1024 && $i < count($units) - 1) {
// $val /= 1024;
// $i++;
// }
// return number_format($val, $val >= 10 ? 0 : ($val >= 1 ? 1 : 0)) . ' ' . $units[$i];
// }
//
// private function fmtDuration(int $s): string
// {
// if ($s < 60) return $s . 's';
// $m = intdiv($s, 60);
// $r = $s % 60;
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
// $h = intdiv($m, 60);
// $m = $m % 60;
// return sprintf('%dh %02dm', $h, $m);
// }
//}
//namespace App\Livewire\Ui\System;
//
//use Carbon\Carbon;
//use Livewire\Component;
//
//class BackupStatusCard extends Component
//{
// public string $lastAt = '';
// public string $lastSize = '';
// public string $lastDuration = '';
// public string $statusText = 'unbekannt';
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
//
// public string $progressText = '';
// public string $progressPercent = '0';
// public string $progressVisibleClass = 'hidden'; // <- Sichtbarkeit
//
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void { $this->load(); }
// public function render() { return view('livewire.ui.system.backup-status-card'); }
// public function refresh(): void { $this->load(true); }
//
// public function runNow(): void
// {
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// // UI sofort auf "läuft" setzen
// $this->progressText = 'Vorbereitung läuft...';
// $this->progressPercent = '1';
// $this->progressVisibleClass = 'block';
// }
//
// protected function load(bool $force = false): void
// {
// $kv = [];
// if (is_file($this->statusFile)) {
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
// $p = strpos($ln, '='); if ($p !== false) $kv[substr($ln,0,$p)] = substr($ln,$p+1);
// }
// }
//
// $state = $kv['state'] ?? null; // running | done | failed
// $step = $kv['step'] ?? null;
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
//
// // Anzeigeformatierungen wie gehabt …
// // (deine bestehenden formatBytes/formatDuration/Timezone-Logik)
//
// $this->progressPercent = (string)max(0, min(100, $percent));
// $this->progressText = $this->mapStep($step);
//
// // Sichtbarkeit steuern KEINE Blade-Logik nötig
// if ($state === 'running') {
// $this->progressVisibleClass = 'block';
// } else {
// // bei done/failed: Balken verstecken und auf 100% / finalen Text setzen
// $this->progressVisibleClass = 'hidden';
// if ($percent >= 100 || $step === 'done') {
// $this->progressPercent = '100';
// $this->progressText = 'Backup abgeschlossen.';
// }
// }
//
// // Status-Badge (wie gehabt)
// if ($ok === true) {
// $this->statusText = 'erfolgreich';
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
// } elseif ($ok === false) {
// $this->statusText = 'fehlgeschlagen';
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
// } else {
// $this->statusText = 'unbekannt';
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
// }
// }
//
// private function mapStep(?string $step): string
// {
// return match($step) {
// 'mysqldump' => 'Datenbank wird gesichert...',
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
// 'app' => 'Anwendungsdaten werden gesichert...',
// 'configs' => 'Konfigurationen werden gesichert...',
// 'compress' => 'Backup wird komprimiert...',
// 'retention' => 'Alte Backups werden gelöscht...',
// 'done' => 'Backup abgeschlossen.',
// default => 'Vorbereitung läuft...',
// };
// }
//}
//
//class BackupStatusCard extends Component
//{
// public string $lastAt = '';
// public string $lastSize = '';
// public string $lastDuration = '';
// public string $statusText = 'unbekannt';
// public string $statusColor = 'text-white/60 border-white/20 bg-white/5';
// public string $progressText = '';
// public string $progressPercent = '0';
// public bool $running = false;
// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//
// public function mount(): void
// {
// $this->load();
// }
//
// public function render()
// {
// return view('livewire.ui.system.backup-status-card');
// }
//
// public function refresh(): void
// {
// $this->load(true);
// }
//
// public function runNow(): void
// {
// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
// $this->running = true;
// $this->progressText = 'Starte Backup...';
// $this->progressPercent = '1';
// }
//
// protected function load(bool $force = false): void
// {
// if (!is_file($this->statusFile)) {
// $this->running = false;
// return;
// }
//
// $kv = [];
// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
// $p = strpos($ln, '=');
// if ($p !== false) {
// $kv[substr($ln, 0, $p)] = substr($ln, $p + 1);
// }
// }
//
// $state = $kv['state'] ?? null;
// $step = $kv['step'] ?? null;
// $percent = isset($kv['percent']) ? (int)$kv['percent'] : 0;
// $ok = isset($kv['ok']) ? ((int)$kv['ok'] === 1) : null;
//
// // Formatierung
// $tz = config('app.timezone', 'Europe/Berlin');
// $finished = $kv['finished_at'] ?? $kv['start_at'] ?? null;
// $this->lastAt = $finished
// ? Carbon::parse($finished)->setTimezone($tz)->format('d.m.Y H:i:s')
// : '';
//
// $this->lastSize = isset($kv['size'])
// ? $this->formatBytes((int)$kv['size'])
// : '';
//
// $this->lastDuration = isset($kv['duration'])
// ? $this->formatDuration((int)$kv['duration'])
// : '';
//
// // Fortschritt
// $this->progressPercent = (string)$percent;
// $this->progressText = $this->mapStep($step);
//
// // Status
// $this->running = ($state === 'running');
//
// if ($ok === true) {
// $this->statusText = 'erfolgreich';
// $this->statusColor = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
// } elseif ($ok === false) {
// $this->statusText = 'fehlgeschlagen';
// $this->statusColor = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
// } else {
// $this->statusText = 'unbekannt';
// $this->statusColor = 'text-white/60 border-white/20 bg-white/5';
// }
// }
//
// private function formatBytes(int $b): string
// {
// if ($b >= 1024 * 1024 * 1024) return number_format($b / (1024 * 1024 * 1024), 1) . ' GB';
// if ($b >= 1024 * 1024) return number_format($b / (1024 * 1024), 1) . ' MB';
// if ($b >= 1024) return number_format($b / 1024, 0) . ' KB';
// return $b . ' B';
// }
//
// private function formatDuration(int $s): string
// {
// if ($s < 60) return $s . 's';
// $m = intdiv($s, 60);
// $r = $s % 60;
// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
// $h = intdiv($m, 60);
// $m = $m % 60;
// return sprintf('%dh %02dm %02ds', $h, $m, $r);
// }
//
// private function mapStep(?string $step): string
// {
// return match ($step) {
// 'mysqldump' => 'Datenbank wird gesichert...',
// 'maildir' => 'Mail-Verzeichnis wird archiviert...',
// 'app' => 'Anwendungsdaten werden gesichert...',
// 'configs' => 'Konfigurationen werden gesichert...',
// 'compress' => 'Backup wird komprimiert...',
// 'retention' => 'Alte Backups werden gelöscht...',
// 'done' => 'Backup abgeschlossen.',
// default => 'Vorbereitung läuft...',
// };
// }
//}
//
////
////
////namespace App\Livewire\Ui\System;
////
////use Carbon\CarbonImmutable;
////use Livewire\Component;
////
////class BackupStatusCard extends Component
////{
//// public ?string $lastAt = null; // finale Zeit
//// public ?string $lastSize = null; // menschenlesbar
//// public ?string $lastDuration = null; // menschenlesbar
//// public ?bool $ok = null;
////
//// // Live-Status
//// public bool $running = false;
//// public ?string $step = null;
//// public int $percent = 0;
////
//// public function mount(): void
//// {
//// $this->load();
//// }
////
//// public function render()
//// {
//// return view('livewire.ui.system.backup-status-card');
//// }
////
//// public function refresh(): void
//// {
//// $this->load(true);
//// }
////
//// public function runNow(): void
//// {
//// // Script asynchron starten (sudoers muss gesetzt sein)
//// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
//// // Sofort UI auf "läuft" stellen Poll holt echten Status nach
//// $this->running = true;
//// $this->step = 'start';
//// $this->percent = 1;
//// }
////
//// protected function load(bool $force = false): void
//// {
//// $f = '/var/lib/mailwolt/backup.status';
//// if (!is_file($f)) {
//// return;
//// }
////
//// $data = [];
//// foreach (@file($f, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
//// if (strpos($ln, '=') !== false) {
//// [$k, $v] = explode('=', $ln, 2);
//// $data[$k] = $v;
//// }
//// }
////
//// $state = $data['state'] ?? null;
//// $this->running = ($state === 'running');
////
//// // Progress
//// $this->step = $data['step'] ?? null;
//// $this->percent = (int)($data['percent'] ?? 0);
////
//// // Finale Werte
//// if ($state === 'done' || $state === 'failed') {
//// $this->ok = ($data['ok'] ?? '') === '1';
//// $ts = $data['finished_at'] ?? $data['start_at'] ?? null;
//// $this->lastAt = $ts ? $this->fmtTs($ts) : null;
////
//// $bytes = (int)($data['size'] ?? 0);
//// $this->lastSize = $bytes ? $this->fmtBytes($bytes) : null;
////
//// $dur = (int)($data['duration'] ?? 0);
//// $this->lastDuration = $dur ? $this->fmtDuration($dur) : null;
//// }
//// }
////
//// protected function fmtTs(string $iso): string
//// {
//// return CarbonImmutable::parse($iso)->tz(config('app.timezone'))
//// ->format('d.m.Y H:i:s');
//// }
////
//// protected function fmtBytes(int $b): string
//// {
//// $u = ['B', 'KB', 'MB', 'GB', 'TB'];
//// $i = 0;
//// while ($b >= 1024 && $i < count($u) - 1) {
//// $b /= 1024;
//// $i++;
//// }
//// return sprintf('%.1f %s', $b, $u[$i]);
//// }
////
//// protected function fmtDuration(int $s): string
//// {
//// if ($s < 60) return $s . 's';
//// $m = intdiv($s, 60);
//// $r = $s % 60;
//// if ($m < 60) return sprintf('%dm %02ds', $m, $r);
//// $h = intdiv($m, 60);
//// $m %= 60;
//// return sprintf('%dh %02dm', $h, $m);
//// }
////}
////
//////
//////namespace App\Livewire\Ui\System;
//////
//////use Carbon\CarbonImmutable;
//////use Illuminate\Support\Str;
//////use Livewire\Component;
//////
//////class BackupStatusCard extends Component
//////{
////// public ?string $lastAt = null; // formatierte Zeit
////// public ?string $lastSize = null; // human readable
////// public ?string $lastDuration = null; // human readable
////// public ?bool $ok = null;
//////
////// // Laufzeit/Progress
////// public string $state = 'idle'; // idle|running|done|error
////// public string $step = ''; // aktueller Schritt
////// public array $steps = [
////// 'mysqldump' => 'Datenbank sichern',
////// 'maildir' => 'Maildir kopieren',
////// 'app' => 'App sichern',
////// 'configs' => 'Configs sichern',
////// 'archive' => 'Archiv erstellen',
////// 'compress' => 'Komprimieren',
////// 'retention' => 'Aufräumen',
////// 'finish' => 'Abschluss',
////// ];
//////
////// protected string $statusFile = '/var/lib/mailwolt/backup.status';
//////
////// public function mount(): void
////// {
////// $this->load(true);
////// }
//////
////// public function render()
////// {
////// return view('livewire.ui.system.backup-status-card');
////// }
//////
////// public function refresh(): void
////// {
////// $this->load(true);
////// }
//////
////// public function runNow(): void
////// {
////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
////// // Sofort in "running" gehen Poll übernimmt dann
////// $this->state = 'running';
////// $this->step = 'mysqldump';
////// }
//////
////// public function load(bool $force = false): void
////// {
////// $raw = $this->readStatus();
////// $this->state = $raw['state'] ?? 'idle';
////// $this->step = $raw['step'] ?? '';
//////
////// // Datum/Zeit hübsch
////// if (!empty($raw['time'])) {
////// $this->lastAt = $this->fmtTime($raw['time']);
////// } else {
////// $this->lastAt = null;
////// }
//////
////// // Größe/Dauer hübsch
////// $bytes = isset($raw['size_bytes']) ? (int)$raw['size_bytes'] : null;
////// $secs = isset($raw['dur_seconds']) ? (int)$raw['dur_seconds'] : null;
//////
////// $this->lastSize = $bytes !== null ? $this->humanBytes($bytes) : null;
////// $this->lastDuration = $secs !== null ? $this->humanDuration($secs) : null;
////// $this->ok = isset($raw['ok']) ? ((string)$raw['ok'] === '1') : null;
////// }
//////
////// protected function readStatus(): array
////// {
////// if (!is_file($this->statusFile)) return [];
////// $out = [];
////// foreach (@file($this->statusFile, FILE_IGNORE_NEW_LINES) ?: [] as $ln) {
////// if (!str_contains($ln, '=')) continue;
////// [$k, $v] = array_map('trim', explode('=', $ln, 2));
////// $out[$k] = $v;
////// }
//////
////// // Backward compatibility (alte Keys)
////// if (isset($out['size']) && !isset($out['size_bytes'])) {
////// $out['size_bytes'] = (int)$out['size'];
////// }
////// if (isset($out['dur']) && !isset($out['dur_seconds'])) {
////// $out['dur_seconds'] = (int)$out['dur'];
////// }
//////
////// return $out;
////// }
//////
////// protected function fmtTime(string $iso): string
////// {
////// try {
////// $tz = config('app.timezone', 'UTC');
////// // ISO aus Script ist idealerweise UTC (Z)
////// $dt = CarbonImmutable::parse($iso)->timezone($tz);
////// // z.B. 27.10.2025, 16:48:02 (CET)
////// return $dt->isoFormat('L, LTS') . ' ' . $dt->format('T');
////// } catch (\Throwable) {
////// return $iso;
////// }
////// }
//////
////// protected function humanBytes(int $bytes): string
////// {
////// $units = ['B', 'KB', 'MB', 'GB', 'TB'];
////// $i = 0;
////// while ($bytes >= 1024 && $i < count($units) - 1) {
////// $bytes /= 1024;
////// $i++;
////// }
////// return number_format($bytes, $i === 0 ? 0 : 1, ',', '.') . ' ' . $units[$i];
////// }
//////
////// protected function humanDuration(int $secs): string
////// {
////// if ($secs < 60) return $secs . ' s';
////// $m = intdiv($secs, 60);
////// $s = $secs % 60;
////// if ($m < 60) return sprintf('%d min %02d s', $m, $s);
////// $h = intdiv($m, 60);
////// $m = $m % 60;
////// return sprintf('%d h %02d min', $h, $m);
////// }
//////}
//////
////////
////////namespace App\Livewire\Ui\System;
////////
////////use Livewire\Component;
////////
////////class BackupStatusCard extends Component
////////{
//////// public ?string $lastAt = null;
//////// public ?string $lastSize = null;
//////// public ?string $lastDuration = null;
//////// public ?bool $ok = null;
////////
//////// public function mount(): void { $this->load(); }
//////// public function render() { return view('livewire.ui.system.backup-status-card'); }
//////// public function refresh(): void { $this->load(true); }
////////
//////// public function runNow(): void
//////// {
//////// @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
////////// $this->dispatch('toast', type:'info', title:'Backup gestartet');
//////// }
////////
//////// protected function load(bool $force=false): void
//////// {
//////// // Example: parse a tiny status file your backup script writes.
//////// $f = '/var/lib/mailwolt/backup.status';
//////// if (is_file($f)) {
//////// $lines = @file($f, FILE_IGNORE_NEW_LINES) ?: [];
//////// foreach ($lines as $ln) {
//////// if (str_starts_with($ln,'time=')) $this->lastAt = substr($ln,5);
//////// if (str_starts_with($ln,'size=')) $this->lastSize = substr($ln,5);
//////// if (str_starts_with($ln,'dur=')) $this->lastDuration = substr($ln,4);
//////// if (str_starts_with($ln,'ok=')) $this->ok = (substr($ln,3) === '1');
//////// }
//////// }
//////// }
////////}

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Ui\System;
use DateTimeImmutable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Livewire\Component;
@ -10,12 +11,14 @@ class DomainsSslForm extends Component
{
/* ========= Basis & Hosts ========= */
public string $base_domain = 'example.com';
public string $base_domain = '';
// nur Subdomain-Teile (ohne Punkte/Protokoll)
public string $ui_sub = 'mail';
public string $webmail_sub = 'webmail';
public string $mta_sub = 'mx';
public string $ui_sub = '';
public string $webmail_sub = '';
public string $mta_sub = '';
public bool $domainsSaving = false;
/* ========= TLS / Redirect ========= */
public bool $force_https = true;
@ -87,9 +90,9 @@ class DomainsSslForm extends Component
{
return [
'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'],
'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'],
'ui_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'webmail_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'mta_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'],
'force_https' => ['boolean'],
'hsts' => ['boolean'],
@ -124,6 +127,16 @@ class DomainsSslForm extends Component
}
public function mount(): void
{
$this->base_domain = (string) config('clubird.domain.base', '');
$this->ui_sub = (string) config('clubird.domain.ui', '');
$this->webmail_sub = (string) config('clubird.domain.webmail', '');
$this->mta_sub = (string) config('clubird.domain.mail', '');
$this->loadMtaStsFromFileIfPossible();
}
protected function loadMtaStsFromFileIfPossible(): void
{
$file = public_path('.well-known/mta-sts.txt');
@ -154,9 +167,52 @@ class DomainsSslForm extends Component
public function saveDomains(): void
{
$this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']);
// TODO: persist
$this->dispatch('toast', body: 'Domains gespeichert.');
$this->validate(['base_domain', 'ui_sub', 'webmail_sub', 'mta_sub']);
$this->domainsSaving = true;
$wmHost = $this->webmail_sub ? $this->webmail_sub.'.'.$this->base_domain : '';
$this->writeEnv([
'BASE_DOMAIN' => $this->base_domain,
'UI_SUB' => $this->ui_sub,
'WEBMAIL_SUB' => $this->webmail_sub,
'MTA_SUB' => $this->mta_sub,
'WEBMAIL_DOMAIN' => $wmHost,
]);
Artisan::call('config:clear');
Artisan::call('route:clear');
$this->domainsSaving = false;
$this->dispatch('toast',
type: 'done',
badge: 'Domains',
title: 'Einstellungen gespeichert',
text: 'Konfiguration wurde übernommen.',
duration: 4000,
);
}
private function writeEnv(array $values): void
{
$path = base_path('.env');
$content = file_get_contents($path);
foreach ($values as $key => $value) {
$escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value);
$line = $key . '=' . $escaped;
$pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m';
if (preg_match($pattern, $content)) {
$content = preg_replace($pattern, $line, $content);
} else {
$content .= "\n{$line}";
}
}
file_put_contents($path, $content);
}
public function saveTls(): void

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class DomainsSslForm extends Component
{
// fix / readonly aus ENV oder config
public string $mail_domain_readonly = '';
// editierbar
public string $ui_domain = '';
public string $webmail_domain = '';
protected function rules(): array
{
return [
'ui_domain' => 'nullable|string|max:190',
'webmail_domain' => 'nullable|string|max:190',
];
}
public function mount(): void
{
$this->mail_domain_readonly = (string) config('clubird.domain.mail', 'mx');
$this->ui_domain = Setting::get('ui_domain', $this->ui_domain);
$this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain);
}
public function save(): void
{
$this->validate();
Setting::put('ui_domain', $this->ui_domain);
Setting::put('webmail_domain', $this->webmail_domain);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Domains gespeichert',
text: 'UI- und Webmail-Domain wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.domains-ssl-form'); }
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class GeneralForm extends Component
{
public string $locale = 'de';
public string $timezone = 'Europe/Berlin';
protected function rules(): array
{
return [
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
];
}
public function mount(): void
{
// Defaults aus ENV nur für den allerersten Seed in Settings (Redis/DB)
$envLocale = env('APP_LOCALE') ?? env('APP_FALLBACK_LOCALE') ?? $this->locale;
$envTimezone = env('APP_TIMEZONE') ?? $this->timezone;
// Wenn (noch) nichts in Settings liegt, einmalig mit ENV-Werten befüllen
if (Setting::get('locale', null) === null) {
Setting::set('locale', $envLocale);
}
if (Setting::get('timezone', null) === null) {
Setting::set('timezone', $envTimezone);
}
// Ab hier ausschließlich aus Settings lesen (Redis → DB Fallback)
$this->locale = (string) Setting::get('locale', $envLocale);
$this->timezone = (string) Setting::get('timezone', $envTimezone);
// Sofort für die aktuelle Request anwenden
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale,
'app.timezone' => $this->timezone,
]);
}
public function save(): void
{
$this->validate();
// Persistieren: DB → Redis (siehe Setting::set)
Setting::set('locale', $this->locale);
Setting::set('timezone', $this->timezone);
// Direkt in der laufenden Request aktivieren
app()->setLocale($this->locale);
@date_default_timezone_set($this->timezone);
config([
'app.locale' => $this->locale,
'app.fallback_locale' => $this->locale, // optional
'app.timezone' => $this->timezone,
]);
$this->dispatch('toast',
type: 'done',
badge: 'System',
title: 'Allgemein gespeichert',
text: 'Sprache und Zeitzone wurden übernommen.',
duration: 5000,
);
}
public function render()
{
return view('livewire.ui.system.form.general-form');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Ui\System\Form;
use App\Models\Setting;
use Livewire\Component;
class SecurityForm extends Component
{
public bool $twofa_enabled = false;
public ?int $rate_limit = 5;
public ?int $password_min = 10;
protected function rules(): array
{
return [
'twofa_enabled' => 'boolean',
'rate_limit' => 'nullable|integer|min:1|max:100',
'password_min' => 'nullable|integer|min:6|max:128',
];
}
public function mount(): void
{
$this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled);
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
}
public function save(): void
{
$this->validate();
Setting::put('twofa_enabled', $this->twofa_enabled);
Setting::put('rate_limit', $this->rate_limit);
Setting::put('password_min', $this->password_min);
$this->dispatch('toast',
type: 'done',
badge: 'Sicherheit',
title: 'Sicherheit gespeichert',
text: '2FA/Rate-Limits/Passwortregeln wurden übernommen.',
duration: 5000,
);
}
public function render() { return view('livewire.ui.system.form.security-form'); }
}

View File

@ -0,0 +1,223 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Installer · Mailwolt')]
class InstallerPage extends Component
{
/* ===== Run state ===== */
public string $state = 'idle'; // idle | running
public bool $running = false;
public ?int $rc = null;
public ?string $lowState = null;
public array $logLines = [];
public int $progressPct = 0;
public string $component = 'all';
public bool $postActionsDone = false;
/* ===== Component status ===== */
public array $componentStatus = [];
private const STATE_DIR = '/var/lib/mailwolt/install';
private const INSTALL_LOG = '/var/log/mailwolt-install.log';
private const COMPONENTS = [
'nginx' => ['label' => 'Nginx', 'service' => 'nginx'],
'postfix' => ['label' => 'Postfix', 'service' => 'postfix'],
'dovecot' => ['label' => 'Dovecot', 'service' => 'dovecot'],
'rspamd' => ['label' => 'Rspamd', 'service' => 'rspamd'],
'fail2ban' => ['label' => 'Fail2ban', 'service' => 'fail2ban'],
'certbot' => ['label' => 'Certbot', 'service' => null, 'binary' => 'certbot'],
];
/* ========================================================= */
public function mount(): void
{
$this->refreshLowLevelState();
$this->readLogLines();
if ($this->running) {
$this->state = 'running';
}
$this->checkComponentStatus();
$this->recalcProgress();
}
public function render()
{
return view('livewire.ui.system.installer-page');
}
/* ================== Aktionen ================== */
public function openConfirmModal(string $component = 'all'): void
{
$this->component = $component;
$this->dispatch('openModal',
component: 'ui.system.modal.installer-confirm-modal',
arguments: ['component' => $component]
);
}
public function runInstaller(string $component = 'all'): void
{
if ($this->running || $this->state === 'running') {
$this->dispatch('toast', type: 'warn', badge: 'Installer',
title: 'Läuft bereits',
text: 'Ein Installer-Prozess ist bereits aktiv.',
duration: 3000);
return;
}
$this->component = $component;
$this->state = 'running';
$this->running = true;
$this->rc = null;
$this->postActionsDone = false;
$this->logLines = ['Installer gestartet …'];
$this->progressPct = 5;
$safeComponent = preg_replace('/[^a-z0-9_-]/i', '', $component);
@shell_exec("nohup sudo -n /usr/local/sbin/mailwolt-install {$safeComponent} >/dev/null 2>&1 &");
}
public function pollStatus(): void
{
$this->refreshLowLevelState();
$this->readLogLines();
$this->recalcProgress();
if ($this->rc !== null) {
$this->running = false;
}
if ($this->lowState === 'done') {
usleep(300_000);
$this->readLogLines();
$this->progressPct = 100;
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan db:seed --class="Database\\\\Seeders\\\\SystemDomainSeeder" --force >/dev/null 2>&1 &');
$this->postActionsDone = true;
$this->dispatch('toast', type: 'done', badge: 'Installer',
title: 'Installation abgeschlossen',
text: 'Die Komponente wurde erfolgreich installiert/konfiguriert.',
duration: 6000);
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
$this->dispatch('toast', type: 'error', badge: 'Installer',
title: 'Installation fehlgeschlagen',
text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.",
duration: 0);
}
$this->state = 'idle';
$this->checkComponentStatus();
}
}
public function checkComponentStatus(): void
{
$statuses = [];
foreach (self::COMPONENTS as $key => $info) {
$installed = false;
$active = false;
// Check if binary exists
$binary = $info['binary'] ?? $key;
$which = @trim(@shell_exec("which {$binary} 2>/dev/null") ?: '');
$installed = $which !== '';
// Check service active state
if ($installed && isset($info['service']) && $info['service']) {
$svcState = @trim(@shell_exec("systemctl is-active {$info['service']} 2>/dev/null") ?: '');
$active = ($svcState === 'active');
} elseif ($installed && $key === 'certbot') {
$active = true; // certbot is a one-shot tool, if installed it's "OK"
}
$statuses[$key] = [
'label' => $info['label'],
'installed' => $installed,
'active' => $active,
];
}
$this->componentStatus = $statuses;
}
public function clearLog(): void
{
@file_put_contents(self::INSTALL_LOG, '');
$this->logLines = [];
$this->dispatch('toast', type: 'done', badge: 'Installer',
title: 'Log geleert', text: '', duration: 2500);
}
/* ================== Helpers ================== */
protected function refreshLowLevelState(): void
{
$state = @trim(@file_get_contents(self::STATE_DIR . '/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
$this->running = ($this->lowState !== 'done');
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
}
protected function readLogLines(): void
{
$p = self::INSTALL_LOG;
if (!is_readable($p)) {
$this->logLines = [];
return;
}
$lines = @file($p, FILE_IGNORE_NEW_LINES) ?: [];
$this->logLines = array_slice($lines, -100);
}
protected function recalcProgress(): void
{
if ($this->state !== 'running' && $this->lowState !== 'running') {
if ($this->lowState === 'done') {
$this->progressPct = 100;
}
return;
}
$text = implode("\n", $this->logLines);
$pct = 5;
foreach ([
'Installation gestartet' => 10,
'Nginx' => 20,
'Postfix' => 35,
'Dovecot' => 50,
'Rspamd' => 65,
'Fail2ban' => 78,
'SSL' => 88,
'Installation beendet' => 100,
] as $needle => $val) {
if (stripos($text, $needle) !== false) {
$pct = max($pct, $val);
}
}
if ($this->lowState === 'done') {
$pct = 100;
}
$this->progressPct = $pct;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyCreateModal extends ModalComponent
{
public string $name = '';
public bool $sandbox = false;
public array $selected = [];
public static array $availableScopes = [
'mailboxes:read' => 'Mailboxen lesen',
'mailboxes:write' => 'Mailboxen schreiben',
'aliases:read' => 'Aliases lesen',
'aliases:write' => 'Aliases schreiben',
'domains:read' => 'Domains lesen',
'domains:write' => 'Domains schreiben',
];
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
public function create(): void
{
$this->validate([
'name' => 'required|string|max:80',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(self::$availableScopes)),
], [
'selected.required' => 'Bitte mindestens einen Scope auswählen.',
'selected.min' => 'Bitte mindestens einen Scope auswählen.',
]);
$token = Auth::user()->createToken($this->name, $this->selected);
$pat = $token->accessToken;
if ($this->sandbox) {
$pat->sandbox = true;
$pat->save();
}
$this->dispatch('token-created', plainText: $token->plainTextToken);
$this->dispatch('openModal',
component: 'ui.system.modal.api-key-show-modal',
arguments: ['plainText' => $token->plainTextToken],
);
}
public function toggleAll(): void
{
if (count($this->selected) === count(self::$availableScopes)) {
$this->selected = [];
} else {
$this->selected = array_keys(self::$availableScopes);
}
}
public function render()
{
return view('livewire.ui.system.modal.api-key-create-modal', [
'scopes' => self::$availableScopes,
]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyDeleteModal extends ModalComponent
{
public int $tokenId;
public string $tokenName = '';
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($tokenId);
$this->tokenId = $tokenId;
$this->tokenName = $token->name;
}
public function delete(): void
{
PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($this->tokenId)
->delete();
$this->dispatch('toast', type: 'done', badge: 'API Key',
title: 'Gelöscht', text: "Key <b>{$this->tokenName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('token-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.api-key-delete-modal');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyScopesModal extends ModalComponent
{
public string $tokenName = '';
public array $scopes = [];
public function mount(int $tokenId): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->where('tokenable_type', Auth::user()::class)
->findOrFail($tokenId);
$this->tokenName = $token->name;
$this->scopes = $token->abilities;
}
public function render()
{
return view('livewire.ui.system.modal.api-key-scopes-modal');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class ApiKeyShowModal extends ModalComponent
{
public string $plainText = '';
public function mount(string $plainText): void
{
$this->plainText = $plainText;
}
public static function closeModalOnClickAway(): bool { return false; }
public static function closeModalOnEscape(): bool { return false; }
public static function closeModalOnEscapeIsForceful(): bool { return false; }
public function dismiss(): void
{
$this->forceClose()->closeModal();
}
public static function modalMaxWidth(): string
{
return '2xl';
}
public function render()
{
return view('livewire.ui.system.modal.api-key-show-modal');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class BackupDeleteModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->jobId = $job->id;
$this->filename = $job->artifact_path ? basename($job->artifact_path) : '—';
}
#[On('backup:confirm-delete')]
public function delete(): void
{
$job = BackupJob::find($this->jobId);
if ($job) {
if ($job->artifact_path && file_exists($job->artifact_path)) {
@unlink($job->artifact_path);
}
$job->delete();
}
$this->dispatch('backup-list-refresh');
$this->dispatch('toast', type: 'done', badge: 'Backup',
title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000);
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.backup-delete-modal');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupProgressModal extends ModalComponent
{
public int $jobId;
public string $restoreToken = '';
public bool $notifiedDone = false;
public static function modalMaxWidth(): string { return 'md'; }
public function mount(int $jobId, string $restoreToken = ''): void
{
$this->jobId = $jobId;
$this->restoreToken = $restoreToken;
}
public function getJobProperty(): ?BackupJob
{
return BackupJob::find($this->jobId);
}
public function getRestoreStatusProperty(): array
{
if (empty($this->restoreToken)) {
return ['status' => 'unknown', 'log' => ''];
}
$file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json';
if (!file_exists($file)) {
return ['status' => 'queued', 'log' => 'Wartend auf Start…'];
}
$data = json_decode(file_get_contents($file), true);
return $data ?: ['status' => 'unknown', 'log' => ''];
}
public function close(): void
{
// Clean up status file for restore jobs
if ($this->restoreToken) {
$file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json';
@unlink($file);
}
$this->closeModal();
}
public function render()
{
if (!$this->notifiedDone) {
$status = empty($this->restoreToken)
? ($this->job?->status ?? 'queued')
: ($this->restoreStatus['status'] ?? 'queued');
if (in_array($status, ['ok', 'failed', 'canceled'])) {
$this->notifiedDone = true;
$this->dispatch('backup-list-refresh');
}
}
return view('livewire.ui.system.modal.backup-progress-modal');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupRestoreConfirmModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public string $startedAt = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->jobId = $job->id;
$this->filename = $job->artifact_path ? basename($job->artifact_path) : '—';
$this->startedAt = $job->started_at?->format('d.m.Y H:i') ?? '—';
}
public function confirm(): void
{
$this->closeModal();
// Trigger restore in the parent list component via event
$this->dispatch('backup:do-restore', jobId: $this->jobId);
}
public function render()
{
return view('livewire.ui.system.modal.backup-restore-confirm-modal');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class InstallerConfirmModal extends ModalComponent
{
public string $component = 'all';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(string $component = 'all'): void
{
$this->component = $component;
}
public function confirm(): void
{
$this->closeModal();
$this->dispatch('installer:run', component: $this->component);
}
public function render()
{
return view('livewire.ui.system.modal.installer-confirm-modal');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class SslProvisionModal extends ModalComponent
{
public static function modalMaxWidth(): string { return 'sm'; }
public function confirm(): void
{
$this->closeModal();
$this->dispatch('ssl:provision');
}
public function render()
{
return view('livewire.ui.system.modal.ssl-provision-modal');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class TotpSetupModal extends ModalComponent
{
public string $step = 'scan'; // scan → verify → codes
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$totp = app(TotpService::class);
$this->secret = $totp->generateSecret();
$this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret);
}
public function verify(): void
{
$this->validate(['code' => 'required|digits:6']);
$totp = app(TotpService::class);
if (!$totp->verify($this->secret, $this->code)) {
$this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.');
return;
}
$this->recoveryCodes = $totp->enable(Auth::user(), $this->secret);
$this->step = 'codes';
}
public function done(): void
{
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP aktiviert',
text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000);
$this->dispatch('2fa-status-changed');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.totp-setup-modal');
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
use Illuminate\Support\Str;
class UpdateModal extends ModalComponent
{
public string $state = 'unknown'; // running|done|unknown
public ?int $rc = null; // exit code
public ?string $line = null; // letzte Logzeile hübsch
public array $tail = []; // letzte N Logzeilen roh
public int $percent = 0; // heuristisch (optional)
public static bool $closingAllowed = false;
private const LOG = '/var/log/mailwolt-update.log';
private const STATE_DIR = '/var/lib/mailwolt/update';
public function mount(): void
{
$this->refresh();
}
public function render()
{
return view('livewire.ui.system.modal.update-modal');
}
#[On('update:modal-refresh')]
public function refresh(): void
{
$st = @trim(@file_get_contents(self::STATE_DIR.'/state') ?: '');
$rcRaw = @trim(@file_get_contents(self::STATE_DIR.'/rc') ?: '');
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
// Nur als "done" gelten wenn [DONE]-Marker im Log steht —
// verhindert dass das Modal fertig zeigt während das Script noch läuft
$logDone = in_array('[DONE]', array_map('trim', $lines), true);
if ($st === 'done' && !$logDone) {
$st = 'running'; // Noch warten bis Log vollständig
}
$this->state = $st ?: 'unknown';
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
$this->tail = array_slice(
array_filter($lines, fn($l) => trim($l) !== '[DONE]'),
-30
);
$last = trim($this->tail ? end($this->tail) : '');
$last = preg_replace('/^\[\w\]\s*/', '', $last);
$last = preg_replace('/^=+ .*? =+\s*$/', 'Update beendet', $last);
$last = preg_replace('/^\d{4}-\d{2}-\d{2}T[^ ]+\s*::\s*/', '', $last);
$this->line = Str::limit($last, 160);
// ganz simple Fortschritts-Heuristik über bekannte Meilensteine
$text = implode("\n", $this->tail);
$pct = 5;
foreach ([
'Update gestartet' => 10,
'Composer' => 25,
'npm ci' => 40,
'npm run build' => 60,
'migrate' => 75,
'optimize' => 85,
'Version aktualisiert' => 95,
'Update beendet' => 100,
] as $needle => $val) {
if (stripos($text, $needle) !== false) { $pct = max($pct, $val); }
}
if ($this->state === 'done') { $pct = 100; }
$this->percent = $pct;
// Auto-Close vorbereiten
if ($this->state === 'done' && $this->rc === 0) {
static::$closingAllowed = true;
}
}
public static function modalMaxWidth(): string { return '2xl'; }
public static function closeModalOnEscape(): bool { return static::$closingAllowed; }
public static function closeModalOnClickAway(): bool { return static::$closingAllowed; }
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserCreateModal extends ModalComponent
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = Role::Operator->value;
public bool $is_active = true;
protected function rules(): array
{
return [
'name' => 'required|string|max:100|unique:users,name',
'email' => 'required|email|max:190|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|in:' . implode(',', Role::values()),
];
}
protected function messages(): array
{
return [
'name.unique' => 'Dieser Benutzername ist bereits vergeben.',
'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.',
];
}
public function save(): void
{
$this->validate();
User::create([
'name' => $this->name,
'email' => $this->email,
'password' => Hash::make($this->password),
'role' => $this->role,
'is_active' => $this->is_active,
]);
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Erstellt', text: "Benutzer <b>{$this->name}</b> wurde angelegt.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
$roles = Role::cases();
return view('livewire.ui.system.modal.user-create-modal', compact('roles'));
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\User;
use LivewireUI\Modal\ModalComponent;
class UserDeleteModal extends ModalComponent
{
public int $userId;
public string $userName = '';
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->userId = $userId;
$this->userName = $user->name;
}
public function delete(): void
{
if ($this->userId === auth()->id()) {
$this->dispatch('toast', type: 'error', badge: 'Benutzer',
title: 'Fehler', text: 'Du kannst deinen eigenen Account nicht löschen.', duration: 5000);
$this->closeModal();
return;
}
User::findOrFail($this->userId)->delete();
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Gelöscht', text: "Benutzer <b>{$this->userName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.user-delete-modal');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserEditModal extends ModalComponent
{
public int $userId;
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = '';
public bool $is_active = true;
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->userId = $userId;
$this->name = $user->name;
$this->email = $user->email;
$this->role = $user->role?->value ?? Role::Operator->value;
$this->is_active = $user->is_active;
}
protected function rules(): array
{
return [
'name' => "required|string|max:100|unique:users,name,{$this->userId}",
'email' => "required|email|max:190|unique:users,email,{$this->userId}",
'password' => 'nullable|string|min:8',
'role' => 'required|in:' . implode(',', Role::values()),
];
}
public function save(): void
{
$this->validate();
$data = [
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'is_active' => $this->is_active,
];
if ($this->password !== '') {
$data['password'] = Hash::make($this->password);
}
User::findOrFail($this->userId)->update($data);
$this->dispatch('toast', type: 'done', badge: 'Benutzer',
title: 'Gespeichert', text: "Benutzer <b>{$this->name}</b> wurde aktualisiert.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
$roles = Role::cases();
$isSelf = $this->userId === auth()->id();
return view('livewire.ui.system.modal.user-edit-modal', compact('roles', 'isSelf'));
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use App\Services\WebhookService;
use LivewireUI\Modal\ModalComponent;
class WebhookCreateModal extends ModalComponent
{
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
public function save(): void
{
$this->validate([
'name' => 'required|string|max:80',
'url' => 'required|url|max:500',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())),
], [
'selected.required' => 'Bitte mindestens ein Event auswählen.',
'selected.min' => 'Bitte mindestens ein Event auswählen.',
]);
Webhook::create([
'name' => $this->name,
'url' => $this->url,
'events' => $this->selected,
'secret' => WebhookService::generateSecret(),
'is_active' => $this->is_active,
]);
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function toggleAll(): void
{
$all = array_keys(Webhook::allEvents());
$this->selected = count($this->selected) === count($all) ? [] : $all;
}
public function render()
{
return view('livewire.ui.system.modal.webhook-create-modal', [
'allEvents' => Webhook::allEvents(),
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookDeleteModal extends ModalComponent
{
public int $webhookId;
public string $webhookName = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->webhookId = $webhookId;
$this->webhookName = $webhook->name;
}
public function delete(): void
{
Webhook::findOrFail($this->webhookId)->delete();
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.webhook-delete-modal');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookEditModal extends ModalComponent
{
public int $webhookId;
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
public string $secret = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->webhookId = $webhookId;
$this->name = $webhook->name;
$this->url = $webhook->url;
$this->selected = $webhook->events;
$this->is_active = $webhook->is_active;
$this->secret = $webhook->secret;
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:80',
'url' => 'required|url|max:500',
'selected' => 'required|array|min:1',
'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())),
], [
'selected.required' => 'Bitte mindestens ein Event auswählen.',
]);
Webhook::findOrFail($this->webhookId)->update([
'name' => $this->name,
'url' => $this->url,
'events' => $this->selected,
'is_active' => $this->is_active,
]);
$this->dispatch('webhook-saved');
$this->closeModal();
}
public function regenerateSecret(): void
{
$webhook = Webhook::findOrFail($this->webhookId);
$webhook->update(['secret' => \App\Services\WebhookService::generateSecret()]);
$this->secret = $webhook->fresh()->secret;
$this->dispatch('notify', type: 'success', message: 'Secret neu generiert.');
}
public function toggleAll(): void
{
$all = array_keys(Webhook::allEvents());
$this->selected = count($this->selected) === count($all) ? [] : $all;
}
public function render()
{
return view('livewire.ui.system.modal.webhook-edit-modal', [
'allEvents' => Webhook::allEvents(),
]);
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Ui\System;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Profil · Mailwolt')]
class ProfilePage extends Component
{
public string $name = '';
public string $email = '';
public string $current_password = '';
public string $new_password = '';
public string $new_password_confirmation = '';
public bool $totpEnabled = false;
public function mount(): void
{
$u = Auth::user();
$this->name = $u->name ?? '';
$this->email = $u->email ?? '';
$this->totpEnabled = app(TotpService::class)->isEnabled($u);
}
#[On('2fa-status-changed')]
public function refreshTotpStatus(): void
{
$this->totpEnabled = app(TotpService::class)->isEnabled(Auth::user());
}
public function saveProfile(): void
{
$this->validate([
'name' => 'required|string|max:100',
]);
$u = Auth::user();
$u->name = $this->name;
$u->save();
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Gespeichert',
text: 'Profilname wurde aktualisiert.', duration: 4000);
}
public function changePassword(): void
{
if ($this->new_password === '') {
$this->addError('new_password', 'Kein neues Passwort eingegeben.');
return;
}
$this->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8',
'new_password_confirmation' => 'required|same:new_password',
]);
$u = Auth::user();
if (!Hash::check($this->current_password, $u->password)) {
$this->addError('current_password', 'Aktuelles Passwort ist falsch.');
return;
}
$u->password = Hash::make($this->new_password);
$u->save();
$this->reset(['current_password', 'new_password', 'new_password_confirmation']);
$this->dispatch('toast', type: 'done', badge: 'Profil',
title: 'Passwort geändert',
text: 'Dein Passwort wurde aktualisiert.', duration: 4000);
}
public function disableTotp(): void
{
app(TotpService::class)->disable(Auth::user());
session()->forget('2fa_verified');
$this->totpEnabled = false;
$this->dispatch('toast', type: 'done', badge: '2FA',
title: 'TOTP deaktiviert',
text: 'Zwei-Faktor-Authentifizierung wurde deaktiviert.', duration: 5000);
}
public function render()
{
return view('livewire.ui.system.profile-page');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxMail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Mail-Sandbox · Mailwolt')]
class SandboxMailbox extends Component
{
#[Url]
public string $search = '';
public ?int $selectedId = null;
public function select(int $id): void
{
$this->selectedId = $id;
SandboxMail::find($id)?->update(['is_read' => true]);
}
public function deleteOne(int $id): void
{
SandboxMail::findOrFail($id)->delete();
if ($this->selectedId === $id) {
$this->selectedId = null;
}
}
public function clearAll(): void
{
SandboxMail::truncate();
$this->selectedId = null;
}
public function render()
{
$query = SandboxMail::orderByDesc('received_at');
if ($this->search !== '') {
$s = '%' . $this->search . '%';
$query->where(fn($q) => $q
->where('from_address', 'like', $s)
->orWhere('subject', 'like', $s)
->orWhereJsonContains('to_addresses', $this->search)
);
}
$mails = $query->get();
$selected = $this->selectedId ? SandboxMail::find($this->selectedId) : null;
$unread = SandboxMail::where('is_read', false)->count();
return view('livewire.ui.system.sandbox-mailbox', compact('mails', 'selected', 'unread'));
}
}

Some files were not shown because too many files have changed in this diff Show More