Compare commits

...

238 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
186 changed files with 6959 additions and 9154 deletions

View File

@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
# SESSION_DOMAIN=.example.com
SESSION_DOMAIN=null
SESSION_DOMAIN=
#BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local

1
.gitignore vendored
View File

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

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.

View File

@ -17,7 +17,7 @@ class BackupRun extends Command
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id;
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
@ -28,7 +28,7 @@ class BackupRun extends Command
$script = '/usr/local/sbin/mailwolt-backup';
if (file_exists($script)) {
$output = [];
exec("bash " . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
exec('sudo -n ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode);
$artifact = null;
foreach ($output as $line) {
@ -37,7 +37,7 @@ class BackupRun extends Command
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
@ -97,12 +97,12 @@ class BackupRun extends Command
$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() . '/mailwolt_backups';
$outDir = sys_get_temp_dir() . '/clubird_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz";
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];

View File

@ -6,50 +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
git config --global --add safe.directory {$appPath} 2>/dev/null || true
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').").");
}
@ -61,28 +136,35 @@ class CheckUpdates extends Command
private function readInstalledVersionNorm(): ?string
{
// Lokal: git describe gibt immer den aktuellen Stand
if (app()->isLocal()) {
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
$v = $this->normalizeVersion($tag);
if ($v) return $v;
// 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 !== '') { $fileVer = $this->normalizeVersion($raw); break; }
}
$paths = [
'/var/lib/mailwolt/version',
base_path('VERSION'),
];
foreach ($paths as $p) {
$raw = @trim(@file_get_contents($p) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
// version_raw bevorzugen (echter installierter Stand)
// Nur wenn version_raw fehlt: version-Datei oder git-Tag als Fallback
if ($rawVer) {
return $rawVer;
}
$raw = $this->readInstalledVersionRaw();
return $raw ? $this->normalizeVersion($raw) : null;
// 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;
}
@ -92,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

@ -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

@ -7,11 +7,12 @@ use Illuminate\Console\Command;
class WizardDomains extends Command
{
protected $signature = 'mailwolt:wizard-domains
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';
@ -26,7 +27,6 @@ class WizardDomains extends Command
@mkdir(self::STATE_DIR, 0755, true);
// Start: alle auf pending
foreach (['ui', 'mail', 'webmail'] as $key) {
file_put_contents(self::STATE_DIR . "/{$key}", 'pending');
}
@ -34,6 +34,7 @@ class WizardDomains extends Command
$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');
@ -42,47 +43,82 @@ class WizardDomains extends Command
file_put_contents(self::STATE_DIR . "/{$key}", 'running');
// DNS prüfen
$hasDns = checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA');
if (!$hasDns) {
file_put_contents(self::STATE_DIR . "/{$key}", 'nodns');
$allOk = false;
continue;
}
}
// SSL-Zertifikat anfordern
if ($ssl) {
$out = shell_exec(sprintf(
'sudo -n certbot certonly --nginx --non-interactive --agree-tos -m root@%s -d %s 2>&1',
escapeshellarg($domain),
escapeshellarg($domain)
));
$certOk = str_contains((string) $out, 'Successfully') || str_contains((string) $out, 'Certificate not yet due for renewal');
if (!$certOk) {
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');
$allOk = false;
continue;
}
}
file_put_contents(self::STATE_DIR . "/{$key}", 'done');
file_put_contents(self::STATE_DIR . '/done', '0');
Setting::set('ssl_configured', '0');
return self::SUCCESS;
}
// Nginx neu konfigurieren (alle Domains auf einmal)
if ($allOk) {
$helper = '/usr/local/sbin/mailwolt-apply-domains';
shell_exec(sprintf(
'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1',
escapeshellarg($helper),
escapeshellarg($ui),
escapeshellarg($webmail),
escapeshellarg($mail),
$ssl ? 1 : 0,
));
// 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');
}
}
file_put_contents(self::STATE_DIR . '/done', $allOk ? '1' : '0');
Setting::set('ssl_configured', $allOk ? '1' : '0');
// 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,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

@ -26,10 +26,10 @@ class ValidateHost
return true;
}
$base = config('mailwolt.domain.base');
$uiSub = config('mailwolt.domain.ui');
$mtaSub = config('mailwolt.domain.mail');
$wmHost = config('mailwolt.domain.webmail_host');
$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,

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

@ -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;

View File

@ -45,14 +45,18 @@ class Wizard extends Component
private const STATE_DIR = '/var/lib/mailwolt/wizard';
public function mount(): void
public function mount()
{
$this->instance_name = config('app.name', 'Mailwolt');
$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', '');
$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
}
}
public function updatedUiDomain(): void { $this->fillEmptyDomains($this->ui_domain); }
@ -90,10 +94,10 @@ class Wizard extends Component
3 => $this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:10|same:admin_password_confirmation',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
'admin_password_confirmation' => 'required',
], [
'admin_password.min' => 'Mindestens 10 Zeichen.',
'admin_password.min' => 'Mindestens 6 Zeichen.',
'admin_password.same' => 'Passwörter stimmen nicht überein.',
]),
default => null,
@ -112,7 +116,7 @@ class Wizard extends Component
$this->validate([
'admin_name' => 'required|string|min:2|max:64',
'admin_email' => 'required|email|max:190',
'admin_password' => 'required|string|min:10|same:admin_password_confirmation',
'admin_password' => 'required|string|min:6|same:admin_password_confirmation',
]);
// Settings + .env speichern
@ -148,10 +152,10 @@ class Wizard extends Component
file_put_contents(self::STATE_DIR . "/{$k}", 'pending');
}
$ssl = (!$this->skipSsl && app()->isProduction()) ? 1 : 0;
$ssl = $this->skipSsl ? 0 : 1;
$artisan = base_path('artisan');
$cmd = sprintf(
'nohup php %s mailwolt:wizard-domains --ui=%s --mail=%s --webmail=%s --ssl=%d > /dev/null 2>&1 &',
'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),
@ -165,6 +169,8 @@ class Wizard extends Component
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)
@ -178,9 +184,36 @@ class Wizard extends Component
}
}
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
{
return redirect()->route('login')->with('setup_done', true);
$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

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

View File

@ -62,9 +62,13 @@ class DomainDnsModal extends ModalComponent
$ipv6 = $ips['ipv6'];
$this->zone = $this->extractZone($d->domain);
$mta_sub = env('MTA_SUB');
$base = env('BASE_DOMAIN');
$mailServerFqdn = "{$mta_sub}.{$base}";
// Setting::get('mail_domain') liefert den vollen MTA-FQDN (z.B. mail.example.com)
// Fallback auf config-Werte, die env() sicher kapseln
$mailDomainSetting = strtolower(trim((string) \App\Models\Setting::get('mail_domain', '')));
$mta_sub = config('mailpool.mta_sub', 'mail');
$base = config('mailpool.platform_zone', 'example.com');
$mailServerFqdn = $mailDomainSetting ?: "{$mta_sub}.{$base}";
// --- Infrastruktur (global) ---
$this->static = [
@ -88,7 +92,7 @@ class DomainDnsModal extends ModalComponent
$dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
$dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
$selector = $dkim ? $dkim->selector : 'mwl1';
$selector = $dkim ? $dkim->selector : 'clb1';
$dkimHost = "{$selector}._domainkey.{$this->domainName}";
$dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
@ -167,7 +171,7 @@ class DomainDnsModal extends ModalComponent
// --- Komfort / Web ---
[
'type' => 'CNAME',
'name' => env('WEBMAIL_SUB') . ".{$this->domainName}",
'name' => (config('clubird.domain.webmail') ?: env('WEBMAIL_SUB', 'webmail')) . ".{$this->domainName}",
'value' => "{$mailServerFqdn}.",
'helpLabel' => 'Webmail Alias',
'helpUrl' => 'https://en.wikipedia.org/wiki/CNAME_record',
@ -670,7 +674,7 @@ class DomainDnsModal extends ModalComponent
// $dmarc = "v=DMARC1; p=none; rua=mailto:dmarc@{$this->domainName}; pct=100";
//
// $dkim = DB::table('dkim_keys')->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
// $selector = $dkim ? $dkim->selector : 'mwl1';
// $selector = $dkim ? $dkim->selector : 'clb1';
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
// ? 'v=DKIM1; k=rsa; p=' . trim($dkim->public_key_txt)
@ -1055,7 +1059,7 @@ class DomainDnsModal extends ModalComponent
//
// $dkim = DB::table('dkim_keys')
// ->where('domain_id', $d->id)->where('is_active', 1)->orderByDesc('id')->first();
// $selector = $dkim ? $dkim->selector : 'mwl1';
// $selector = $dkim ? $dkim->selector : 'clb1';
// $dkimHost = "{$selector}._domainkey.{$this->domainName}";
// $dkimTxt = $dkim && !str_starts_with(trim($dkim->public_key_txt), 'v=')
// ? 'v=DKIM1; k=rsa; p=' . $dkim->public_key_txt
@ -1172,10 +1176,10 @@ class DomainDnsModal extends ModalComponent
////
//// // Placeholder-Werte, sofern du sie anderswo speicherst gern ersetzen:
//// $serverIp = config('app.server_ip', 'DEINE.SERVER.IP');
//// $mxHost = config('mailwolt.mx_fqdn', 'mx.' . $this->domain->domain);
//// $mxHost = config('clubird.mx_fqdn', 'mx.' . $this->domain->domain);
//// $selector = optional(
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()
//// )->selector ?? 'mwl1';
//// )->selector ?? 'clb1';
////
//// $dkimTxt = optional(
//// DkimKey::where('domain_id', $this->domain->id)->where('is_active', true)->first()

View File

@ -6,9 +6,11 @@ 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;
@ -19,13 +21,7 @@ class Dashboard extends Component
{
public function render()
{
$cached = Cache::get(CacheVer::k('health:services'), []);
$rows = $cached['rows'] ?? [];
$services = array_map(fn($r) => [
'name' => $r['label'] ?? ucfirst($r['name']),
'type' => $r['hint'] ?? '',
'status' => ($r['ok'] ?? false) ? 'online' : 'offline',
], $rows);
$services = $this->loadServices();
[$cpu, $cpuCores, $cpuMhz] = $this->cpu();
[$ramPercent, $ramUsed, $ramTotal] = $this->ram();
@ -35,7 +31,15 @@ class Dashboard extends Component
$servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online'));
$sslConfigured = SettingModel::get('ssl_configured', '1') === '1';
// 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,
@ -43,7 +47,8 @@ class Dashboard extends Component
'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(),
'servicesActive' => $servicesActive,
'servicesTotal' => count($services),
'alertCount' => 0,
'alertCount' => SandboxRoute::where('is_active', true)->count(),
'sandboxAlerts' => SandboxRoute::activeRoutes(),
'backup' => $this->backupData(),
'mailHostname' => gethostname() ?: 'mailserver',
'services' => $services,
@ -62,12 +67,82 @@ class Dashboard extends Component
'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];

View File

@ -4,7 +4,6 @@ namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use App\Services\DkimService;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
@ -39,27 +38,12 @@ class DnsDkim extends Component
{
$domain = Domain::findOrFail($id);
$selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector
?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
try {
/** @var DkimService $svc */
$svc = app(DkimService::class);
$res = $svc->generateForDomain($domain, 2048, $selector);
$priv = $res['priv_path'] ?? storage_path("app/private/dkim/{$domain->domain}/{$selector}.private");
$txt = storage_path("app/private/dkim/{$domain->domain}/{$selector}.txt");
if (!is_readable($txt) && !empty($res['dns_txt'])) {
file_put_contents($txt, $res['dns_txt']);
}
$proc = Process::run(['sudo', '-n', '/usr/local/sbin/mailwolt-install-dkim',
$domain->domain, $selector, $priv, $txt]);
if (!$proc->successful()) {
throw new \RuntimeException($proc->errorOutput());
}
Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'opendkim']);
$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);
@ -71,13 +55,13 @@ class DnsDkim extends Component
private function dkimReady(string $domain, string $selector): bool
{
return Process::run(['sudo', '-n', '/usr/bin/test', '-s',
"/etc/opendkim/keys/{$domain}/{$selector}.private"])->successful();
$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', 'mwl1');
$defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$mapped = Domain::where('is_server', false)
->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()])

View File

@ -29,6 +29,48 @@ class QuarantineList extends Component
#[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(); }
@ -43,7 +85,11 @@ class QuarantineList extends Component
public function render()
{
$all = $this->fetchHistory();
$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'));

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

@ -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

@ -15,22 +15,9 @@ class Fail2banBanlist extends Component
public ?string $jail = null;
/**
* Struktur für Blade (reine Ausgabe, keine Logik im Blade):
* [
* [
* 'ip' => '1.2.3.4',
* 'jail' => 'recidive',
* 'permanent' => false,
* 'label' => 'Temporär', // oder 'Permanent'
* 'box' => 'border-amber-400/20 bg-white/3', // Kartenstil
* 'badge' => 'border-amber-400/30 bg-amber-500/10 text-amber-200',
* 'btn' => 'border-rose-400/30 bg-rose-500/10 text-rose-200 hover:border-rose-400/50',
* ],
* ...
* ]
*
* @var array<int,array{
* ip:string,jail:string,permanent:bool,label:string,box:string,badge:string,btn:string
* ip:string,jail:string,service:string,permanent:bool,label:string,
* remaining:string,box:string,badge:string,dot:string,btn:string
* }>
*/
public array $rows = [];
@ -75,7 +62,9 @@ class Fail2banBanlist extends Component
continue;
}
$permanent = $this->isPermanent($j, $ip);
$banInfo = $this->getBanInfo($j, $ip);
$permanent = $banInfo['permanent'];
$remaining = $banInfo['remaining'];
if ($permanent) {
$box = 'border-rose-400/30 bg-rose-500/5';
@ -94,9 +83,11 @@ class Fail2banBanlist extends Component
$rows[] = [
'ip' => $ip,
'jail' => $j,
'service' => $this->serviceLabel($j),
'permanent' => $permanent,
'style' => $style,
'label' => $label,
'remaining' => $remaining,
'box' => $box,
'badge' => $badge,
'dot' => $dot,
@ -140,35 +131,63 @@ class Fail2banBanlist extends Component
/* ================= helpers ================= */
/** Prüft via SQLite, ob der **letzte** Ban für (jail, ip) permanent ist (bantime < 0). */
private function isPermanent(string $jail, string $ip): bool
/** Gibt permanent-Flag und verbleibende Zeit für (jail, ip) zurück. */
private function getBanInfo(string $jail, string $ip): array
{
$db = $this->getDbFile();
if ($db === '' || !is_readable($db)) {
// Fallback: Blacklist-Jail ist per Design permanent
return $jail === 'mailwolt-blacklist';
$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)];
}
}
$q = <<<SQL
WITH last AS (
SELECT MAX(timeofban) AS t
FROM bans
WHERE jail = '$jail' AND ip = '$ip'
)
SELECT bantime
FROM bans, last
WHERE jail = '$jail' AND ip = '$ip' AND timeofban = last.t
LIMIT 1;
SQL;
// 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)];
}
$cmd = sprintf(
'sudo -n /usr/bin/sqlite3 -readonly %s %s 2>&1',
escapeshellarg($db),
escapeshellarg($q)
);
$out = trim((string)@shell_exec($cmd));
if ($out === '') return ($jail === 'mailwolt-blacklist'); // Fallback
return ((int)$out) < 0;
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 */
@ -188,13 +207,4 @@ SQL;
return (string) @shell_exec('sudo -n /usr/bin/fail2ban-client '.$args.' 2>&1');
}
/** Pfad zur Fail2Ban-SQLite-DB holen */
private function getDbFile(): string
{
$out = $this->f2b('get dbfile');
$lines = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $out))));
$path = end($lines) ?: '';
$path = preg_replace('/^`?-?\s*/', '', $path);
return $path ?: '/var/lib/fail2ban/fail2ban.sqlite3';
}
}

View File

@ -135,7 +135,7 @@ bantime.factor = {$s->bantime_factor}
bantime.maxtime = {$s->max_bantime}
CONF;
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
}
protected function writeWhitelistConfig(): void
@ -146,7 +146,7 @@ CONF;
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
}
/* ---------------- Helper ---------------- */
@ -320,7 +320,7 @@ CONF;
//bantime.maxtime = {$s->max_bantime}
//CONF;
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/00-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
@ -330,7 +330,7 @@ CONF;
//
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// /**
@ -493,7 +493,7 @@ CONF;
//bantime.factor = {$s->bantime_factor}
//bantime.maxtime = {$s->max_bantime}
//CONF;
// file_put_contents('/etc/fail2ban/jail.d/00-mailwolt-defaults.local', $content);
// file_put_contents('/etc/fail2ban/jail.d/00-defaults.local', $content);
// }
//
// protected function writeWhitelistConfig(): void
@ -501,7 +501,7 @@ CONF;
// $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/mailwolt-whitelist.local', $content);
// file_put_contents('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void

View File

@ -168,7 +168,7 @@ class Fail2banIpModal extends ModalComponent
$ignore = implode(' ', array_unique(array_filter($ips)));
$content = "[DEFAULT]\nignoreip = {$ignore}\n";
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
$this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
}
private function writeRootFileViaTee(string $target, string $content): void
@ -293,7 +293,7 @@ class Fail2banIpModal extends ModalComponent
// Fail2banIpList::create(['ip' => $ip, 'type' => $this->type]);
//
// if ($this->type === 'whitelist') {
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/mailwolt-whitelist.local
// $this->writeWhitelistConfig(); // schreibt /etc/fail2ban/jail.d/whitelist.local
// $this->reloadFail2ban(); // f2b neu laden
// } else {
// // Blacklist = sofort bannen im dedizierten Jail
@ -356,7 +356,7 @@ class Fail2banIpModal extends ModalComponent
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// // sicher in Root-Pfad schreiben (sudo tee)
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/mailwolt-whitelist.local', $content);
// $this->writeRootFileViaTee('/etc/fail2ban/jail.d/whitelist.local', $content);
// }
//
// private function writeRootFileViaTee(string $target, string $content): void
@ -540,7 +540,7 @@ class Fail2banIpModal extends ModalComponent
// $ignore = implode(' ', array_unique(array_filter($ips)));
// $content = "[DEFAULT]\nignoreip = {$ignore}\n";
//
// $file = '/etc/fail2ban/jail.d/mailwolt-whitelist.local';
// $file = '/etc/fail2ban/jail.d/whitelist.local';
// $tmp = $file.'.tmp';
// @file_put_contents($tmp, $content, LOCK_EX);
// @chmod($tmp, 0644);

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

@ -55,7 +55,7 @@ class RspamdForm extends Component
private function writeRspamdConfig(): void
{
$target = '/etc/rspamd/local.d/mailwolt-actions.conf';
$target = '/etc/rspamd/local.d/actions.conf';
$content = <<<CONF
actions {
reject = {$this->reject_score};

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Ui\Security;
use App\Models\Setting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@ -12,9 +13,26 @@ 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
@ -24,6 +42,55 @@ class SslCertificatesTable extends Component
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);
@ -44,6 +111,21 @@ class SslCertificatesTable extends Component
}
}
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');
@ -51,7 +133,7 @@ class SslCertificatesTable extends Component
if (str_contains($out, 'No certificates found')) return [];
$certs = [];
$blocks = preg_split('/\n(?=Certificate Name:)/m', $out);
$blocks = preg_split('/\n(?=\s*Certificate Name:)/m', $out);
foreach ($blocks as $block) {
if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue;
@ -63,6 +145,13 @@ class SslCertificatesTable extends Component
$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];
@ -75,12 +164,12 @@ class SslCertificatesTable extends Component
$domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : [];
$certs[] = [
'name' => trim($nameM[1]),
'domains' => $domains,
'expiry' => $expiryRaw,
'days_left' => $daysLeft,
'expired' => $expired,
'cert_path' => trim($certM[1] ?? ''),
'name' => trim($nameM[1]),
'domains' => $domains,
'expiry_date' => $expiryDate,
'days_left' => $daysLeft,
'expired' => $expired,
'cert_path' => trim($certM[1] ?? ''),
];
}

View File

@ -96,7 +96,7 @@ class TlsCiphersForm extends Component
private function writePostfixConfig(): void
{
$target = '/etc/postfix/mailwolt-tls.cf';
$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"
@ -106,7 +106,7 @@ class TlsCiphersForm extends Component
private function writeDovecotConfig(): void
{
$target = '/etc/dovecot/conf.d/99-mailwolt-tls.conf';
$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);

View File

@ -129,10 +129,10 @@ class DomainsSslForm extends Component
public function mount(): void
{
$this->base_domain = (string) config('mailwolt.domain.base', '');
$this->ui_sub = (string) config('mailwolt.domain.ui', '');
$this->webmail_sub = (string) config('mailwolt.domain.webmail', '');
$this->mta_sub = (string) config('mailwolt.domain.mail', '');
$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();
}

View File

@ -24,7 +24,7 @@ class DomainsSslForm extends Component
public function mount(): void
{
$this->mail_domain_readonly = (string) config('mailwolt.domain.mail', 'mx');
$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);
}

View File

@ -106,6 +106,7 @@ class InstallerPage extends Component
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',

View File

@ -20,21 +20,22 @@ class ApiKeyCreateModal extends ModalComponent
'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',
'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,
);
$token = Auth::user()->createToken($this->name, $this->selected);
$pat = $token->accessToken;
if ($this->sandbox) {
@ -43,7 +44,10 @@ class ApiKeyCreateModal extends ModalComponent
}
$this->dispatch('token-created', plainText: $token->plainTextToken);
$this->closeModal();
$this->dispatch('openModal',
component: 'ui.system.modal.api-key-show-modal',
arguments: ['plainText' => $token->plainTextToken],
);
}
public function toggleAll(): void

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

@ -13,9 +13,18 @@ class ApiKeyShowModal extends ModalComponent
$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 'md';
return '2xl';
}
public function render()

View File

@ -34,12 +34,24 @@ class UpdateModal extends ModalComponent
$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;
// Log einlesen
$lines = @file(self::LOG, FILE_IGNORE_NEW_LINES) ?: [];
$this->tail = array_slice($lines, -30);
$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);

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,68 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxRoute;
use App\Services\SandboxService;
use Livewire\Attributes\Computed;
use Livewire\Component;
class SandboxRoutes extends Component
{
public string $newType = 'domain';
public string $newTarget = '';
public ?string $syncMessage = null;
public bool $syncOk = false;
public function boot(SandboxService $sandboxService): void
{
$this->sandboxService = $sandboxService;
}
public function addRoute(): void
{
if ($this->newType !== 'global') {
$this->validate(['newTarget' => 'required|string|max:255']);
}
$target = $this->newType === 'global' ? null : trim($this->newTarget);
$this->sandboxService->enable($this->newType, $target);
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
$this->newTarget = '';
}
public function removeRoute(int $id): void
{
$this->sandboxService->delete($id);
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
}
public function toggleRoute(int $id): void
{
$route = SandboxRoute::findOrFail($id);
$route->is_active = !$route->is_active;
$route->save();
$this->sandboxService->syncTransportFile();
$this->dispatch('sandbox-route-changed');
}
public function syncPostfix(): void
{
$result = $this->sandboxService->syncTransportFile();
$this->syncOk = $result['ok'];
$this->syncMessage = $result['message'];
}
#[Computed]
public function routes()
{
return SandboxRoute::orderBy('type')->orderBy('target')->get();
}
public function render()
{
return view('livewire.ui.system.sandbox-routes');
}
}

View File

@ -7,12 +7,18 @@ use App\Models\Setting;
use Illuminate\Support\Facades\Artisan;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Einstellungen · Mailwolt')]
class SettingsForm extends Component
{
#[Url]
public string $tab = 'general';
private const VALID_TABS = ['general', 'domains', 'backup', 'notifications'];
// Allgemein
public string $instance_name = '';
public string $locale = 'de';
@ -24,6 +30,15 @@ class SettingsForm extends Component
public string $mail_domain = '';
public string $webmail_domain = '';
// SSL-Status (read-only, für Anzeige in Einstellungen)
public array $sslCerts = [];
private const SSL_STATE_DIR = '/var/lib/mailwolt/wizard';
// Benachrichtigungen
public string $notify_sender_name = '';
public string $notify_admin_email = '';
// Sicherheit
public int $rate_limit = 5;
public int $password_min = 10;
@ -47,8 +62,10 @@ class SettingsForm extends Component
'locale' => 'required|string|max:10',
'timezone' => 'required|string|max:64',
'session_timeout' => 'required|integer|min:5|max:1440',
'rate_limit' => 'required|integer|min:1|max:100',
'password_min' => 'required|integer|min:6|max:128',
'notify_sender_name' => 'nullable|string|max:80',
'notify_admin_email' => 'nullable|email|max:190',
'rate_limit' => 'required|integer|min:1|max:100',
'password_min' => 'required|integer|min:6|max:128',
'backup_enabled' => 'boolean',
'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom',
'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/',
@ -123,8 +140,13 @@ class SettingsForm extends Component
Setting::setMany($persist);
}
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
$this->loadSslStatus();
$defaultSenderName = ($this->instance_name ?: 'Mailwolt') . ' Benachrichtigung';
$defaultAdminEmail = auth()->user()?->system_notify_email ?? '';
$this->notify_sender_name = (string) Setting::get('notify_sender_name', $defaultSenderName);
$this->notify_admin_email = (string) Setting::get('notify_admin_email', $defaultAdminEmail);
$this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit);
$this->password_min = (int) Setting::get('password_min', $this->password_min);
// Backup aus BackupPolicy laden
$policy = BackupPolicy::first();
@ -145,13 +167,20 @@ class SettingsForm extends Component
$this->validate();
Setting::setMany([
'locale' => $this->locale,
'timezone' => $this->timezone,
'session_timeout' => $this->session_timeout,
'rate_limit' => $this->rate_limit,
'password_min' => $this->password_min,
'locale' => $this->locale,
'timezone' => $this->timezone,
'session_timeout' => $this->session_timeout,
'notify_sender_name' => $this->notify_sender_name,
'notify_admin_email' => $this->notify_admin_email,
'rate_limit' => $this->rate_limit,
'password_min' => $this->password_min,
]);
// MAIL_FROM_NAME in .env synchronisieren
if ($this->notify_sender_name) {
$this->writeEnv(['MAIL_FROM_NAME' => $this->notify_sender_name]);
}
// Backup-Policy speichern
$cron = $this->buildCron();
BackupPolicy::updateOrCreate([], [
@ -174,7 +203,6 @@ class SettingsForm extends Component
{
$this->validate($this->domainRules(), $this->domainMessages());
// Normalize: strip schema + trailing slashes
$this->ui_domain = $this->cleanDomain($this->ui_domain);
$this->mail_domain = $this->cleanDomain($this->mail_domain);
$this->webmail_domain = $this->cleanDomain($this->webmail_domain);
@ -185,18 +213,88 @@ class SettingsForm extends Component
'webmail_domain' => $this->webmail_domain,
]);
// Immer .env aktualisieren — unabhängig von DNS oder Nginx
$this->syncDomainsToEnv();
// Nginx-Konfiguration nur anwenden wenn alle Domains gesetzt sind
$warnings = [];
if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) {
$sslAuto = app()->isProduction();
$this->applyDomains($sslAuto);
$nginxOk = $this->applyDomains(false);
if ($nginxOk === false) {
$warnings[] = 'Nginx konnte nicht neu geladen werden — DNS noch nicht aktiv oder Helper fehlt.';
}
}
$this->dispatch('toast', type: 'done', badge: 'Domains',
title: 'Domains gespeichert',
text: 'Konfiguration wurde übernommen.', duration: 4000);
$sysmailErr = $this->syncSysmailDomain();
if ($sysmailErr) {
$warnings[] = 'System-Domain: ' . $sysmailErr;
}
if ($this->mail_domain) {
$this->applyPostfixHostname($this->mail_domain);
}
$this->loadSslStatus();
if ($warnings) {
$this->dispatch('toast', type: 'warn', badge: 'Domains',
title: 'Domains gespeichert — mit Hinweisen',
text: implode(' · ', $warnings),
duration: 0);
} else {
$this->dispatch('toast', type: 'done', badge: 'Domains',
title: 'Domains gespeichert',
text: 'Konfiguration wurde übernommen.',
duration: 4000);
}
}
private function syncSysmailDomain(): ?string
{
// BASE_DOMAIN aus config, env oder mail_domain ableiten
$platformBase = strtolower((string) config('mailpool.platform_zone', ''));
if (!$platformBase || $platformBase === 'example.com') {
$platformBase = strtolower((string) env('BASE_DOMAIN', ''));
}
if (!$platformBase || $platformBase === 'example.com') {
// Fallback: aus mail_domain den Hostnamen ohne ersten Subdomain-Teil
$mailDomain = strtolower(trim((string) Setting::get('mail_domain', '')));
if ($mailDomain) {
$parts = explode('.', $mailDomain);
$platformBase = count($parts) > 2
? implode('.', array_slice($parts, 1))
: $mailDomain;
}
}
if (!$platformBase || $platformBase === 'example.com') return null;
$systemSub = strtolower(config('mailpool.platform_system_zone', 'sysmail'));
$expectedFqdn = "{$systemSub}.{$platformBase}";
$existing = \App\Models\Domain::where('is_system', true)
->where(fn($q) => $q->whereNull('is_server')->orWhere('is_server', false))
->first();
if ($existing && $existing->domain === $expectedFqdn) return null;
if ($existing && $existing->domain !== $expectedFqdn) {
\App\Models\MailUser::where('domain_id', $existing->id)->forceDelete();
$existing->forceDelete();
}
try {
Artisan::call('db:seed', [
'--class' => 'Database\\Seeders\\SystemDomainSeeder',
'--force' => true,
]);
\Illuminate\Support\Facades\Log::info('SystemDomainSeeder ok', ['fqdn' => $expectedFqdn]);
return null;
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('SystemDomainSeeder failed', [
'fqdn' => $expectedFqdn,
'error' => $e->getMessage(),
]);
return $e->getMessage();
}
}
private function cleanDomain(string $value): string
@ -206,21 +304,56 @@ class SettingsForm extends Component
return rtrim($value, '/');
}
public function openSslModal(): void
public function loadSslStatus(): void
{
if (! ($this->ui_domain && $this->mail_domain && $this->webmail_domain)) {
$this->dispatch('toast', type: 'warn', badge: 'SSL',
title: 'Domains fehlen',
text: 'Bitte erst alle drei Domains speichern.', duration: 5000);
return;
}
$this->dispatch('openModal', component: 'ui.system.modal.ssl-provision-modal');
}
clearstatcache(true);
#[\Livewire\Attributes\On('ssl:provision')]
public function provisionSsl(): void
{
$this->applyDomains(true);
$domains = [
'ui' => $this->ui_domain,
'webmail' => $this->webmail_domain,
'mail' => $this->mail_domain,
];
$certs = [];
foreach ($domains as $key => $domain) {
if (! $domain) {
$certs[$key] = ['domain' => '', 'has_cert' => false, 'expiry' => null, 'status' => 'nodomain'];
continue;
}
$certFile = "/etc/letsencrypt/live/{$domain}/fullchain.pem";
$certDir = "/etc/letsencrypt/live/{$domain}";
// renewal/ ist immer 755 und world-readable — zuverlässigster Existenzcheck.
// is_dir() als Fallback falls renewal-Datei fehlt (manuell ausgestellte Certs).
$renewalConf = "/etc/letsencrypt/renewal/{$domain}.conf";
$certExists = file_exists($renewalConf) || is_dir($certDir);
if (! $certExists) {
$certs[$key] = ['domain' => $domain, 'has_cert' => false, 'expiry' => null, 'status' => 'missing'];
continue;
}
// sudo nötig: archive/ ist 700 (Symlink-Ziel), voller Pfad für sudoers-Matching
$expiry = trim((string) @shell_exec(
"sudo -n /usr/bin/openssl x509 -enddate -noout -in " . escapeshellarg($certFile) . " 2>/dev/null | sed 's/notAfter=//'"
));
$expiryTs = $expiry ? strtotime($expiry) : null;
$daysLeft = $expiryTs ? (int) round(($expiryTs - time()) / 86400) : null;
$certStatus = match (true) {
$daysLeft === null => 'ok',
$daysLeft <= 0 => 'expired',
$daysLeft <= 14 => 'expiring',
default => 'ok',
};
$certs[$key] = [
'domain' => $domain,
'has_cert' => true,
'expiry' => $expiryTs ? date('d.m.Y', $expiryTs) : null,
'days' => $daysLeft,
'status' => $certStatus,
];
}
$this->sslCerts = $certs;
}
public function updatedUiDomain(): void { $this->validateOnly('ui_domain', $this->domainRules(), $this->domainMessages()); }
@ -271,7 +404,7 @@ class SettingsForm extends Component
}
}
private function applyDomains(bool $sslAuto = false): void
private function applyDomains(bool $sslAuto = false): bool
{
// DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen
$unreachable = [];
@ -282,12 +415,8 @@ class SettingsForm extends Component
}
if ($unreachable) {
$this->dispatch('toast', type: 'warn', badge: 'DNS',
title: 'DNS noch nicht erreichbar',
text: 'Kein DNS-Eintrag für: ' . implode(', ', $unreachable) . '. Domains wurden trotzdem gespeichert — Nginx-Konfiguration bitte nach DNS-Setup erneut speichern.',
duration: 9000,
);
return;
Log::info('mailwolt-apply-domains skipped (DNS not ready)', ['unreachable' => $unreachable]);
return false;
}
$helper = '/usr/local/sbin/mailwolt-apply-domains';
@ -307,40 +436,47 @@ class SettingsForm extends Component
if ($ok) {
Setting::set('ssl_configured', '1');
$this->dispatch('toast', type: 'done', badge: 'Nginx',
title: 'Nginx aktualisiert',
text: 'Nginx-Konfiguration wurde neu geladen.',
duration: 5000,
);
} else {
$this->dispatch('toast', type: 'warn', badge: 'Nginx',
title: 'Nginx-Konfiguration fehlgeschlagen',
text: 'Nginx konnte nicht neu geladen werden. Domains wurden trotzdem gespeichert. Siehe Laravel-Log.',
duration: 7000,
);
return true;
}
Log::warning('mailwolt-apply-domains failed', ['output' => $output]);
return false;
}
private function syncDomainsToEnv(): void
{
$base = rtrim((string) config('mailwolt.domain.base', ''), '.');
// BASE_DOMAIN aus mail_domain ableiten (mx.pxo.at → pxo.at)
$derivedBase = '';
if ($this->mail_domain) {
$parts = explode('.', strtolower(trim($this->mail_domain)));
$derivedBase = count($parts) > 2
? implode('.', array_slice($parts, 1))
: implode('.', $parts);
}
$base = $derivedBase ?: rtrim((string) config('clubird.domain.base', ''), '.');
$sub = function (string $full) use ($base): string {
$full = strtolower(trim($full));
if ($base && str_ends_with($full, '.' . $base)) {
return substr($full, 0, -(strlen($base) + 1));
}
// Kein passender Base — ersten Label nehmen
$parts = explode('.', $full);
return count($parts) > 1 ? $parts[0] : '';
};
$this->writeEnv([
$env = [
'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '',
'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '',
'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '',
'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '',
]);
];
if ($derivedBase && $derivedBase !== 'example.com') {
$env['BASE_DOMAIN'] = $derivedBase;
}
$this->writeEnv($env);
Artisan::call('config:clear');
Artisan::call('route:clear');
@ -366,9 +502,23 @@ class SettingsForm extends Component
file_put_contents($path, $content);
}
private function applyPostfixHostname(string $mailHost): void
{
$safeHost = preg_replace('/[^a-zA-Z0-9.\-]/', '', $mailHost);
if (!$safeHost) return;
$helper = '/usr/local/sbin/mailwolt-apply-hostname';
@shell_exec(sprintf('sudo -n %s %s 2>/dev/null', escapeshellarg($helper), escapeshellarg($safeHost)));
}
public function render()
{
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
return view('livewire.ui.system.settings-form', compact('timezones'));
if (!in_array($this->tab, self::VALID_TABS, true)) {
$this->tab = 'allgemein';
}
$timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
$backupMeta = BackupPolicy::first();
return view('livewire.ui.system.settings-form', compact('timezones', 'backupMeta'));
}
}

View File

@ -49,7 +49,8 @@ class UpdateCard extends Component
$this->recompute();
$this->progressLine = '';
if ($this->running) {
if ($this->running || $this->lowState === 'done') {
// Update läuft oder abgeschlossen aber Post-Actions noch nicht ausgeführt
$this->state = 'running';
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
}
@ -66,24 +67,45 @@ class UpdateCard extends Component
public function runUpdate(): void
{
// evtl. alte Einträge aufräumen
// State-Dateien VOR dem Start zurücksetzen — verhindert dass pollUpdate()
// das "done" vom letzten Update liest und sofort als "fertig" wertet.
@mkdir('/var/lib/mailwolt/update', 0755, true);
@file_put_contents('/var/lib/mailwolt/update/state', 'starting');
@unlink('/var/lib/mailwolt/update/rc');
Cache::forget('mailwolt.update_available');
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
@shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &');
// Sofort ins Running gehen
$this->latest = null;
$this->latest = null;
$this->displayLatest = null;
$this->hasUpdate = false;
$this->state = 'running';
$this->running = true;
$this->errorLine = null;
$this->hasUpdate = false;
$this->state = 'running';
$this->running = true;
$this->rc = null;
$this->lowState = 'starting';
$this->errorLine = null;
$this->progressLine = 'Update gestartet …';
$this->postActionsDone = false;
$this->recomputeUi();
}
public function openLogs(): void
{
$this->dispatch('openModal', component: 'ui.system.modal.update-modal');
}
public function refreshVersions(): void
{
if ($this->state === 'running') return;
$this->reloadVersionsAndStatus();
$this->recompute();
$this->recomputeUi();
}
public function pollUpdate(): void
{
$this->refreshLowLevelState();
@ -112,22 +134,30 @@ class UpdateCard extends Component
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan optimize >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan spamav:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan rbl:probe --force >/dev/null 2>&1 &');
$this->postActionsDone = true;
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
$ver = $this->displayCurrent ?? 'aktuelle Version';
$this->progressLine = 'Update abgeschlossen: ' . $ver;
// Optional: NICHT sofort reloaden Nutzer entscheidet
// $this->dispatch('reload-page', delay: 6000);
$this->dispatch('toast', type: 'done', badge: 'System',
title: 'Update abgeschlossen',
text: "Mailwolt wurde auf {$ver} aktualisiert.",
duration: 6000);
$this->dispatch('closeModal');
} elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) {
$this->postActionsDone = true;
@file_put_contents('/var/lib/mailwolt/update/state', 'complete');
$this->errorLine = "Update fehlgeschlagen (rc={$this->rc}).";
$this->dispatch('toast', type:'error', title:'Update fehlgeschlagen',
text:$this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
badge:'System', duration:0);
$this->dispatch('toast', type: 'error', title: 'Update fehlgeschlagen',
text: $this->progressLine ?: 'Bitte Logs prüfen: /var/log/mailwolt-update.log',
badge: 'System', duration: 0);
$this->dispatch('closeModal');
}
$this->state = 'idle';
@ -200,7 +230,7 @@ class UpdateCard extends Component
// Update-Checker schreibt:
// - updates:latest (normiert)
// - updates:latest_raw (original)
$latNorm = Cache::get('updates:latest');
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
$latRaw = Cache::get('updates:latest_raw');
// Legacy-Fallback
@ -226,7 +256,9 @@ class UpdateCard extends Component
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
if (!$this->hasUpdate) {
$this->displayLatest = null;
} elseif (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}
@ -306,11 +338,10 @@ class UpdateCard extends Component
$this->lowState = $state !== '' ? $state : null;
// running: solange NICHT 'done'
$this->running = ($this->lowState !== 'done');
$this->running = ($this->lowState === 'running');
// rc erst freigeben, wenn wirklich done
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int)$rcRaw : null;
// rc freigeben wenn 'done' (Post-Actions ausstehend) oder 'complete' (bereits gelaufen)
$this->rc = (in_array($this->lowState, ['done', 'complete']) && is_numeric($rcRaw)) ? (int)$rcRaw : null;
$this->progressLine = $this->tailUpdateLog();
}
@ -318,27 +349,24 @@ class UpdateCard extends Component
protected function readCurrentVersion(): ?string
{
// 1) normierte Version vom Wrapper
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
$fileVer = $this->normalizeVersion(@trim(@file_get_contents(self::VERSION_FILE) ?: ''));
// 2) raw -> normieren
// 2) git-Tag (bevorzugt wenn neuer als Datei z.B. auf Dev-Server)
$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;
}
if ($fileVer) return $fileVer;
// 3) raw -> normieren
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);
// 3) build.info
$build = @file_get_contents(self::BUILD_INFO);
if ($build) {
foreach (preg_split('/\R+/', $build) as $line) {
if (str_starts_with($line, 'version=')) {
$v = $this->normalizeVersion(trim(substr($line, 8)));
if ($v) return $v;
}
}
}
// 4) Fallback auf config(app.version)
$v = $this->normalizeVersion(config('app.version') ?: '');
return $v ?: null;
return $this->normalizeVersion(config('app.version') ?: '') ?: null;
}
protected function normalizeVersion(?string $v): ?string

View File

@ -61,7 +61,7 @@ class UpdatePage extends Component
public function checkForUpdates(): void
{
@shell_exec('php ' . base_path('artisan') . ' mailwolt:check-updates 2>&1');
@shell_exec('php ' . base_path('artisan') . ' clubird:check-updates 2>&1');
$this->reloadVersionsAndStatus();
$this->recompute();
@ -133,6 +133,7 @@ class UpdatePage extends Component
if ($this->rc === 0 && !$this->postActionsDone) {
@shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan config:cache >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &');
@shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &');
$this->postActionsDone = true;
@ -169,7 +170,7 @@ class UpdatePage extends Component
{
$this->current = $this->readCurrentVersion();
$latNorm = Cache::get('updates:latest');
$latNorm = $this->normalizeVersion(Cache::get('updates:latest') ?? '');
$latRaw = Cache::get('updates:latest_raw');
if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) {
@ -194,7 +195,9 @@ class UpdatePage extends Component
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
if (!$this->displayLatest && $latNorm) {
if (!$this->hasUpdate) {
$this->displayLatest = null;
} elseif (!$this->displayLatest && $latNorm) {
$this->displayLatest = 'v' . $latNorm;
}
}
@ -205,7 +208,7 @@ class UpdatePage extends Component
$rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: '');
$this->lowState = $state !== '' ? $state : null;
$this->running = ($this->lowState !== 'done');
$this->running = ($this->lowState === 'running');
$this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null;
}
@ -255,16 +258,14 @@ class UpdatePage extends Component
protected function readCurrentVersion(): ?string
{
// Lokal: direkt aus git describe lesen damit Entwicklungsumgebung immer aktuell ist
if (app()->isLocal()) {
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
$v = $this->normalizeVersion($tag);
if ($v) return $v;
}
$v = @trim(@file_get_contents(self::VERSION_FILE) ?: '');
if ($v !== '') return $v;
// Fallback: git tag (lokal immer, production wenn Datei fehlt)
$tag = @trim((string) shell_exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null'));
$v = $this->normalizeVersion($tag);
if ($v) return $v;
$raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: '');
if ($raw !== '') return $this->normalizeVersion($raw);

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
class SandboxRoute extends Model
{
protected $fillable = ['type', 'target', 'is_active'];
protected $casts = [
'is_active' => 'boolean',
];
/**
* All active routes ordered by type and target.
*/
public static function activeRoutes(): Collection
{
return static::where('is_active', true)
->orderBy('type')
->orderBy('target')
->get();
}
/**
* Check whether sandbox is active for a given domain.
* True if global sandbox is active OR domain-specific rule is active.
*/
public static function isActiveForDomain(string $domain): bool
{
return static::where('is_active', true)
->where(function ($q) use ($domain) {
$q->where('type', 'global')
->orWhere(function ($q2) use ($domain) {
$q2->where('type', 'domain')->where('target', $domain);
});
})
->exists();
}
/**
* Check whether sandbox is active for a given address.
* True if global is active OR domain matches OR address-specific rule exists.
*/
public static function isActiveForAddress(string $address): bool
{
$domain = substr(strrchr($address, '@'), 1) ?: '';
return static::where('is_active', true)
->where(function ($q) use ($address, $domain) {
$q->where('type', 'global')
->orWhere(function ($q2) use ($domain) {
$q2->where('type', 'domain')->where('target', $domain);
})
->orWhere(function ($q3) use ($address) {
$q3->where('type', 'address')->where('target', $address);
});
})
->exists();
}
}

View File

@ -17,11 +17,17 @@ class DomainObserver
{
if ($domain->is_server) return;
$selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
$bits = (int) config('mailpool.defaults.dkim_bits', 2048);
// Service erledigt: Key generieren, DB (upsert) pflegen, Helper ausführen, OpenDKIM reloaden
app(\App\Services\DkimService::class)->generateForDomain($domain, $bits, $selector);
try {
app(\App\Services\DkimService::class)->generateForDomain($domain, $bits, $selector);
} catch (\Throwable $e) {
Log::warning('DKIM install skipped (sudo/helper not ready)', [
'domain' => $domain->domain,
'error' => $e->getMessage(),
]);
}
// DNS-Records: aktiven Key aus DB lesen und provisionieren
$active = $domain->dkimKeys()->where('is_active', true)->latest()->first();
@ -50,7 +56,7 @@ class DomainObserver
// Selector VOR dem Delete einsammeln (Relation oder direkte Query)
$selectors = DkimKey::where('domain_id', $domain->id)->pluck('selector')->all();
$selectors = $selectors ?: ['mwl1'];
$selectors = $selectors ?: ['clb1'];
foreach ($selectors as $sel) {
$cmd = ['sudo','-n','/usr/local/sbin/mailwolt-remove-dkim', $domain->domain, $sel];
@ -130,7 +136,7 @@ class DomainObserver
// return;
// }
//
// $selector = (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $selector = (string) config('mailpool.defaults.dkim_selector', 'clb1');
// $bits = (int) config('mailpool.defaults.dkim_bits', 2048);
//
// $res = app(\App\Services\DkimService::class)

View File

@ -40,7 +40,16 @@ class AppServiceProvider extends ServiceProvider
} catch (\Throwable) {}
});
config(['app.version' => trim(@file_get_contents('/var/lib/mailwolt/version')) ?: 'dev']);
$fileVer = trim(@file_get_contents('/var/lib/mailwolt/version') ?: '');
$out = []; @exec('git -C ' . escapeshellarg(base_path()) . ' describe --tags --abbrev=0 2>/dev/null', $out);
$gitVer = ltrim(trim($out[0] ?? ''), 'vV');
$appVer = ($gitVer && (!$fileVer || version_compare($gitVer, $fileVer, '>'))) ? $gitVer : ($fileVer ?: 'dev');
// Versions-Datei selbst aktualisieren wenn veraltet
if ($appVer !== 'dev' && $appVer !== $fileVer) {
@mkdir('/var/lib/mailwolt', 0755, true);
@file_put_contents('/var/lib/mailwolt/version', $appVer);
}
config(['app.version' => $appVer]);
if (file_exists(base_path('.git/HEAD'))) {
$ref = trim(file_get_contents(base_path('.git/HEAD')));
if (str_starts_with($ref, 'ref:')) {

View File

@ -15,7 +15,7 @@ class DkimService
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
public function generateForDomain(Domain $domain, int $bits = 2048, string $selector = null): array
{
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
$selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
// $dirKey = $this->safeKey($domain->domain);
// $selKey = $this->safeKey($selector, 32);
@ -205,7 +205,7 @@ class DkimService
? $domain->dkimKeys()->pluck('selector')->all()
: \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
->pluck('selector')->all();
$keys = $keys ?: ['mwl1'];
$keys = $keys ?: ['clb1'];
} else {
$keys = [$selector];
}
@ -241,7 +241,7 @@ class DkimService
// : \App\Models\DkimKey::whereHas('domain', fn($q) => $q->where('domain', $name))
// ->pluck('selector')->all();
//
// $keys = $keys ?: ['mwl1']; // notfalls versuchen wir Standard
// $keys = $keys ?: ['clb1']; // notfalls versuchen wir Standard
// } else {
// $keys = [$selector];
// }
@ -265,7 +265,7 @@ class DkimService
// public function removeForDomain(Domain|string $domain, ?string $selector = null): void
// {
// $name = $domain instanceof \App\Models\Domain ? $domain->domain : $domain;
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1');
// $selector = $selector ?: (string) config('mailpool.defaults.dkim_selector', 'clb1');
//
// // Root-Helper ausführen
// $p = Process::run([

View File

@ -0,0 +1,91 @@
<?php
namespace App\Services;
use App\Models\SandboxRoute;
class SandboxService
{
/**
* Enable (or create) a sandbox route.
*/
public function enable(string $type, ?string $target): SandboxRoute
{
return SandboxRoute::updateOrCreate(
['type' => $type, 'target' => $target],
['is_active' => true]
);
}
/**
* Disable a sandbox route by id.
*/
public function disable(int $id): void
{
SandboxRoute::findOrFail($id)->update(['is_active' => false]);
}
/**
* Delete a sandbox route by id.
*/
public function delete(int $id): void
{
SandboxRoute::findOrFail($id)->delete();
}
/**
* Write /etc/postfix/transport.sandbox and run postmap.
* Returns ['ok' => bool, 'message' => string].
*/
public function syncTransportFile(): array
{
$file = config('sandbox.transport_file', '/etc/postfix/transport.sandbox');
$routes = SandboxRoute::where('is_active', true)
->orderBy('type')
->orderBy('target')
->get();
$lines = [];
$hasGlobal = false;
foreach ($routes as $route) {
if ($route->type === 'global') {
$hasGlobal = true;
} elseif ($route->type === 'domain') {
$lines[] = $route->target . ' sandbox:';
} elseif ($route->type === 'address') {
$lines[] = $route->target . ' sandbox:';
}
}
// Global catch-all goes at the end
if ($hasGlobal) {
$lines[] = '* sandbox:';
}
$content = implode("\n", $lines);
if ($lines) {
$content .= "\n";
}
$tmp = tempnam(sys_get_temp_dir(), 'mw_sandbox_');
try {
file_put_contents($tmp, $content);
} catch (\Throwable $e) {
@unlink($tmp);
return ['ok' => false, 'message' => 'Fehler beim Schreiben: ' . $e->getMessage()];
}
$out = [];
$code = 0;
exec('sudo -n /usr/local/sbin/mailwolt-sandbox-sync ' . escapeshellarg($tmp) . ' 2>&1', $out, $code);
@unlink($tmp);
if ($code !== 0) {
return ['ok' => false, 'message' => 'Sync-Fehler: ' . implode(' ', $out)];
}
return ['ok' => true, 'message' => 'Transport-Datei synchronisiert (' . count($lines) . ' Einträge).'];
}
}

View File

@ -14,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
using: function () {
// Webmail must be registered BEFORE web.php so its domain constraint
// takes priority over the catch-all Route::get('/') in web.php.
$wmHost = config('mailwolt.domain.webmail_host');
$wmHost = config('clubird.domain.webmail_host');
if ($wmHost) {
Route::middleware('web')
@ -22,18 +22,28 @@ return Application::configure(basePath: dirname(__DIR__))
->name('ui.webmail.')
->group(base_path('routes/webmail.php'));
// Path-based fallback (no names — avoids duplicate-name conflict)
// Path-based fallback mit eigenem Namen-Prefix — kein Konflikt mit web.php 'login'
Route::middleware('web')
->prefix('webmail')
->name('webmail.')
->group(base_path('routes/webmail.php'));
// UI-Routes nur auf UI-Domain binden, damit webmail.* sie nicht beantwortet
$uiHost = config('clubird.domain.ui') && config('clubird.domain.base')
? config('clubird.domain.ui') . '.' . config('clubird.domain.base')
: parse_url(config('app.url'), PHP_URL_HOST);
Route::middleware('web')
->domain($uiHost)
->group(base_path('routes/web.php'));
} else {
Route::middleware('web')
->prefix('webmail')
->name('ui.webmail.')
->group(base_path('routes/webmail.php'));
}
Route::middleware('web')->group(base_path('routes/web.php'));
Route::middleware('web')->group(base_path('routes/web.php'));
}
},
)
->withMiddleware(function (Middleware $middleware): void {

View File

@ -3,6 +3,7 @@
return [
'platform_zone' => env('BASE_DOMAIN', 'example.com'),
'platform_system_zone' => env('SYSMAIL_SUB', 'sysmail'),
'mta_sub' => env('MTA_SUB', 'mail'),
'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 10), // 10 %
@ -18,7 +19,7 @@ return [
'fallback_du' => (bool) env('MAILPOOL_FALLBACK_DU', true),
'defaults' => [
'dkim_selector' => 'mwl1',
'dkim_selector' => 'clb1',
'dkim_bits' => 2048,
'max_aliases' => (int) env('MAILPOOL_DEFAULT_MAX_ALIASES', 400),

View File

@ -40,6 +40,7 @@ return [
'label' => 'Sicherheit', 'icon' => 'ph-shield', 'items' => [
['label' => 'TLS & Ciphers', 'route' => 'ui.security.tls'],
['label' => 'Ratelimits', 'route' => 'ui.security.abuse'],
['label' => 'Virenschutz', 'route' => 'ui.security.clamav'],
['label' => 'Audit-Logs', 'route' => 'ui.security.audit'],
],
],

View File

@ -2,6 +2,11 @@
return [
// Nur diese Keys erscheinen im Dashboard-Widget
'dashboard' => [
'postfix', 'dovecot', 'rspamd', 'clamav', 'db', 'redis', 'nginx', 'mw-ws', 'fail2ban',
],
'cards' => [
// Mail
'postfix' => [
@ -18,9 +23,10 @@ return [
],
'clamav' => [
'label' => 'ClamAV', 'hint' => 'Virenscanner',
'optional' => true,
'sources' => [
'systemd:clamav-daemon', 'systemd:clamav-daemon@scan', 'systemd:clamd',
'socket:/run/clamav/clamd.ctl', 'pid:/run/clamav/clamd.pid', 'tcp:127.0.0.1:3310',
// Nur systemd Socket-Datei bleibt nach dem Stoppen erhalten (false positive)
'systemd:clamav-daemon',
],
],
@ -64,6 +70,10 @@ return [
],
// Sonstiges
'monit' => [
'label' => 'Monit', 'hint' => 'Prozess-Monitoring',
'sources' => ['systemd:monit', 'tcp:127.0.0.1:2812'],
],
'fail2ban' => [
'label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection',
'sources' => ['systemd:fail2ban'],

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sandbox_routes', function (Blueprint $table) {
$table->id();
$table->enum('type', ['global', 'domain', 'address'])->default('domain');
$table->string('target')->nullable(); // domain or email, null = global
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['type', 'target']);
});
}
public function down(): void
{
Schema::dropIfExists('sandbox_routes');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('two_factor_methods', function (Blueprint $table) {
$table->text('secret')->nullable()->change();
});
}
public function down(): void
{
Schema::table('two_factor_methods', function (Blueprint $table) {
$table->string('secret', 255)->nullable()->change();
});
}
};

View File

@ -140,7 +140,7 @@ class SystemDomainSeeder extends Seeder
$this->command->line("System-Domain angelegt: {$systemDomain->domain}");
}
$noReply = MailUser::firstOrCreate(
$noReply = MailUser::updateOrCreate(
['domain_id' => $systemDomain->id, 'localpart' => 'no-reply'],
[
'email' => 'no-reply@' . $systemDomain->domain,
@ -305,7 +305,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, später rotieren
// $selector = 'clb1'; // frei wählbar, später rotieren
//
// DkimKey::create([
// 'domain_id' => $systemDomain->id,
@ -452,7 +452,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (!$systemDomain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, später rotieren
// $selector = 'clb1'; // frei wählbar, später rotieren
//
// DkimKey::create([
// 'domain_id' => $systemDomain->id,
@ -569,7 +569,7 @@ class SystemDomainSeeder extends Seeder
// // DKIM Key erzeugen, falls keiner aktiv existiert
// if (! $domain->dkimKeys()->where('is_active', true)->exists()) {
// [$privPem, $pubTxt] = $this->generateDkimKeyPair();
// $selector = 'mwl1'; // frei wählbar, z. B. rotierend später
// $selector = 'clb1'; // frei wählbar, z. B. rotierend später
//
// DkimKey::create([
// 'domain_id' => $domain->id,

View File

@ -44,6 +44,10 @@ NODE_SETUP="${NODE_SETUP:-deb}"
GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m"
BAR="──────────────────────────────────────────────────────────────────────────────"
# ===== Install-Log =====
LOG_FILE="/var/log/mailwolt-install.log"
> "$LOG_FILE"
header() {
echo -e "${CYAN}${BAR}${NC}"
echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}"
@ -63,27 +67,72 @@ footer_ok() {
local ip="$1"
local app_name="${2:-$APP_NAME}"
local app_dir="${3:-$APP_DIR}"
local nginx_site="${4:-$NGINX_SITE}"
local cert_dir="${5:-$CERT_DIR}"
local cert_dir="${4:-$CERT_DIR}"
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN}${app_name} Bootstrap erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${app_name} Installation erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e " Aufruf: ${CYAN}http://${ip}${NC} ${GREY}| https://${ip}${NC}"
echo -e " Laravel Root: ${GREY}${app_dir}${NC}"
echo -e " Nginx Site: ${GREY}${nginx_site}${NC}"
echo -e " Self-signed Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC}"
echo -e " Postfix/Dovecot Ports aktiv: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e " Rspamd/OpenDKIM: ${GREY}aktiv (DKIM-Keys später im Wizard)${NC}"
echo -e " Monit (Watchdog): ${GREY}installiert, NICHT aktiviert${NC}"
echo -e ""
echo -e " ${CYAN}➜ Setup-Wizard jetzt öffnen:${NC}"
echo -e " ${CYAN}http://${ip}/setup${NC}"
echo -e ""
echo -e " Laravel Root: ${GREY}${app_dir}${NC}"
echo -e " Mail-TLS Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC} (Postfix/Dovecot)"
echo -e " Postfix/Dovecot: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo
}
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[x]${NC} $*"; }
_STEP_T=0
_SPIN_PID=
_spin_bg() {
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local n=${#chars} i=0
while true; do
printf "\r \033[0;90m${chars:$i:1}\033[0m"
i=$(( (i + 1) % n ))
sleep 0.1
done
}
start_spin() { _spin_bg & _SPIN_PID=$!; }
stop_spin() {
[[ -n "${_SPIN_PID:-}" ]] || return 0
kill "$_SPIN_PID" 2>/dev/null || true
wait "$_SPIN_PID" 2>/dev/null || true
_SPIN_PID=
printf "\r\033[K"
}
trap 'stop_spin' EXIT INT TERM
step() {
local msg="$1" dur="${2:-}"
[ -n "$dur" ] && dur=" ${GREY}(~${dur})${NC}" || dur=""
printf "\n${CYAN}${NC} %-42s%b\n" "$msg" "$dur"
_STEP_T=$SECONDS
}
ok() {
stop_spin
local t=$(( SECONDS - _STEP_T ))
[ $t -gt 1 ] && printf " ${GREEN}${NC} ${GREY}%ds${NC}\n" $t || printf " ${GREEN}${NC}\n"
}
warn() { stop_spin; printf " ${YELLOW}${NC} %s\n" "$*"; }
err() { stop_spin; printf " ${RED}${NC} %s\n" "$*"; }
quietly() {
start_spin
if ! "$@" >> "$LOG_FILE" 2>&1; then
stop_spin
printf " ${RED}${NC} Fehlgeschlagen. Letzte Log-Zeilen:\n\n"
tail -20 "$LOG_FILE" | sed 's/^/ /'
printf "\n ${GREY}Vollständiges Log: %s${NC}\n\n" "$LOG_FILE"
exit 1
fi
stop_spin
}
try_quiet() { "$@" >> "$LOG_FILE" 2>&1 || true; }
log() { :; }
require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; }
# ===== IP ermitteln =====
@ -100,30 +149,37 @@ gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" ||
pw() { gen 28; }
short() { gen 16; }
# ===== Argument-Parsing =====
while [[ $# -gt 0 ]]; do
case "$1" in
-dev) APP_ENV="local"; APP_DEBUG="true" ;;
-stag|-staging) APP_ENV="staging"; APP_DEBUG="false" ;;
esac
shift
done
# ===== Start =====
require_root
header
SERVER_IP="$(detect_ip)"
APP_PW="${APP_PW:-$(pw)}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}" # Wizard setzt später FQDN
TZ="${TZ:-""}" # leer; Wizard setzt final
echo -e "${GREY}Server-IP erkannt: ${SERVER_IP}${NC}"
echo -e "\n ${GREY}Server-IP: ${SERVER_IP} Log: ${LOG_FILE}${NC}"
[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; }
log "Paketquellen aktualisieren…"
step "Paketquellen aktualisieren" "10 Sek"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
quietly apt-get update -y
ok
# ---- MariaDB-Workaround (fix für mariadb-common prompt) ----
log "MariaDB-Workaround vorbereiten…"
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[ -f /etc/mysql/mariadb.cnf ] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
# ---- Basis-Pakete installieren ----
log "Pakete installieren… (dies kann einige Minuten dauern)"
#apt-get install -y \
apt-get -y -o Dpkg::Options::="--force-confdef" \
step "Pakete installieren" "25 Min"
quietly apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
@ -132,20 +188,20 @@ apt-get -y -o Dpkg::Options::="--force-confdef" \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd php-sqlite3 unzip curl acl \
composer \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit
ca-certificates rsyslog sudo openssl netcat-openbsd monit git
ok
# ===== Verzeichnisse / User =====
log "Verzeichnisse und Benutzer anlegen…"
step "Benutzer & Verzeichnisse anlegen" "5 Sek"
mkdir -p ${CERT_DIR} /etc/postfix /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
quietly bash -c "id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail"
chown -R vmail:vmail /var/mail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
usermod -a -G www-data "$APP_USER"
quietly bash -c "id '$APP_USER' >/dev/null 2>&1 || adduser --disabled-password --gecos '' '$APP_USER'"
quietly usermod -a -G www-data "$APP_USER"
ok
# ===== Self-signed TLS (SAN = IP) =====
CERT="${CERT_DIR}/cert.pem"
@ -153,7 +209,7 @@ KEY="${CERT_DIR}/key.pem"
OSSL_CFG="${CERT_DIR}/openssl.cnf"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
log "Erzeuge Self-Signed TLS Zertifikat (SAN=IP:${SERVER_IP})…"
step "Self-Signed Zertifikat erstellen" "5 Sek"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits = 2048
@ -173,26 +229,26 @@ subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
quietly openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chmod 600 "$KEY"; chmod 644 "$CERT"
ok
fi
# ===== MariaDB vorbereiten =====
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
DB_NAME="${DB_USER}"
DB_USER="${DB_USER}"
step "Datenbank einrichten" "10 Sek"
quietly systemctl enable --now mariadb
DB_PASS="$(pw)"
mysql -uroot <<SQL
quietly mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
ok
# ===== Postfix konfigurieren (25/465/587) =====
log "Postfix konfigurieren…"
step "Mailserver konfigurieren (Postfix / Dovecot / Rspamd)" "15 Sek"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
@ -271,10 +327,9 @@ CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
systemctl enable --now postfix
try_quiet systemctl enable --now postfix
# ===== Dovecot konfigurieren (IMAP/POP3 + SSL) =====
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
@ -350,15 +405,14 @@ ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
systemctl enable --now dovecot
try_quiet systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
systemctl enable --now rspamd || true
try_quiet systemctl enable --now rspamd
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
@ -374,17 +428,17 @@ LogWhy yes
OversignHeaders From
# KeyTable / SigningTable später im Wizard
CONF
systemctl enable --now opendkim || true
# ===== Redis =====
systemctl enable --now redis-server
try_quiet systemctl enable --now opendkim
try_quiet systemctl enable --now redis-server
ok
# ===== Nginx: Laravel vHost (80/443) =====
log "Nginx konfigurieren…"
step "Webserver konfigurieren (Nginx)" "5 Sek"
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
PHP_FPM_SOCK="/run/php/php-fpm.sock"
[ -S "/run/php/php8.2-fpm.sock" ] && PHP_FPM_SOCK="/run/php/php8.2-fpm.sock"
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SOCK="/run/php/php${PHPV}-fpm.sock"
[ -S "$PHP_FPM_SOCK" ] || PHP_FPM_SOCK="/run/php/php-fpm.sock"
cat > ${NGINX_SITE} <<CONF
server {
@ -398,6 +452,10 @@ server {
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
@ -406,38 +464,7 @@ server {
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files $uri /index.php?$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
access_log off;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_ssl_access.log;
error_log /var/log/nginx/${APP_USER}_ssl_error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files $uri /index.php?$query_string;
try_files \$uri /index.php?\$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
@ -446,176 +473,182 @@ server {
}
CONF
ln -sf ${NGINX_SITE} ${NGINX_SITE_LINK}
nginx -t && systemctl enable --now nginx
try_quiet nginx -t
try_quiet systemctl enable --now nginx
ok
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
APP_ENV="local"
APP_DEBUG="true"
;;
-stag|-staging)
APP_ENV="staging"
APP_DEBUG="false"
;;
esac
shift
done
# ===== Laravel installieren (als eigener User) =====
log "Laravel installieren…"
step "Projekt herunterladen (Git)" "15 Sek"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
chown "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
if [ ! -d "${APP_DIR}" ] || [ -z "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]; then
sudo -u "$APP_USER" -H bash -lc "cd /var/www && COMPOSER_ALLOW_SUPERUSER=0 composer create-project laravel/laravel ${APP_USER} --no-interaction"
if [ ! -d "${APP_DIR}/.git" ]; then
rm -rf "${APP_DIR}"
quietly sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only"
fi
ok
# ===== .env erstellen und befüllen =====
APP_URL="http://${SERVER_IP}"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force"
# .env befüllen (MariaDB & Redis Sessions)
sed -i "s|^APP_NAME=.*|APP_NAME=${APP_NAME}|g" "${APP_DIR}/.env"
sed -i "s|^APP_URL=.*|APP_URL=${APP_URL}|g" "${APP_DIR}/.env"
sed -i "s|^APP_ENV=.*|APP_ENV=${APP_ENV}|g" "${APP_DIR}/.env"
sed -i "s|^APP_DEBUG=.*|APP_DEBUG=${APP_DEBUG}|g" "${APP_DIR}/.env"
sed -i "s|^DB_CONNECTION=.*|DB_CONNECTION=mysql|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_HOST=.*|DB_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PORT=.*|DB_PORT=3306|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_DATABASE=.*|DB_DATABASE=${DB_NAME}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_USERNAME=.*|DB_USERNAME=${DB_USER}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PASSWORD=.*|DB_PASSWORD=${DB_PASS}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_HOST=.*|DB_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PORT=.*|DB_PORT=3306|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_DATABASE=.*|DB_DATABASE=${DB_NAME}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_USERNAME=.*|DB_USERNAME=${DB_USER}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PASSWORD=.*|DB_PASSWORD=${DB_PASS}|g" "${APP_DIR}/.env"
sed -i "s|^CACHE_DRIVER=.*|CACHE_DRIVER=redis|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*CACHE_PREFIX=.*|CACHE_PREFIX=${APP_USER}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*CACHE_PREFIX=.*|CACHE_PREFIX=${APP_USER}|g" "${APP_DIR}/.env"
sed -i "s|^SESSION_DRIVER=.*|SESSION_DRIVER=redis|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_HOST=.*|REDIS_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=null|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PORT=.*|REDIS_PORT=6379|g" "${APP_DIR}/.env"
REVERB_APP_ID="$(short)"
REVERB_APP_KEY="$(short)"
REVERB_APP_SECRET="$(short)"
grep -q '^REVERB_APP_ID=' "${APP_DIR}/.env" \
|| printf '\nBROADCAST_CONNECTION=reverb\nREVERB_APP_ID=%s\nREVERB_APP_KEY=%s\nREVERB_APP_SECRET=%s\nREVERB_HOST=127.0.0.1\nREVERB_PORT=8080\nREVERB_SCHEME=http\nVITE_REVERB_APP_KEY=%s\nVITE_REVERB_HOST=%s\nVITE_REVERB_PORT=8080\nVITE_REVERB_SCHEME=http\n' \
"$REVERB_APP_ID" "$REVERB_APP_KEY" "$REVERB_APP_SECRET" "$REVERB_APP_KEY" "$SERVER_IP" >> "${APP_DIR}/.env"
# === Bootstrap-Admin für den ersten Login (nur .env, kein DB-User) ===
# Bootstrap-Admin für den ersten Login
BOOTSTRAP_USER="${APP_USER}"
BOOTSTRAP_EMAIL="${APP_USER}@localhost"
BOOTSTRAP_PASS="$(openssl rand -base64 18 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 12)"
BOOTSTRAP_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$BOOTSTRAP_PASS")"
grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" || echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env"
grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" \
|| echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env"
sed -i "s|^SETUP_PHASE=.*|SETUP_PHASE=bootstrap|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_USER=.*|BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "$>
grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_EMAIL=.*|BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_PASSWORD_HASH=.*|BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}|g" "${APP_DIR}/.env"
# ===== Node/NPM installieren (für Vite/Tailwind Build) =====
if [ "$NODE_SETUP" = "nodesource" ]; then
# LTS via NodeSource (empfohlen für aktuelle LTS)
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
else
# Debian-Repo (ok für Basics, aber u.U. älter)
apt-get install -y nodejs npm
fi
step "PHP-Abhängigkeiten installieren (Composer)" "12 Min"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-dev --optimize-autoloader --no-interaction"
ok
# ===== Projekt aus Git holen (PLATZHALTER) =====
# Falls dein Repo später bereitsteht, überschreibt dieser Block das leere/Standard-Laravel.
if [ "${GIT_REPO}" != "https://example.com/your-repo-placeholder.git" ]; then
if [ ! -d "${APP_DIR}/.git" ]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
step "Datenbank migrieren" "15 Sek"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan migrate --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan storage:link --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan config:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan route:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan view:cache"
ok
if [ -f "${APP_DIR}/package.json" ]; then
step "Frontend bauen (npm)" "13 Min"
# Node/npm falls noch nicht installiert
if ! command -v node >/dev/null 2>&1; then
if [ "$NODE_SETUP" = "nodesource" ]; then
quietly bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh && bash /tmp/nodesource_setup.sh && rm -f /tmp/nodesource_setup.sh"
quietly apt-get install -y nodejs
else
quietly apt-get install -y nodejs npm
fi
fi
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
if ! sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build" >> "$LOG_FILE" 2>&1; then
warn "npm run build fehlgeschlagen — manuell nachholen: cd ${APP_DIR} && npm run build"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only"
ok
fi
fi
# ===== Frontend Build (nur wenn package.json existiert) =====
if [ -f "${APP_DIR}/package.json" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
# Prod-Build (Vite/Tailwind)
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build || npm run build:prod || true"
mkdir -p /var/lib/mailwolt/wizard
chown www-data:www-data /var/lib/mailwolt/wizard
chmod 775 /var/lib/mailwolt/wizard
step "Hilfsskripte & Konfiguration installieren" "5 Sek"
install -m 755 "${APP_DIR}/scripts/mailwolt-apply-domains" /usr/local/sbin/mailwolt-apply-domains
install -m 755 "${APP_DIR}/scripts/mailwolt-fetch-tags" /usr/local/sbin/mailwolt-fetch-tags
# ===== mailwolt-update installieren =====
install -m 755 "${APP_DIR}/scripts/update.sh" /usr/local/sbin/mailwolt-update
# ===== Sudoers für www-data (helper + update) =====
cat > /etc/sudoers.d/mailwolt-certbot <<'SUDOERS'
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-domains
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-fetch-tags
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
www-data ALL=(root) NOPASSWD: /usr/bin/openssl x509 -enddate -noout -in /etc/letsencrypt/live/*/fullchain.pem
SUDOERS
chmod 440 /etc/sudoers.d/mailwolt-certbot
# git safe.directory damit spätere pulls als root möglich sind
git config --global --add safe.directory "${APP_DIR}" || true
# ===== Version-Datei schreiben =====
mkdir -p /var/lib/mailwolt
GIT_TAG="$(sudo -u "$APP_USER" -H bash -lc "git -C ${APP_DIR} ls-remote --tags --sort=-v:refname origin 'v*' 2>/dev/null | grep -v '\^{}' | head -1 | sed 's|.*refs/tags/||'")"
if [ -n "$GIT_TAG" ]; then
echo "${GIT_TAG#v}" > /var/lib/mailwolt/version
echo "$GIT_TAG" > /var/lib/mailwolt/version_raw
else
warn "Kein Git-Tag gefunden — Version-Datei wird nicht geschrieben"
fi
ok
# ===== App-User/Gruppen & Rechte (am ENDE ausführen) =====
APP_USER="${APP_USER:-${APP_NAME}app}"
APP_GROUP="${APP_GROUP}"
APP_PW="${APP_PW:-changeme123}"
APP_DIR="${APP_DIR}"
# User anlegen (nur falls noch nicht vorhanden) + Passwort setzen + Gruppe
step "Berechtigungen setzen & Dienste starten" "10 Sek"
if ! id -u "$APP_USER" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "$APP_USER"
echo "${APP_USER}:${APP_PW}" | chpasswd
quietly adduser --disabled-password --gecos "" "$APP_USER"
fi
usermod -a -G "$APP_GROUP" "$APP_USER"
echo "${APP_USER}:${APP_PW}" | quietly chpasswd
quietly usermod -a -G "$APP_GROUP" "$APP_USER"
# Besitz & Rechte
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
find "$APP_DIR" -type d -exec chmod 775 {} \; 2>>"$LOG_FILE"
find "$APP_DIR" -type f -exec chmod 664 {} \; 2>>"$LOG_FILE"
chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache
if command -v setfacl >/dev/null 2>&1; then
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
fi
echo -e "${YELLOW}[i] App-User: ${APP_USER} Passwort: ${APP_PW}${NC}"
# Optional: ACLs, falls verfügbar (robuster bei gemischten Schreibzugriffen)
if command -v setfacl >/dev/null 2>&1; then
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
try_quiet setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
try_quiet setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
fi
grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile
grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc
try_quiet sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002"
# 7) npm respektiert umask zur Sicherheit direkt setzen (für APP_USER)
sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002" >/dev/null 2>&1 || true
# 8) PHP-FPM-Worker laufen als www-data (Standard). Stelle sicher, dass der FPM-Socket group-writable ist:
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL"
systemctl restart php${PHPV}-fpm || true
try_quiet systemctl restart php${PHPV}-fpm
fi
# 9) Optional: deinem Shell-/IDE-User ebenfalls Schreibrechte geben
IDE_USER="${SUDO_USER:-}"
if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then
usermod -a -G "$APP_GROUP" "$IDE_USER" || true
setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
echo -e "${YELLOW}[i]${NC} Benutzer '${IDE_USER}' wurde für Schreibzugriff freigeschaltet (ACL + Gruppe ${APP_GROUP})."
try_quiet usermod -a -G "$APP_GROUP" "$IDE_USER"
try_quiet setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
try_quiet setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
fi
# Webstack neu laden
systemctl reload nginx || true
systemctl restart php*-fpm || true
try_quiet systemctl reload nginx
try_quiet systemctl restart php*-fpm
ok
# Hinweis zur neuen Gruppenzugehörigkeit
echo -e "${YELLOW}[i]${NC} SHELL: Du kannst dich nun als Benutzer '${APP_USER}' mit dem Passwort '${APP_PW}' anmelden."
echo -e "${YELLOW}[i]${NC} Hinweis: Nach dem ersten Login solltest du das Passwort mit 'passwd ${APP_USER}' ändern."
echo -e "${YELLOW}[i]${NC} Damit die Gruppenrechte (${APP_GROUP}) aktiv werden, bitte einmal ab- und wieder anmelden."
# ===== Monit (Watchdog) installiert, aber NICHT aktiviert =====
log "Monit (Watchdog) installieren (deaktiviert)"
# ===== Monit =====
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
@ -624,62 +657,127 @@ set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed host 127.0.0.1 port 25 protocol smtp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process dovecot with pidfile /var/run/dovecot/master.pid
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
if failed host 127.0.0.1 port 143 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 993 type tcpssl for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
check process mariadb matching "mysqld"
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
if failed host 127.0.0.1 port 3306 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
if failed host 127.0.0.1 port 6379 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process rspamd with pidfile /run/rspamd/rspamd.pid
start program = "/bin/systemctl start rspamd"
check process rspamd matching "rspamd: main process"
start program = "/bin/systemctl start rspamd" with timeout 60 seconds
stop program = "/bin/systemctl stop rspamd"
if failed port 11332 type tcp then restart
if failed host 127.0.0.1 port 11332 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 11334 type tcp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed port 8891 type tcp then restart
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 10 cycles then alert
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
if failed host 127.0.0.1 port 80 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 10 cycles then alert
check process clamav matching "clamd"
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
chmod 600 /etc/monit/monitrc
systemctl disable --now monit || true
apt-mark hold monit >/dev/null 2>&1 || true
monit -t || { warn "Monit-Config ungültig — prüfe /etc/monit/monitrc"; }
try_quiet systemctl enable --now monit
# ===== Smoke-Test (alle Ports, mit Timeouts) =====
log "Smoke-Test (Ports & Banner):"
set +e
printf "[25] " && timeout 6s bash -lc 'printf "EHLO localhost\r\nQUIT\r\n" | nc -v -w 4 127.0.0.1 25 2>&1' || true
printf "[465] " && timeout 6s openssl s_client -connect 127.0.0.1:465 -brief -quiet </dev/null || echo "[465] Verbindung fehlgeschlagen"
printf "[587] " && timeout 6s openssl s_client -starttls smtp -connect 127.0.0.1:587 -brief -quiet </dev/null || echo "[587] Verbindung fehlgeschlagen"
printf "[110] " && timeout 6s bash -lc 'printf "QUIT\r\n" | nc -v -w 4 127.0.0.1 110 2>&1' || true
printf "[995] " && timeout 6s openssl s_client -connect 127.0.0.1:995 -brief -quiet </dev/null || echo "[995] Verbindung fehlgeschlagen"
printf "[143] " && timeout 6s bash -lc 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | nc -v -w 4 127.0.0.1 143 2>&1' || true
printf "[993] " && timeout 6s openssl s_client -connect 127.0.0.1:993 -brief -quiet </dev/null || echo "[993] Verbindung fehlgeschlagen"
set -e
# ===== Smoke-Test =====
step "Dienste prüfen (Port-Check)"
_sok=0; _sfail=0
smoke_smtp() {
local port="$1" label="$2"
local out
out=$(printf "EHLO localhost\r\nQUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -q '^220'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_tls() {
local port="$1" label="$2" extra="${3:-}"
local out
out=$(timeout 5s openssl s_client $extra -connect 127.0.0.1:"$port" -brief -quiet </dev/null 2>&1 || true)
if echo "$out" | grep -qiE '(CONNECTED|depth|Verify|^220|\+OK|OK)'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_imap() {
local port="$1" label="$2"
local out
out=$(printf ". CAPABILITY\r\n. LOGOUT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi 'CAPABILITY'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_pop3() {
local port="$1" label="$2"
local out
out=$(printf "QUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi '^\+OK'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_smtp 25 "SMTP"
smoke_tls 465 "SMTPS" ""
smoke_tls 587 "Submission" "-starttls smtp"
smoke_imap 143 "IMAP"
smoke_tls 993 "IMAPS" ""
smoke_pop3 110 "POP3"
smoke_tls 995 "POP3S" ""
printf "\n ${GREY}%d/%d Dienste erreichbar${NC}\n" "$_sok" "$(( _sok + _sfail ))"
echo
echo "=============================================================="
echo " Bootstrap-Login (nur für ERSTEN Login & Wizard):"
echo " User: ${BOOTSTRAP_USER}"
echo " Passwort: ${BOOTSTRAP_PASS}"
echo "=============================================================="
echo -e " ${GREY}Bootstrap-Login (nur für ERSTEN Login & Wizard):${NC}"
echo -e " ${CYAN}User: ${NC}${BOOTSTRAP_USER}"
echo -e " ${CYAN}Passwort: ${NC}${BOOTSTRAP_PASS}"
echo -e " ${GREY}Log: ${LOG_FILE}${NC}"
echo
footer_ok "$SERVER_IP"

418
logos.svg Normal file
View File

@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CluBird — Logo</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Sans:wght@400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07080e;
--surface: #0b0d16;
--border: #181c2e;
--border2: #252840;
--acc: #6366f1;
--acc-h: #4f46e5;
--acc-l: #818cf8;
--acc-bg: #1a1a3a;
--acc-bd: #2d2f5a;
--t1: #e8eaf6;
--t2: #9a9ec8;
--t3: #4a5070;
--t4: #252840;
}
body {
background: var(--bg);
color: var(--t1);
font-family: 'DM Sans', system-ui, sans-serif;
padding: 40px;
min-height: 100vh;
}
h2 {
font-family: 'DM Sans', sans-serif;
font-size: 10px;
font-weight: 500;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 16px;
}
.section { margin-bottom: 48px; }
.row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-light {
background: #f0f2ff;
border: 1px solid #d4d8ff;
border-radius: 14px;
padding: 28px 32px;
}
.card-label {
font-size: 9px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
/* ── Logo Lockup ── */
.logo {
display: flex;
align-items: center;
gap: 11px;
}
.logo-icon {
flex-shrink: 0;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.wordmark {
font-family: 'Syne', sans-serif;
font-weight: 800;
letter-spacing: -0.5px;
line-height: 1;
}
.wordmark .clu { color: var(--t1); }
.wordmark .bird { color: var(--acc); }
.wordmark-light .clu { color: #0f1120; }
.wordmark-light .bird { color: var(--acc); }
.tagline {
font-size: 9px;
font-weight: 500;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 1.8px;
margin-top: 3px;
}
/* ── Divider ── */
.divider {
width: 1px;
height: 40px;
background: var(--border);
margin: 0 8px;
}
/* ── Family ── */
.family {
display: flex;
align-items: center;
gap: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 28px;
width: fit-content;
}
.family-item {
display: flex;
align-items: center;
gap: 9px;
}
.plus {
font-size: 16px;
color: var(--border2);
font-weight: 300;
}
.family-name {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.3px;
}
.family-sub {
font-size: 8.5px;
color: var(--t3);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-top: 1px;
}
</style>
</head>
<body>
<!-- ── 1. HAUPTLOGO ── -->
<div class="section">
<h2>Hauptlogo — Dark Mode</h2>
<div class="row">
<!-- Groß -->
<div>
<div class="card-label">Groß</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:48px;height:48px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 20px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<!-- Körper -->
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<!-- Flügel links oben -->
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Flügel rechts -->
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!-- Auge -->
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<!-- Schwanz / Brust -->
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark" style="font-size:28px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline">Mail Server Management</div>
</div>
</div>
</div>
</div>
<!-- Standard -->
<div>
<div class="card-label">Standard</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 14px rgba(99,102,241,.3);">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark" style="font-size:21px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline">by Aziros</div>
</div>
</div>
</div>
</div>
<!-- Klein / Sidebar -->
<div>
<div class="card-label">Klein / Sidebar</div>
<div class="card">
<div class="logo">
<div class="logo-icon" style="width:28px;height:28px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:7px;">
<svg width="16" height="16" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white" opacity=".95"/>
</svg>
</div>
<div class="wordmark" style="font-size:16px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 2. LIGHT MODE ── -->
<div class="section">
<h2>Light Mode</h2>
<div class="row">
<div>
<div class="card-label">Standard Light</div>
<div class="card-light">
<div class="logo">
<div class="logo-icon" style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;box-shadow:0 4px 12px rgba(99,102,241,.25);">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="wordmark wordmark-light" style="font-size:21px;">
<span class="clu">Clu</span><span class="bird">Bird</span>
</div>
<div class="tagline" style="color:#8892aa;">Mail Server Management</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 3. ICON VARIANTEN ── -->
<div class="section">
<h2>Icon-Only — alle Größen</h2>
<div class="card" style="display:flex;gap:20px;align-items:flex-end;width:fit-content;">
<!-- 64px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:64px;height:64px;border-radius:16px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px rgba(99,102,241,.4);">
<svg width="38" height="38" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">64px</span>
</div>
<!-- 48px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">48px</span>
</div>
<!-- 36px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:36px;height:36px;border-radius:9px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">36px</span>
</div>
<!-- 24px -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">24px</span>
</div>
<!-- Favicon -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:16px;height:16px;border-radius:4px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;">
<svg width="10" height="10" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">Favicon</span>
</div>
<!-- Rund -->
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;">
<div style="width:48px;height:48px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<span style="font-size:9px;color:var(--t3);">Rund</span>
</div>
</div>
</div>
<!-- ── 4. WORDMARK VARIANTEN ── -->
<div class="section">
<h2>Wordmark</h2>
<div class="card" style="display:flex;flex-direction:column;gap:16px;width:fit-content;">
<div class="wordmark" style="font-size:32px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
<div class="wordmark" style="font-size:22px;"><span class="clu">Clu</span><span class="bird">Bird</span></div>
<div style="font-family:'Syne',sans-serif;font-size:18px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--t1);">CLU<span style="color:var(--acc);">BIRD</span></div>
<div style="font-family:'Syne',sans-serif;font-size:16px;font-weight:800;color:var(--t1);">clu<span style="color:var(--acc);">bird</span></div>
</div>
</div>
<!-- ── 5. PRODUKTFAMILIE ── -->
<div class="section">
<h2>Aziros Produktfamilie</h2>
<div class="family">
<!-- CluPilot -->
<div class="family-item">
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
<span style="font-size:11px;font-weight:800;color:#fff;font-family:'Syne',sans-serif;">CP</span>
</div>
<div>
<div class="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Pilot</span></div>
<div class="family-sub">Infrastruktur</div>
</div>
</div>
<div class="plus">+</div>
<!-- CluBird -->
<div class="family-item">
<div style="width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#4338ca);display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(99,102,241,.3);">
<svg width="18" height="18" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="family-name"><span style="color:var(--t1);">Clu</span><span style="color:var(--acc);">Bird</span></div>
<div class="family-sub">Mail Management</div>
</div>
</div>
<div class="plus">=</div>
<!-- Aziros Suite -->
<div>
<div style="font-family:'Syne',sans-serif;font-size:15px;font-weight:800;color:var(--t1);">Aziros <span style="color:var(--acc);">Suite</span></div>
<div class="family-sub">Infrastructure + Mail</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/mailwolt-installer.iml" filepath="$PROJECT_DIR$/.idea/mailwolt-installer.iml" />
</modules>
</component>
</project>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@ -1,17 +0,0 @@
# ===================== HTTP (Port 80) =====================
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# ACME HTTP-01
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
allow all;
}
__HTTP_BODY__
}
# ===================== HTTPS (Port 443) ====================
__SSL_SERVER_BLOCK__

View File

@ -1,909 +0,0 @@
#!/usr/bin/env bash
(
export HISTFILE=
set +o history
set -euo pipefail
##############################################
# MailWolt #
# Bootstrap Installer v1.0 #
##############################################
# ===== CLI-Flags (-dev / -stag) =====
APP_ENV="${APP_ENV:-production}"
APP_DEBUG="${APP_DEBUG:-false}"
DEV_MODE=0
STAG_MODE=0
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
DEV_MODE=1
APP_ENV="local"
APP_DEBUG="true"
;;
-stag|-staging)
STAG_MODE=1
APP_ENV="staging"
APP_DEBUG="false"
;;
esac
shift
done
# ===== Branding & Pfade =====
APP_NAME="${APP_NAME:-MailWolt}"
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_DIR="/var/www/${APP_USER}"
ADMIN_USER="${APP_USER}"
ADMIN_EMAIL="admin@localhost"
ADMIN_PASS="ChangeMe"
CONF_BASE="/etc/${APP_USER}"
CERT_DIR="${CONF_BASE}/ssl"
CERT="${CERT_DIR}/cert.pem"
KEY="${CERT_DIR}/key.pem"
NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf"
NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf"
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
GIT_REPO="${GIT_REPO:-http://10.10.20.81:3000/boban/mailwolt.git}"
GIT_BRANCH="${GIT_BRANCH:-main}"
NODE_SETUP="${NODE_SETUP:-deb}"
# ===== Styling =====
GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m"
BAR="──────────────────────────────────────────────────────────────────────────────"
header() {
echo -e "${CYAN}${BAR}${NC}"
echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}"
echo -e "${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}"
echo -e "${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}"
echo -e "${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}"
echo -e "${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}"
echo -e "${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}"
echo -e "${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}"
echo -e "${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}"
echo -e "${CYAN}${BAR}${NC}"
echo
}
print_bootstrap_summary() {
local ip="$1"
local admin_user="$2"
local admin_pass="$3"
local GREEN="\033[1;32m"
local CYAN="\033[1;36m"
local GREY="\033[0;90m"
local YELLOW="\033[1;33m"
local RED="\033[1;31m"
local NC="\033[0m"
local BAR="${BAR:-──────────────────────────────────────────────────────────────────────────────}"
local scheme="http"
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then scheme="https"; fi
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN}${APP_NAME} Bootstrap erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e " Bootstrap-Login (nur für ERSTEN Login & Wizard):"
echo -e " User: ${YELLOW}${admin_user}${NC}"
echo -e " Passwort: ${RED}${admin_pass}${NC}"
echo
echo -e " Aufruf: ${CYAN}${scheme}://${ip}${NC}"
echo -e " Laravel Root: ${GREY}${APP_DIR}${NC}"
echo -e " Nginx Site: ${GREY}${NGINX_SITE}${NC}"
echo -e " Self-signed Cert: ${GREY}${CERT_DIR}/{cert.pem,key.pem}${NC}"
echo -e " Postfix/Dovecot Ports aktiv: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e " Rspamd/OpenDKIM: ${GREY}aktiv (DKIM-Keys später im Wizard)${NC}"
echo -e " Monit (Watchdog): ${GREY}installiert, NICHT aktiviert${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo
}
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[x]${NC} $*"; }
require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; }
detect_ip() {
local ip
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || { err "Konnte Server-IP nicht ermitteln."; exit 1; }
echo "$ip"
}
gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" || true; }
pw() { gen 28; }
short() { gen 16; }
# ===== Start =====
require_root
header
SERVER_IP="$(detect_ip)"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}"
TZ="${TZ:-""}"
echo -e "${GREY}Server-IP erkannt: ${SERVER_IP}${NC}"
[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; }
log "Paketquellen aktualisieren…"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
# ---- MariaDB-Workaround ----
log "MariaDB-Workaround vorbereiten…"
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[ -f /etc/mysql/mariadb.cnf ] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
# ---- Basis-Pakete installieren ----
log "Pakete installieren… (dies kann einige Minuten dauern)"
export DEBIAN_FRONTEND=noninteractive
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client \
redis-server \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
composer git \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit acl
NGINX_HTTP2_SUPPORTED=0
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUPPORTED=1
log "Nginx: HTTP/2-Unterstützung vorhanden ✅"
else
warn "Nginx: HTTP/2-Modul nicht gefunden wechsle auf 'nginx-full'…"
apt-get install -y nginx-full || true
systemctl restart nginx || true
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUPPORTED=1
log "Nginx: HTTP/2 jetzt verfügbar ✅"
else
warn "HTTP/2 weiterhin nicht verfügbar (verwende SSL ohne http2)."
fi
fi
if [ "$NGINX_HTTP2_SUPPORTED" = "1" ]; then
NGINX_HTTP2_SUFFIX=" http2"
else
NGINX_HTTP2_SUFFIX=""
fi
# ===== Verzeichnisse / User =====
log "Verzeichnisse und Benutzer anlegen…"
mkdir -p "${CERT_DIR}" /etc/postfix/sql /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
chown -R vmail:vmail /var/mail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
usermod -a -G "$APP_GROUP" "$APP_USER"
# ===== Self-signed TLS (SAN = IP) =====
OSSL_CFG="${CERT_DIR}/openssl.cnf"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
log "Erzeuge Self-Signed TLS Zertifikat (SAN=IP:${SERVER_IP})…"
install -d -m 0750 -o root -g "${APP_USER}" "${CERT_DIR}"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${SERVER_IP}
O = ${APP_NAME}
C = DE
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chown root:"${APP_USER}" "$KEY" "$CERT"
chmod 640 "$KEY" "$CERT"
chmod 750 "${CERT_DIR}"
fi
DEV_USER="${SUDO_USER:-$USER}"
if command -v setfacl >/dev/null 2>&1; then
setfacl -m u:${DEV_USER}:x "${CONF_BASE}" "${CERT_DIR}" || true
setfacl -m u:${DEV_USER}:r "$CERT" "$KEY" || true
fi
# ===== MariaDB =====
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME}
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
ALTER USER '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
# ===== Postfix =====
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "smtpd_tls_cert_file = ${CERT}"
postconf -e "smtpd_tls_key_file = ${KEY}"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtp_tls_security_level = may"
postconf -e "smtpd_tls_received_header = yes"
postconf -e "disable_vrfy_command = yes"
postconf -e "smtpd_helo_required = yes"
postconf -e "milter_default_action = accept"
postconf -e "milter_protocol = 6"
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "pickup/unix=pickup unix n - y 60 1 pickup"
postconf -M "cleanup/unix=cleanup unix n - y - 0 cleanup"
postconf -M "qmgr/unix=qmgr unix n - n 300 1 qmgr"
install -d -o root -g postfix -m 750 /etc/postfix/sql
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-alias-maps.cf
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id WHERE a.source = '%s' AND a.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
systemctl restart postfix
systemctl enable --now postfix
# ===== Dovecot =====
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox { inbox = yes }
mail_privileged_group = mail
CONF
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
CONF
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
# password_query = SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%u' AND is_active = 1 LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext }
userdb { driver = static args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n }
CONF
sudo chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext
sudo chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp { mode = 0600 user = postfix group = postfix }
}
service auth {
unix_listener /var/spool/postfix/private/auth { mode = 0660 user = postfix group = postfix }
}
service imap-login {
inet_listener imap { port = 143 }
inet_listener imaps { port = 993 ssl = yes }
}
service pop3-login {
inet_listener pop3 { port = 110 }
inet_listener pop3s { port = 995 ssl = yes }
}
CONF
cat > /etc/dovecot/conf.d/10-ssl.conf <<CONF
ssl = required
ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
sudo mkdir -p /var/spool/postfix/private
sudo chown postfix:postfix /var/spool/postfix /var/spool/postfix/private
sudo chmod 0755 /var/spool/postfix /var/spool/postfix/private
sudo systemctl restart dovecot
sudo systemctl restart postfix
systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
systemctl enable --now rspamd || true
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
# KeyTable / SigningTable werden nach dem Wizard gesetzt
CONF
systemctl enable --now opendkim || true
# ===== Redis =====
log "Redis absichern (Passwort setzen & nur localhost)…"
REDIS_CONF="/etc/redis/redis.conf"
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
sed -i 's/^\s*#\?\s*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
sed -i 's/^\s*#\?\s*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
if grep -qE '^\s*#?\s*requirepass ' "$REDIS_CONF"; then
sed -i "s/^\s*#\?\s*requirepass .*/requirepass ${REDIS_PASS}/" "$REDIS_CONF"
else
printf "\nrequirepass %s\n" "${REDIS_PASS}" >> "$REDIS_CONF"
fi
systemctl enable --now redis-server
systemctl restart redis-server
# ===== Nginx =====
log "Nginx konfigurieren…"
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
detect_php_fpm_sock() {
for v in 8.3 8.2 8.1 8.0 7.4; do s="/run/php/php${v}-fpm.sock"; [ -S "$s" ] && { echo "$s"; return; }; done
[ -S "/run/php/php-fpm.sock" ] && { echo "/run/php/php-fpm.sock"; return; }
echo "127.0.0.1:9000"
}
PHP_FPM_SOCK="$(detect_php_fpm_sock)"
install -d -m 0755 /var/www/letsencrypt
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then
cat > "${NGINX_SITE}" <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; allow all; }
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name _;
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_ssl_access.log;
error_log /var/log/nginx/${APP_USER}_ssl_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:${PHP_FPM_SOCK}; }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
CONF
else
cat > "${NGINX_SITE}" <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:${PHP_FPM_SOCK}; }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
ln -sf "${NGINX_SITE}" "${NGINX_SITE_LINK}"
if nginx -t; then
systemctl enable --now nginx
systemctl reload nginx || true
else
echo "[x] Nginx-Konfiguration fehlerhaft bitte /var/log/nginx/* prüfen."
exit 1
fi
# ===== Laravel Projekt =====
log "Laravel bereitstellen…"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
log "Git Repo vorbereiten…"
if [ "${GIT_REPO}" = "https://example.com/your-repo-placeholder.git" ]; then
if [ ! -d "${APP_DIR}" ] || [ -z "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]; then
sudo -u "$APP_USER" -H bash -lc "cd /var/www && COMPOSER_ALLOW_SUPERUSER=0 composer create-project laravel/laravel ${APP_USER} --no-interaction"
fi
else
if [ ! -d "${APP_DIR}/.git" ]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
sudo -u "$APP_USER" -H bash -lc "
set -e
cd ${APP_DIR}
git checkout ${GIT_BRANCH} 2>/dev/null || git checkout -B ${GIT_BRANCH}
git fetch --depth=1 origin ${GIT_BRANCH}
if git merge-base --is-ancestor HEAD origin/${GIT_BRANCH}; then
git pull --ff-only
else
echo '[i] Non-fast-forward erkannt setze hart auf origin/${GIT_BRANCH}.' >&2
git reset --hard origin/${GIT_BRANCH}
git clean -fd
fi
"
fi
if [ -f "${APP_DIR}/composer.json" ]; then
if [ "${DEV_MODE}" = "1" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist --no-dev"
fi
fi
fi
# ===== Node / Frontend =====
if [ -f "${APP_DIR}/package.json" ]; then
log "Node/NPM installieren…"
if command -v node >/dev/null 2>&1; then
NODE_MAJ=$(node -v | sed 's/^v//' | cut -d. -f1)
NODE_MIN=$(node -v | sed 's/^v//' | cut -d. -f2)
if [ "$NODE_MAJ" -lt 20 ] || { [ "$NODE_MAJ" -eq 20 ] && [ "$NODE_MIN" -lt 19 ]; }; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
else
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
# .env anlegen & APP_KEY
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
if ! grep -q '^APP_KEY=' "${APP_DIR}/.env"; then echo "APP_KEY=" >> "${APP_DIR}/.env"; fi
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
fi
# ===== .env füllen =====
ENV_FILE="${APP_DIR}/.env"
upsert_env () {
local key="$1" val="$2"
local esc_key esc_val
esc_key="$(printf '%s' "$key" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"
esc_val="$(printf '%s' "$val" | sed -e 's/[&/]/\\&/g')"
if grep -qE "^[#[:space:]]*${esc_key}=" "$ENV_FILE"; then
sed -Ei "s|^[#[:space:]]*${esc_key}=.*|${key}=${esc_val}|g" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then
upsert_env APP_URL "\"https://\${APP_HOST}\""
else
upsert_env APP_URL "\"http://\${APP_HOST}\""
fi
upsert_env APP_HOST "${SERVER_IP}"
upsert_env APP_ADMIN_USER "${ADMIN_USER}"
upsert_env APP_ADMIN_EMAIL "${ADMIN_EMAIL}"
upsert_env APP_ADMIN_PASS "${ADMIN_PASS}"
upsert_env APP_NAME "${APP_NAME}"
upsert_env APP_ENV "${APP_ENV}"
upsert_env APP_DEBUG "${APP_DEBUG}"
upsert_env DB_CONNECTION "mysql"
upsert_env DB_HOST "127.0.0.1"
upsert_env DB_PORT "3306"
upsert_env DB_DATABASE "${DB_NAME}"
upsert_env DB_USERNAME "${DB_USER}"
upsert_env DB_PASSWORD "${DB_PASS}"
# -------- WICHTIG: Cache-Store auf REDIS setzen (verhindert DB-Tabellenfehler) --------
upsert_env CACHE_SETTINGS_STORE "redis"
upsert_env CACHE_STORE "redis" # <- HIER geändert (vorher: database)
upsert_env CACHE_DRIVER "redis"
upsert_env CACHE_PREFIX "${APP_USER}_cache"
upsert_env SESSION_DRIVER "redis"
upsert_env REDIS_CLIENT "phpredis"
upsert_env REDIS_HOST "127.0.0.1"
upsert_env REDIS_PORT "6379"
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
upsert_env REDIS_DB "0"
upsert_env REDIS_CACHE_DB "1"
upsert_env REDIS_CACHE_CONNECTION "cache"
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
# Reverb / Vite
upsert_env BROADCAST_DRIVER "reverb"
upsert_env QUEUE_CONNECTION "redis"
upsert_env REVERB_APP_ID "${APP_USER}"
upsert_env REVERB_APP_KEY "${APP_USER}-yhp47tbt1aebhr1fgvgj"
upsert_env REVERB_APP_SECRET "${APP_USER}-ulrdt9agwzkqwqsunbnb"
upsert_env REVERB_HOST "127.0.0.1"
upsert_env REVERB_PORT "443"
upsert_env REVERB_SCHEME "https"
upsert_env REVERB_PATH "/ws"
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
if [ "${DEV_MODE}" = "1" ]; then
sed -i '/^# --- MailWolt DEV/,/^# --- \/MailWolt DEV/d' "${ENV_FILE}"
cat >> "${ENV_FILE}" <<CONF
# --- MailWolt DEV ---
VITE_DEV_HOST=127.0.0.1
VITE_DEV_PORT=5173
VITE_HMR_PROTOCOL=wss
VITE_HMR_CLIENT_PORT=443
VITE_HMR_HOST=${APP_HOST}
VITE_DEV_ORIGIN=${APP_URL}
# --- /MailWolt DEV ---
CONF
cat > "${APP_DIR}/vite.config.js" <<'JS'
import { defineConfig, loadEnv } from 'vite'
import laravel from 'laravel-vite-plugin'
import tailwindcss from '@tailwindcss/vite'
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const host = env.VITE_DEV_HOST || '127.0.0.1'
const port = Number(env.VITE_DEV_PORT || 5173)
const origin = env.VITE_DEV_ORIGIN || env.APP_URL || 'https://localhost'
const hmrHost = env.VITE_HMR_HOST || (new URL(origin)).hostname
return defineConfig({
plugins: [laravel({ input: ['resources/css/app.css','resources/js/app.js'], refresh: true }), tailwindcss()],
server: { host, port, https:false, strictPort:true,
hmr:{ protocol: env.VITE_HMR_PROTOCOL || 'wss', host: hmrHost, clientPort:Number(env.VITE_HMR_CLIENT_PORT||443) },
origin
}
})
}
JS
chown "${APP_USER}:${APP_GROUP}" "${APP_DIR}/vite.config.js"
fi
# ===== Frontend Build =====
if [ -f "${APP_DIR}/package.json" ]; then
log "Frontend Build…"
if [ -f "${APP_DIR}/package-lock.json" ] || [ -f "${APP_DIR}/npm-shrinkwrap.json" ]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm install"
fi
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build || true"
rm -f ${APP_DIR}/bootstrap/cache/*.php
fi
# ===== Rechte / PHP-FPM =====
APP_PW="${APP_PW:-changeme}"
if ! id -u "$APP_USER" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "$APP_USER"
echo "${APP_USER}:${APP_PW}" | chpasswd
fi
usermod -a -G "$APP_GROUP" "$APP_USER"
# Sichert, dass alle nötigen Ordner existieren (idempotent)
install -d -m 0775 "${APP_DIR}/storage" \
"${APP_DIR}/storage/framework" \
"${APP_DIR}/storage/framework/cache" \
"${APP_DIR}/storage/framework/cache/data" \
"${APP_DIR}/storage/framework/sessions" \
"${APP_DIR}/storage/framework/views" \
"${APP_DIR}/bootstrap/cache"
# Besitz & Rechte
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
[ -f "$APP_DIR/artisan" ] && chmod 755 "$APP_DIR/artisan"
[ -d "$APP_DIR/vendor/bin" ] && chmod -R 755 "$APP_DIR/vendor/bin"
[ -d "$APP_DIR/node_modules/.bin" ] && chmod -R 755 "$APP_DIR/node_modules/.bin"
[ -f "$APP_DIR/node_modules/vite/bin/vite.js" ] && chmod 755 "$APP_DIR/node_modules/vite/bin/vite.js"
find "$APP_DIR" -type f -name "*.sh" -exec chmod 755 {} \;
chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache
if command -v setfacl >/dev/null 2>&1; then
setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true
fi
grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile
grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc
sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002" >/dev/null 2>&1 || true
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL"
systemctl restart php${PHPV}-fpm || true
fi
IDE_USER="${SUDO_USER:-}"
if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then
usermod -a -G "$APP_GROUP" "$IDE_USER" || true
setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
echo -e "${GREEN}[i]${NC} Benutzer '${IDE_USER}' wurde für Schreibzugriff freigeschaltet (ACL + Gruppe ${APP_GROUP})."
fi
# Webstack neu laden
systemctl reload nginx || true
systemctl restart php*-fpm || true
# ===== Reverb systemd =====
cat > /etc/systemd/system/mailwolt-ws.service <<EOF
[Unit]
Description=MailWolt WebSocket Backend
After=network.target
[Service]
Type=simple
Environment=NODE_ENV=production WS_PORT=8080
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=2
StandardOutput=append:/var/log/mailwolt-ws.log
StandardError=append:/var/log/mailwolt-ws.log
KillSignal=SIGINT
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/mailwolt-ws.service
chmod 644 /etc/systemd/system/mailwolt-ws.service
touch /var/log/mailwolt-ws.log
chown ${APP_USER}:${APP_GROUP} /var/log/mailwolt-ws.log
chmod 664 /var/log/mailwolt-ws.log
install -d -m 775 -o "${APP_USER}" -g "${APP_GROUP}" \
"${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
# ---- Laravel-Caches als APP_USER aufräumen (funktioniert jetzt ohne DB-Cache-Tabelle) ----
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache"
# HINWEIS: Wenn du unbedingt DATABASE als Cache-Store willst, dann vor obiger Zeile:
# sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan cache:table && php artisan migrate --force"
systemctl daemon-reload
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi 2>/dev/null | grep -qE '(^| )reverb:start( |$)'"; then
systemctl enable --now mailwolt-ws
else
systemctl disable --now mailwolt-ws >/dev/null 2>&1 || true
fi
# ===== Monit =====
log "Monit konfigurieren & starten…"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcp ssl then restart
if failed port 587 type tcp then restart
check process dovecot with pidfile /var/run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
if failed port 110 type tcp then restart
if failed port 995 type tcp ssl then restart
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
check process redis-server with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
check process rspamd with pidfile /run/rspamd/rspamd.pid
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
if failed port 11332 type tcp then restart
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed port 8891 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
check process mailwolt-ws matching "reverb:start"
start program = "/bin/systemctl start mailwolt-ws"
stop program = "/bin/systemctl stop mailwolt-ws"
if failed host 127.0.0.1 port 8080 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
chmod 600 /etc/monit/monitrc
monit -t && systemctl enable --now monit
monit reload
monit summary || true
# ===== Healthchecks =====
GREEN="\033[1;32m"; RED="\033[1;31m"; GREY="\033[0;90m"; NC="\033[0m"
ok(){ echo -e " [${GREEN}OK${NC}]"; }
fail(){ echo -e " [${RED}FAIL${NC}]"; }
echo "[+] Quick-Healthchecks…"
printf " • MariaDB … " ; mysqladmin ping --silent >/dev/null 2>&1 && ok || fail
printf " • Redis … " ; if command -v redis-cli >/dev/null 2>&1; then
if [ -n "${REDIS_PASS:-}" ] && [ "${REDIS_PASS}" != "null" ]; then
redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG && ok || fail
else
redis-cli ping 2>/dev/null | grep -q PONG && ok || fail
fi
else fail; fi
printf " • PHP-FPM … " ; if [[ "$PHP_FPM_SOCK" == 127.0.0.1:9000 ]]; then ss -ltn | grep -q ":9000 " && ok || fail; else [ -S "$PHP_FPM_SOCK" ] && ok || fail; fi
printf " • App … " ; if command -v curl >/dev/null 2>&1; then
if [ -s "${CERT}" ] && [ -s "${KEY}" ]; then curl -skI "https://127.0.0.1" >/dev/null 2>&1 && ok || fail; else curl -sI "http://127.0.0.1" >/dev/null 2>&1 && ok || fail; fi
else echo -e " ${GREY}(curl fehlt)${NC}"; fi
check_port(){ local label="$1" cmd="$2"; printf " • %-5s … " "$label"; timeout 8s bash -lc "$cmd" >/dev/null 2>&1 && ok || fail; }
check_port "25" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 25'
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof'
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof'
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110'
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof'
check_port "143" 'printf ". LOGOUT\r\n" | nc -w 3 127.0.0.1 143'
check_port "993" 'printf ". LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof'
print_bootstrap_summary "$SERVER_IP" "$ADMIN_USER" "$ADMIN_PASS"
# ===== MOTD =====
install -d /usr/local/bin
cat >/usr/local/bin/mw-motd <<'SH'
#!/usr/bin/env bash
set -euo pipefail
NC="\033[0m"; CY="\033[1;36m"; GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"; GY="\033[0;90m"
printf "\033[1;36m"
cat <<'ASCII'
:::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
+:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
+:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
### ### ### ### ########### ########## ### ### ######## ########## ###
ASCII
printf "\033[0m\n"
now="$(date '+%Y-%m-%d %H:%M:%S %Z')"
fqdn="$(hostname -f 2>/dev/null || hostname)"
ip_int="$(hostname -I 2>/dev/null | awk '{print $1}')"
ip_ext=""; command -v curl >/dev/null 2>&1 && ip_ext="$(curl -s --max-time 1 https://ifconfig.me || true)"
upt="$(uptime -p 2>/dev/null || true)"
cores="$(nproc 2>/dev/null || echo -n '?')"
mhz="$(LC_ALL=C lscpu 2>/dev/null | awk -F: '/MHz/{gsub(/ /,"",$2); printf("%.0f MHz",$2); exit}')"
[ -z "$mhz" ] && mhz="$(awk -F: '/cpu MHz/{printf("%.0f MHz",$2); exit}' /proc/cpuinfo 2>/dev/null)"
load="$(awk '{print $1" / "$2" / "$3}' /proc/loadavg 2>/dev/null)"
mem_total="$(awk '/MemTotal/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
mem_free="$(awk '/MemAvailable/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
svc_status(){ systemctl is-active --quiet "$1" && echo -e "${GR}OK${NC}" || echo -e "${RD}FAIL${NC}"; }
printf "${CY}Information as of:${NC} ${YE}%s${NC}\n" "$now"
printf "${GY}FQDN :${NC} %s\n" "$fqdn"
if [ -n "$ip_ext" ]; then printf "${GY}IP :${NC} %s ${GY}(external:${NC} %s${GY})${NC}\n" "${ip_int:-?}" "$ip_ext"; else printf "${GY}IP :${NC} %s\n" "${ip_int:-?}"; fi
printf "${GY}Uptime :${NC} %s\n" "${upt:-?}"
printf "${GY}Core(s) :${NC} %s core(s) at ${CY}%s${NC}\n" "$cores" "${mhz:-?}"
printf "${GY}Load :${NC} %s (1 / 5 / 15)\n" "${load:-?}"
printf "${GY}Memory :${NC} ${RD}%s${NC} ${GY}(free)${NC} / ${CY}%s${NC} ${GY}(total)${NC}\n" "${mem_free:-?}" "${mem_total:-?}"
echo
printf "${GY}Services :${NC} postfix: $(svc_status postfix) dovecot: $(svc_status dovecot) nginx: $(svc_status nginx) mariadb: $(svc_status mariadb) redis: $(svc_status redis)\n"
SH
chmod +x /usr/local/bin/mw-motd
if [ -d /etc/update-motd.d ]; then
cat >/etc/update-motd.d/10-mailwolt <<'SH'
#!/usr/bin/env bash
/usr/local/bin/mw-motd
SH
chmod +x /etc/update-motd.d/10-mailwolt
[ -f /etc/update-motd.d/50-motd-news ] && chmod -x /etc/update-motd.d/50-motd-news || true
[ -f /etc/update-motd.d/80-livepatch ] && chmod -x /etc/update-motd.d/80-livepatch || true
else
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
SH
fi
: > /etc/motd 2>/dev/null || true
)

View File

@ -1,125 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
if [ -r /etc/mailwolt/installer.env ]; then
. /etc/mailwolt/installer.env
fi
REDIS_PASS="${REDIS_PASS:-}"
SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
find "$SCRIPTS_DIR/.." -type f -name "*.sh" -exec sed -i 's/\r$//' {} \; || true
log "Pakete installieren …"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
# Minimal aber vollständig
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client redis-server rspamd opendkim opendkim-tools opendmarc clamav \
clamav-daemon nginx php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql \
php-redis php-gd unzip curl composer git certbot python3-certbot-nginx fail2ban ca-certificates \
rsyslog sudo openssl monit acl netcat-openbsd jq sqlite3
# <<< Apache konsequent entfernen >>>
systemctl disable --now apache2 >/dev/null 2>&1 || true
apt-get -y purge 'apache2*' >/dev/null 2>&1 || true
apt-get -y autoremove >/dev/null 2>&1 || true
log "Systemuser/Dirs …"
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
# Systemuser/Dirs …
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
# --- FIX: Gruppen und Berechtigungen für Maildir und Dovecot-Zugriff ---
# vmail soll primär der Gruppe "mail" angehören, zusätzlich dovecot
usermod -g mail -a -G dovecot vmail || true
# App-User in relevante Gruppen
usermod -a -G "$APP_GROUP" "$APP_USER" || true
usermod -a -G mail,dovecot "$APP_USER" || true
# Maildir-Baum für Gruppe mail lesbar
chgrp -R mail /var/mail/vhosts || true
chmod -R g+rx /var/mail/vhosts || true
# ACLs setzen, damit neue Verzeichnisse automatisch passende Rechte bekommen
setfacl -R -m g:mail:rx /var/mail/vhosts || true
setfacl -dR -m g:mail:rx /var/mail/vhosts || true
usermod -a -G "$APP_GROUP" "$APP_USER" || true
install -d -m 0755 -o root -g root /var/www
install -d -m 0775 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR"
SUDOERS_DKIM="/etc/sudoers.d/mailwolt-dkim"
cat > "${SUDOERS_DKIM}" <<'EOF'
Defaults!/usr/local/sbin/mailwolt-install-dkim !requiretty
Defaults!/usr/local/sbin/mailwolt-remove-dkim !requiretty
Defaults!/usr/bin/systemctl !requiretty
Defaults!/usr/bin/test !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install-dkim *
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-remove-dkim *
www-data ALL=(root) NOPASSWD: /usr/bin/systemctl reload opendkim
www-data ALL=(root) NOPASSWD: /usr/bin/test *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install-dkim *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-remove-dkim *
mailwolt ALL=(root) NOPASSWD: /usr/bin/systemctl reload opendkim
mailwolt ALL=(root) NOPASSWD: /usr/bin/test *
EOF
chown root:root "${SUDOERS_DKIM}"
chmod 440 "${SUDOERS_DKIM}"
if ! visudo -c -f "${SUDOERS_DKIM}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_DKIM} entferne Datei."
rm -f "${SUDOERS_DKIM}"
fi
SUDOERS_DOVEADM="/etc/sudoers.d/mailwolt-doveadm"
cat > "${SUDOERS_DOVEADM}" <<'EOF'
Cmnd_Alias MW_DOVEADM_STATUS = /usr/bin/doveadm -f tab mailbox status -u * messages INBOX, \
/usr/bin/doveadm mailbox status -u * messages INBOX
www-data ALL=(vmail) NOPASSWD: MW_DOVEADM_STATUS
mailwolt ALL=(vmail) NOPASSWD: MW_DOVEADM_STATUS
EOF
chown root:root "${SUDOERS_DOVEADM}"
chmod 440 "${SUDOERS_DOVEADM}"
visudo -c -f "${SUDOERS_DOVEADM}" || rm -f "${SUDOERS_DOVEADM}"
log "MariaDB include-fix …"
mkdir -p /etc/mysql/mariadb.conf.d
[[ -f /etc/mysql/mariadb.cnf ]] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
log "Redis absichern …"
if [[ -z "${REDIS_PASS:-}" || "${REDIS_PASS}" == "changeme" ]]; then
REDIS_PASS="$(openssl rand -hex 16)"
export REDIS_PASS
log "Neues Redis-Passwort generiert."
fi
# Aktiven Redis-Config-Pfad aus systemd holen (Fallback: Standard)
REDIS_CONF="$(systemctl show -p ExecStart redis-server \
| sed -n 's/^ExecStart=.*redis-server[[:space:]]\+\([^[:space:]]\+\).*/\1/p')"
REDIS_CONF="${REDIS_CONF:-/etc/redis/redis.conf}"
# Bind + protected-mode hart setzen
sed -i 's/^[[:space:]]*#\?[[:space:]]*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
sed -i 's/^[[:space:]]*#\?[[:space:]]*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
# Vorherige requirepass-Zeilen entfernen (kommentiert/unkommentiert), dann neu schreiben
sed -i '/^[[:space:]]*#\?[[:space:]]*requirepass[[:space:]]\+/d' "$REDIS_CONF"
printf '\nrequirepass %s\n' "${REDIS_PASS}" >> "$REDIS_CONF"
# Dienst aktivieren & neu starten
systemctl enable --now redis-server
systemctl restart redis-server || true
# Sanity-Check (kein harter Exit, nur Log)
if redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG; then
log "Redis mit Passwort OK."
else
warn "Redis PING mit Passwort fehlgeschlagen bitte /etc/redis/redis.conf prüfen."
fi

View File

@ -1,60 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
CONF_BASE="/etc/${APP_USER}"
CERT_DIR="${CONF_BASE}/ssl"
UI_SSL_DIR="/etc/ssl/ui"; WEBMAIL_SSL_DIR="/etc/ssl/webmail"; MAIL_SSL_DIR="/etc/ssl/mail"
UI_CERT="${UI_SSL_DIR}/fullchain.pem"; UI_KEY="${UI_SSL_DIR}/privkey.pem"
WEBMAIL_CERT="${WEBMAIL_SSL_DIR}/fullchain.pem"; WEBMAIL_KEY="${WEBMAIL_SSL_DIR}/privkey.pem"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"; MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
install -d -m 0750 "$CERT_DIR"
CERT="${CERT_DIR}/cert.pem"; KEY="${CERT_DIR}/key.pem"
if [[ ! -s "$CERT" || ! -s "$KEY" ]]; then
log "Self-signed Zertifikat erzeugen …"
OSSL_CFG="${CERT_DIR}/openssl.cnf"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits=2048
prompt=no
default_md=sha256
req_extensions=req_ext
distinguished_name=dn
[dn]
CN=${SERVER_PUBLIC_IPV4}
O=${APP_NAME}
C=DE
[req_ext]
subjectAltName=@alt_names
[alt_names]
IP.1=${SERVER_PUBLIC_IPV4}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes -keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chgrp www-data "$CERT" "$KEY" || true
chmod 640 "$KEY" "$CERT"
fi
install -d -m 0755 "$UI_SSL_DIR" "$WEBMAIL_SSL_DIR" "$MAIL_SSL_DIR"
ln -sf "$CERT" "$UI_CERT"; ln -sf "$KEY" "$UI_KEY"
ln -sf "$CERT" "$WEBMAIL_CERT";ln -sf "$KEY" "$WEBMAIL_KEY"
ln -sf "$CERT" "$MAIL_CERT"; ln -sf "$KEY" "$MAIL_KEY"
# --- Mail-Zertifikate: Rechte für Postfix & Dovecot -------------------------
# WICHTIG: Rechte am *Target* (KEY/CERT im $CERT_DIR) setzen, nicht an den Symlinks.
if [[ -f "$KEY" && -f "$CERT" ]]; then
echo "[+] Setze Berechtigungen für Mail-Zertifikate …"
# Key: nur root + Gruppe lesen. Gruppe → postfix
chgrp postfix "$KEY" || true
chmod 640 "$KEY" || true
# Dovecot zusätzlich Leserechte via ACL
setfacl -m u:dovecot:r "$KEY" || true
# Zertifikat darf weltweit lesbar sein
chmod 644 "$CERT" || true
else
echo "[!] Zertifikatsdateien fehlen: $KEY oder $CERT" >&2
fi
# Optional: kurze Info, wohin verlinkt wurde
echo "[i] Mail TLS: $MAIL_CERT -> $CERT ; $MAIL_KEY -> $KEY"

View File

@ -1,588 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
# -------------------------------------------------------------------
# 2) POSIX-kompatibler Deploy-Wrapper (von Certbot aufgerufen)
# -------------------------------------------------------------------
cat >/usr/local/sbin/mailwolt-deploy.sh <<'WRAP'
#!/bin/sh
# POSIX-safe Certbot deploy-hook (ohne bashisms)
set -eu
# Installer-ENV laden (liefert UI_HOST/WEBMAIL_HOST/MAIL_HOSTNAME etc.)
if [ -r /etc/mailwolt/installer.env ]; then
. /etc/mailwolt/installer.env
fi
UI_HOST="${UI_HOST:-}"
WEBMAIL_HOST="${WEBMAIL_HOST:-}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
ACME_BASE="/etc/letsencrypt/live"
copy_cert() {
le_base="$1" # z.B. /etc/letsencrypt/live/ui.example.com
target_dir="$2" # z.B. /etc/ssl/ui
cert="${le_base}/fullchain.pem"
key="${le_base}/privkey.pem"
[ -s "$cert" ] || { echo "[deploy] missing $cert"; return 1; }
[ -s "$key" ] || { echo "[deploy] missing $key"; return 1; }
mkdir -p "$target_dir"
# echte Dateien (keine Symlinks), feste Rechte
install -m 0644 "$cert" "${target_dir}/fullchain.pem"
install -m 0600 "$key" "${target_dir}/privkey.pem"
echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
}
reload_services() {
kind="$1" # ui | mail
if command -v systemctl >/dev/null 2>&1; then
if [ "$kind" = "mail" ]; then
systemctl reload postfix 2>/dev/null || true
systemctl reload dovecot 2>/dev/null || true
else
systemctl reload nginx 2>/dev/null || true
fi
fi
}
# Certbot-Kontext
LINEAGE="${RENEWED_LINEAGE:-}"
HOST=""
if [ -n "$LINEAGE" ]; then
HOST="$(basename "$LINEAGE")"
fi
did_any=0
maybe_copy_for_host() {
host="$1"
dir="$2"
[ -n "$host" ] || return 0
# Fall A: Certbot liefert RENEWED_DOMAINS (Space-getrennt)
if [ -n "${RENEWED_DOMAINS:-}" ]; then
case " ${RENEWED_DOMAINS} " in
*" ${host} "*) copy_cert "${ACME_BASE}/${host}" "${dir}" && did_any=1 ;;
esac
return 0
fi
# Fall B: Erst-issue / kein RENEWED_DOMAINS → über LINEAGE matchen
if [ -n "$HOST" ] && [ "$HOST" = "$host" ]; then
copy_cert "${ACME_BASE}/${host}" "${dir}" && did_any=1
fi
}
# Gezieltes Kopieren
maybe_copy_for_host "$UI_HOST" "/etc/ssl/ui"
maybe_copy_for_host "$WEBMAIL_HOST" "/etc/ssl/webmail"
maybe_copy_for_host "$MAIL_HOSTNAME" "/etc/ssl/mail"
# Fallback (Erstlauf): kopiere vorhandene Lineages
if [ "$did_any" -eq 0 ]; then
[ -n "$UI_HOST" ] && [ -d "${ACME_BASE}/${UI_HOST}" ] && copy_cert "${ACME_BASE}/${UI_HOST}" "/etc/ssl/ui"
[ -n "$WEBMAIL_HOST" ] && [ -d "${ACME_BASE}/${WEBMAIL_HOST}" ] && copy_cert "${ACME_BASE}/${WEBMAIL_HOST}" "/etc/ssl/webmail"
[ -n "$MAIL_HOSTNAME" ] && [ -d "${ACME_BASE}/${MAIL_HOSTNAME}" ] && copy_cert "${ACME_BASE}/${MAIL_HOSTNAME}" "/etc/ssl/mail"
fi
# TLSA-Refresh (tolerant falls App noch nicht ready)
if command -v php >/dev/null 2>&1 && [ -f /var/www/mailwolt/artisan ]; then
(cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
fi
# Services neu laden
if [ -n "$HOST" ]; then
if [ -n "$MAIL_HOSTNAME" ] && [ "$HOST" = "$MAIL_HOSTNAME" ]; then
reload_services mail
else
reload_services ui
fi
else
reload_services ui
fi
exit 0
WRAP
chmod +x /usr/local/sbin/mailwolt-deploy.sh
# -------------------------------------------------------------------
# 3) Certbot deploy-hook, der den Wrapper aufruft
# -------------------------------------------------------------------
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh <<'HOOK'
#!/bin/sh
exec /usr/local/sbin/mailwolt-deploy.sh
HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh
log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
## Persistente Installer-Variablen (werden vom Wrapper gelesen)
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#SYSMAIL_SUB="${SYSMAIL_SUB}"
#SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN}"
#DKIM_ENABLE="${DKIM_ENABLE}"
#DKIM_SELECTOR="${DKIM_SELECTOR}"
#DKIM_GENERATE="${DKIM_GENERATE}"
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
## --- Kopieren statt Symlinks (damit Laravel lesen kann) ---------------------
#copy_cert() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
#
# [[ -s "$cert" && -s "$key" ]] || return 0
#
# install -d -m 0755 "$target_dir"
#
# # Vorhandene Symlinks entfernen, sonst kopierst du in die LE-Datei hinein
# [ -L "${target_dir}/fullchain.pem" ] && rm -f "${target_dir}/fullchain.pem"
# [ -L "${target_dir}/privkey.pem" ] && rm -f "${target_dir}/privkey.pem"
#
# # Echte Dateien ablegen
# install -m 0644 "$cert" "${target_dir}/fullchain.pem"
# install -m 0600 "$key" "${target_dir}/privkey.pem"
#
# echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
#}
#
## Nur Domains bearbeiten, die in diesem Lauf betroffen sind.
## Bei manchen Distros ist RENEWED_DOMAINS auf Erst-issue leer -> Fallback nutzen.
#RDOMS=" ${RENEWED_DOMAINS:-} "
#did_any=0
#
#maybe_copy_for() {
# local host="$1" dir="$2"
# [[ -z "$host" ]] && return 0
# if [[ "$RDOMS" == *" ${host} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${host}" "${dir}"
# did_any=1
# fi
#}
#
## 1) Normalfall: nur die vom Certbot gemeldeten Hosts kopieren
#maybe_copy_for "$UI_HOST" "/etc/ssl/ui"
#maybe_copy_for "$WEBMAIL_HOST" "/etc/ssl/webmail"
#maybe_copy_for "$MAIL_HOSTNAME" "/etc/ssl/mail"
#
## 2) Fallback: Beim Erstlauf/Edge-Cases alles kopieren, was bereits existiert
#if [[ "$did_any" -eq 0 ]]; then
# [[ -n "$UI_HOST" && -d "/etc/letsencrypt/live/${UI_HOST}" ]] && copy_cert "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
# [[ -n "$WEBMAIL_HOST" && -d "/etc/letsencrypt/live/${WEBMAIL_HOST}" ]] && copy_cert "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
# [[ -n "$MAIL_HOSTNAME" && -d "/etc/letsencrypt/live/${MAIL_HOSTNAME}"]] && copy_cert "/etc/letsencrypt/live/${MAIL_HOSTNAME}"/etc/ssl/mail
#fi
#
## Optional: TLSA via Laravel (tolerant, falls App noch nicht gebaut)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ] && [ -f /var/www/mailwolt/artisan ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hook: ruft den Wrapper bei jeder erfolgreichen Ausstellung/Renew auf
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-certs.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
## Persistente Installer-Variablen (werden vom Wrapper gelesen)
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
## --- Kopieren statt Symlinks (damit Laravel lesen kann) ---------------------
#copy_cert() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
#
# [[ -s "$cert" && -s "$key" ]] || return 0
#
# # Zielordner sicherstellen
# install -d -m 0755 "$target_dir"
#
# # Falls vorher Symlinks existieren → entfernen, sonst würde "install" das Ziel des Links überschreiben
# [ -L "${target_dir}/fullchain.pem" ] && rm -f "${target_dir}/fullchain.pem"
# [ -L "${target_dir}/privkey.pem" ] && rm -f "${target_dir}/privkey.pem"
#
# # KOPIEREN mit sauberen Rechten (Chain world-readable, Key nur root)
# install -m 0644 "$cert" "${target_dir}/fullchain.pem"
# install -m 0600 "$key" "${target_dir}/privkey.pem"
#
# echo "[+] Copied ${target_dir}/fullchain.pem und privkey.pem ← ${le_base}"
#}
#
## Nur für Domains arbeiten, die in diesem Lauf betroffen sind
#RDOMS=" ${RENEWED_DOMAINS:-} "
#
## UI
#if [[ -n "$UI_HOST" && "$RDOMS" == *" ${UI_HOST} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#fi
## Webmail
#if [[ -n "$WEBMAIL_HOST" && "$RDOMS" == *" ${WEBMAIL_HOST} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#fi
## MX
#if [[ -n "$MAIL_HOSTNAME" && "$RDOMS" == *" ${MAIL_HOSTNAME} "* ]]; then
# copy_cert "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#fi
#
## Optional: TLSA via Laravel (still tolerant, falls App noch nicht gebaut)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ] && [ -f /var/www/mailwolt/artisan ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hook: ruft den Wrapper bei jeder erfolgreichen Ausstellung/Renew auf
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#BASE_DOMAIN=${BASE_DOMAIN}
#LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
#APP_ENV=${APP_ENV:-production}
#EOF
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper, den Certbot bei Issue/Renew aufruft
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
## Installer-Variablen laden (UI_HOST, WEBMAIL_HOST, MAIL_HOSTNAME, optional LE_EMAIL etc.)
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
#link_if() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
# [[ -s "$cert" && -s "$key" ]] || return 0
# install -d -m 0755 "$target_dir"
# ln -sf "$cert" "${target_dir}/fullchain.pem"
# ln -sf "$key" "${target_dir}/privkey.pem"
# chmod 644 "${target_dir}/fullchain.pem" 2>/dev/null || true
# chmod 600 "${target_dir}/privkey.pem" 2>/dev/null || true
# echo "[+] Linked ${target_dir} -> ${le_base}"
#}
#
## Nur für Domains arbeiten, die im aktuellen Lauf erneuert/ausgestellt wurden
#RDOMS=" ${RENEWED_DOMAINS:-} "
#
## UI
#if [[ -n "$UI_HOST" && "$RDOMS" == *" ${UI_HOST} "* ]]; then
# link_if "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#fi
## Webmail
#if [[ -n "$WEBMAIL_HOST" && "$RDOMS" == *" ${WEBMAIL_HOST} "* ]]; then
# link_if "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#fi
## MX
#if [[ -n "$MAIL_HOSTNAME" && "$RDOMS" == *" ${MAIL_HOSTNAME} "* ]]; then
# link_if "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#fi
#
## Optional: TLSA via Laravel, falls App schon vorhanden (sonst still überspringen)
#if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ]; then
# (cd /var/www/mailwolt && php artisan dns:tlsa:refresh) || true
#fi
#
## Nginx nur neu laden, wenn aktiv
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot-Deploy-Hooks einrichten (ruft nur den Wrapper auf)
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "Let's Encrypt Deploy-Hooks und Wrapper anlegen …"
#
## 1) Wrapper-Skript, das Symlinks setzt und Nginx reloaded
#cat >/usr/local/sbin/mw-deploy.sh <<'WRAP'
##!/usr/bin/env bash
#set -euo pipefail
#
#link_if() {
# local le_base="$1" target_dir="$2"
# local cert="${le_base}/fullchain.pem"
# local key="${le_base}/privkey.pem"
# [[ -s "$cert" && -s "$key" ]] || return 0
# install -d -m 0755 "$target_dir"
# ln -sf "$cert" "${target_dir}/fullchain.pem"
# ln -sf "$key" "${target_dir}/privkey.pem"
# chmod 644 "${target_dir}/fullchain.pem" 2>/dev/null || true
# chmod 600 "${target_dir}/privkey.pem" 2>/dev/null || true
# echo "[+] Linked ${target_dir} -> ${le_base}"
#}
#
#UI_HOST="${UI_HOST:-}"
#WEBMAIL_HOST="${WEBMAIL_HOST:-}"
#MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
#
#[[ -n "$UI_HOST" ]] && link_if "/etc/letsencrypt/live/${UI_HOST}" "/etc/ssl/ui"
#[[ -n "$WEBMAIL_HOST" ]] && link_if "/etc/letsencrypt/live/${WEBMAIL_HOST}" "/etc/ssl/webmail"
#[[ -n "$MAIL_HOSTNAME" ]] && link_if "/etc/letsencrypt/live/${MAIL_HOSTNAME}" "/etc/ssl/mail"
#
#if systemctl is-active --quiet nginx; then
# systemctl reload nginx || true
#fi
#WRAP
#
#chmod +x /usr/local/sbin/mw-deploy.sh
#
## 2) Certbot Deploy-Hook-Verzeichnis + Symlink für Renewals
#install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
#cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
##!/usr/bin/env bash
#exec /usr/local/sbin/mw-deploy.sh
#HOOK
#chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
#
#log "[✓] MailWolt Deploy-Hook eingerichtet"
#
###!/usr/bin/env bash
##set -euo pipefail
##source ./lib.sh
##
### ────────────────────────────────────────────────────────────────────────────
### 21-le-deploy-hook.sh
### • legt /etc/mailwolt/installer.env an (falls fehlt)
### • erzeugt Deploy-Hooks:
### - 50-mailwolt-symlinks.sh → verlinkt LE-Zerts nach /etc/ssl/{ui,webmail,mail}
### - 60-mailwolt-tlsa.sh → aktualisiert TLSA (3 1 1) für MX bei jedem Renew
### • KEIN Reload von Postfix/Dovecot (kommt später im Installer)
### ────────────────────────────────────────────────────────────────────────────
##
### 0) Hostnamen persistent speichern (für spätere Deploys)
##install -d -m 0755 /etc/mailwolt
##if [[ ! -f /etc/mailwolt/installer.env ]]; then
## cat >/etc/mailwolt/installer.env <<EOF
##UI_HOST=${UI_HOST}
##WEBMAIL_HOST=${WEBMAIL_HOST}
##MAIL_HOSTNAME=${MAIL_HOSTNAME}
##EOF
## echo "[+] /etc/mailwolt/installer.env erstellt."
##fi
##
### 1) Deploy-Hooks-Verzeichnis anlegen
##install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
##
### ────────────────────────────────────────────────────────────────────────────
### 2) 50-mailwolt-symlinks.sh
### ────────────────────────────────────────────────────────────────────────────
##cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<HOOK
###!/usr/bin/env bash
##set -euo pipefail
##
##UI_LE="/etc/letsencrypt/live/${UI_HOST}"
##WEBMAIL_LE="/etc/letsencrypt/live/${WEBMAIL_HOST}"
##MX_LE="/etc/letsencrypt/live/${MAIL_HOSTNAME}"
##
##UI_SSL_DIR="/etc/ssl/ui"
##WEBMAIL_SSL_DIR="/etc/ssl/webmail"
##MAIL_SSL_DIR="/etc/ssl/mail"
##
### Zielverzeichnisse anlegen (einmalig)
##install -d -m 0755 "\$UI_SSL_DIR" "\$WEBMAIL_SSL_DIR" "\$MAIL_SSL_DIR"
##
##link_if() {
## local le_base="\$1" target_dir="\$2"
## local cert="\${le_base}/fullchain.pem"
## local key="\${le_base}/privkey.pem"
## [[ -s "\$cert" && -s "\$key" ]] || return 0
## ln -sf "\$cert" "\${target_dir}/fullchain.pem"
## ln -sf "\$key" "\${target_dir}/privkey.pem"
## chmod 644 "\${target_dir}/fullchain.pem" 2>/dev/null || true
## chmod 600 "\${target_dir}/privkey.pem" 2>/dev/null || true
## echo "[+] Linked \${target_dir} -> \${le_base}"
##}
##
### Verlinken (nur wenn Host konfiguriert)
##[[ -n "${UI_HOST}" ]] && link_if "\$UI_LE" "\$UI_SSL_DIR"
##[[ -n "${WEBMAIL_HOST}" ]] && link_if "\$WEBMAIL_LE" "\$WEBMAIL_SSL_DIR"
##[[ -n "${MAIL_HOSTNAME}" ]] && link_if "\$MX_LE" "\$MAIL_SSL_DIR"
##
### Nur reloaden, wenn Nginx aktiv ist (Installer startet ihn später erst)
##if systemctl is-active --quiet nginx; then
## systemctl reload nginx || true
##fi
##HOOK
##chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh
##
### ────────────────────────────────────────────────────────────────────────────
### 3) 60-mailwolt-tlsa.sh
### → nutzt Laravel, falls vorhanden; sonst Fallback mit OpenSSL.
### → schreibt nur, wenn sich der Hash geändert hat (idempotent)
### ────────────────────────────────────────────────────────────────────────────
##cat >/etc/letsencrypt/renewal-hooks/deploy/60-mailwolt-tlsa.sh <<'HOOK'
###!/usr/bin/env bash
##set -euo pipefail
##
### installer.env lesen
##set +u
##[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
##set -u
##
##APP_ENV_VAL="${APP_ENV:-production}"
##BASE_DOMAIN_VAL="${BASE_DOMAIN:-example.com}"
##
##case "$APP_ENV_VAL" in
## local|dev|development) exit 0 ;;
##esac
##[ "$BASE_DOMAIN_VAL" = "example.com" ] && exit 0
##
##MX_HOST="${MAIL_HOSTNAME:-}"
##SERVICE="_25._tcp"
##DNS_DIR="/etc/mailwolt/dns"
##OUT_FILE="${DNS_DIR}/${MX_HOST}.tlsa.txt"
##
### Nur reagieren, wenn MX-Zertifikat betroffen war
##case " ${RENEWED_DOMAINS:-} " in
## *" ${MX_HOST} "*) ;;
## *) exit 0 ;;
##esac
##
##CERT="${RENEWED_LINEAGE}/fullchain.pem"
##[ -s "$CERT" ] || exit 0
##
### Wenn Laravel vorhanden ist → interner Command (DB + Datei idempotent)
##if command -v php >/dev/null 2>&1 && [ -d /var/www/mailwolt ]; then
## cd /var/www/mailwolt || exit 0
## php artisan dns:tlsa:refresh || true
## exit 0
##fi
##
### Fallback: nur Datei aktualisieren, wenn Hash sich ändert
##HASH="$(openssl x509 -in "$CERT" -noout -pubkey \
## | openssl pkey -pubin -outform DER \
## | openssl dgst -sha256 | sed 's/^.*= //')"
##NEW_LINE="${SERVICE}.${MX_HOST}. IN TLSA 3 1 1 ${HASH}"
##
##mkdir -p "$DNS_DIR"
##
##if [ -r "$OUT_FILE" ] && grep -q "IN TLSA" "$OUT_FILE"; then
## if grep -q "$HASH" "$OUT_FILE"; then
## echo "[TLSA] Unverändert kein Update nötig."
## exit 0
## fi
##fi
##
##echo "$NEW_LINE" > "$OUT_FILE"
##echo "[TLSA] Aktualisiert: $NEW_LINE"
##HOOK
##chmod +x /etc/letsencrypt/renewal-hooks/deploy/60-mailwolt-tlsa.sh
##
### ────────────────────────────────────────────────────────────────────────────
##echo "[✓] Deploy-Hooks installiert."

View File

@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Installiere DKIM-Helper …"
install -d -m 0755 /usr/local/sbin
cat >/usr/local/sbin/mailwolt-install-dkim <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1" # z.B. sysmail.toastra.com
SELECTOR="${2:-mwl1}"
[[ -n "$DOMAIN" ]] || { echo "Usage: $0 <domain> [selector]"; exit 2; }
KEYDIR="/etc/opendkim/keys/${DOMAIN}"
PRIV="${KEYDIR}/${SELECTOR}.private"
TXT="${KEYDIR}/${SELECTOR}.txt"
install -d -m 0750 -o opendkim -g opendkim "$KEYDIR"
if [[ ! -s "$PRIV" ]]; then
opendkim-genkey -b 2048 -s "$SELECTOR" -d "$DOMAIN" -D "$KEYDIR"
chown opendkim:opendkim "$PRIV"
chmod 600 "$PRIV"
fi
grep -q "^${SELECTOR}\._domainkey\.${DOMAIN} " /etc/opendkim/KeyTable 2>/dev/null \
|| echo "${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${PRIV}" >> /etc/opendkim/KeyTable
grep -q "^\*@${DOMAIN} " /etc/opendkim/SigningTable 2>/dev/null \
|| echo "*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}" >> /etc/opendkim/SigningTable
install -d -m 0755 /etc/mailwolt/dns
[[ -s "$TXT" ]] && cp -f "$TXT" "/etc/mailwolt/dns/dkim-${DOMAIN}.txt" || true
systemctl restart opendkim
EOF
log "[✓] DKIM-Helper installiert: /usr/local/sbin/mailwolt-install-dkim"

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "MariaDB vorbereiten …"
systemctl enable --now mariadb
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

View File

@ -1,133 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Postfix konfigurieren …"
# --- TLS-Dateirechte (falls du sie in /etc/mailwolt/ssl spiegelst) -----------
if [[ -e "${MAIL_KEY}" ]]; then
chgrp -R postfix /etc/mailwolt/ssl || true
chmod 750 /etc/mailwolt/ssl || true
chmod 640 /etc/mailwolt/ssl/key.pem /etc/mailwolt/ssl/cert.pem || true
fi
# --- Basiskonfiguration -------------------------------------------------------
/usr/sbin/postconf -e "myhostname = ${MAIL_HOSTNAME}"
/usr/sbin/postconf -e "myorigin = \$myhostname"
/usr/sbin/postconf -e "mydestination = "
/usr/sbin/postconf -e "inet_interfaces = all"
/usr/sbin/postconf -e "inet_protocols = all"
/usr/sbin/postconf -e "smtpd_banner = \$myhostname ESMTP"
# --- TLS ----------------------------------------------------------------------
/usr/sbin/postconf -e "smtpd_tls_cert_file = ${MAIL_CERT}"
/usr/sbin/postconf -e "smtpd_tls_key_file = ${MAIL_KEY}"
/usr/sbin/postconf -e "smtpd_tls_security_level = may"
/usr/sbin/postconf -e "smtpd_use_tls = yes"
/usr/sbin/postconf -e "smtpd_tls_received_header = yes"
/usr/sbin/postconf -e "smtpd_tls_protocols = !SSLv2,!SSLv3"
/usr/sbin/postconf -e "smtpd_tls_mandatory_protocols = !SSLv2,!SSLv3"
/usr/sbin/postconf -e "smtpd_tls_loglevel = 1"
/usr/sbin/postconf -e "smtp_tls_security_level = may"
/usr/sbin/postconf -e "smtp_tls_loglevel = 1"
DH_FILE="/etc/ssl/private/dhparam.pem"
if [[ ! -s "$DH_FILE" ]]; then
log "Generiere 2048-Bit DH-Parameter …"
openssl dhparam -out "$DH_FILE" 2048
chmod 600 "$DH_FILE"
chown root:root "$DH_FILE"
fi
/usr/sbin/postconf -e "smtpd_tls_dh1024_param_file = ${DH_FILE}"
/usr/sbin/postconf -e "smtpd_tls_dh1024_param_file = ${DH_FILE}"
/usr/sbin/postconf -e "smtpd_tls_eecdh_grade = strong"
/usr/sbin/postconf -e "tls_preempt_cipherlist = yes"
# Nur moderne TLS-Versionen (auch für ausgehendes SMTP)
# (überschreibt die älteren Zeilen oben)
/usr/sbin/postconf -e "smtpd_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
/usr/sbin/postconf -e "smtpd_tls_mandatory_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
/usr/sbin/postconf -e "smtp_tls_protocols = !SSLv2,!SSLv3,!TLSv1,!TLSv1.1"
# Hohe Cipher, alte raus
/usr/sbin/postconf -e "smtpd_tls_ciphers = high"
/usr/sbin/postconf -e "smtp_tls_ciphers = high"
/usr/sbin/postconf -e "smtpd_tls_exclude_ciphers = aNULL,eNULL,MD5,RC4,DES,3DES"
/usr/sbin/postconf -e "smtp_tls_exclude_ciphers = aNULL,eNULL,MD5,RC4,DES,3DES"
# --- SMTP Sicherheit ----------------------------------------------------------
/usr/sbin/postconf -e "disable_vrfy_command = yes"
/usr/sbin/postconf -e "smtpd_helo_required = yes"
# --- Milter -------------------------------------------------------------------
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
# --- SASL Auth via Dovecot ----------------------------------------------------
/usr/sbin/postconf -e "smtpd_sasl_type = dovecot"
/usr/sbin/postconf -e "smtpd_sasl_path = private/auth"
/usr/sbin/postconf -e "smtpd_sasl_auth_enable = yes"
/usr/sbin/postconf -e "smtpd_sasl_security_options = noanonymous"
# --- Recipient & Relay Restriction --------------------------------------------
/usr/sbin/postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
/usr/sbin/postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
# --- Listener / Master.cf Definition ------------------------------------------
/usr/sbin/postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
/usr/sbin/postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
/usr/sbin/postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
# postscreen ggf. deaktivieren
sed -i 's/^[[:space:]]*smtp[[:space:]]\+inet[[:space:]]\+.*postscreen/# &/' /etc/postfix/master.cf || true
# --- SQL Maps (Verzeichnis zuerst!) -------------------------------------------
install -d -o root -g postfix -m 750 /etc/postfix/sql
# Domains
cat > /etc/postfix/sql/mysql-virtual-domains.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT 1 FROM domains WHERE domain = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-domains.cf
chmod 640 /etc/postfix/sql/mysql-virtual-domains.cf
# Mailboxen
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND u.can_login = 1 AND u.password_hash IS NOT NULL AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
# Aliase
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT COALESCE(mu.email, r.email) AS destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id JOIN mail_alias_recipients r ON r.alias_id = a.id LEFT JOIN mail_users mu ON mu.id = r.mail_user_id WHERE d.domain = SUBSTRING_INDEX('%s','@',-1) AND a.local = SUBSTRING_INDEX('%s','@', 1) AND a.is_active = 1 AND d.is_active = 1 AND (mu.email IS NOT NULL OR r.email IS NOT NULL) ORDER BY r.position ASC;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
# Aktivieren
/usr/sbin/postconf -e "virtual_mailbox_domains = proxy:mysql:/etc/postfix/sql/mysql-virtual-domains.cf"
/usr/sbin/postconf -e "virtual_mailbox_maps = proxy:mysql:/etc/postfix/sql/mysql-virtual-mailbox-maps.cf"
/usr/sbin/postconf -e "virtual_alias_maps = proxy:mysql:/etc/postfix/sql/mysql-virtual-alias-maps.cf"
/usr/sbin/postconf -e "virtual_transport = lmtp:unix:private/dovecot-lmtp"
# --- Dienst aktivieren & neu laden --------------------------------------------
#systemctl enable postfix >/dev/null 2>&1 || true

View File

@ -1,247 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Dovecot konfigurieren …"
# ──────────────────────────────────────────────────────────────────────────────
# 1) vmail-Benutzer/Gruppe & Mailspool vorbereiten (DYNAMIC UID!)
# ──────────────────────────────────────────────────────────────────────────────
# Sicherstellen, dass die Gruppe 'mail' existiert (auf Debian/Ubuntu idR vorhanden)
getent group mail >/dev/null || groupadd -g 8 mail || true
# vmail anlegen, wenn er fehlt. Bevorzugt UID 109, falls frei sonst automatisch.
if ! getent passwd vmail >/dev/null; then
if ! getent passwd 109 >/dev/null; then
useradd -u 109 -g mail -d /var/mail -M -s /usr/sbin/nologin vmail
else
useradd -g mail -d /var/mail -M -s /usr/sbin/nologin vmail
fi
fi
# Tatsächliche vmail-UID ermitteln (wird unten in die Dovecot-Config geschrieben)
VMAIL_UID="$(id -u vmail)"
# Mailspool-Basis
install -d -m 0770 -o vmail -g mail /var/mail/vhosts
# ──────────────────────────────────────────────────────────────────────────────
# 2) Dovecot Grundgerüst
# ──────────────────────────────────────────────────────────────────────────────
# Hauptdatei
install -d -m 0755 /etc/dovecot/conf.d
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
# Mail-Location & Namespace + UID-Grenzen
cat > /etc/dovecot/conf.d/10-mail.conf <<CONF
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
}
mail_privileged_group = mail
mail_access_groups = mail
first_valid_uid = ${VMAIL_UID}
last_valid_uid = ${VMAIL_UID}
CONF
cat > /etc/dovecot/conf.d/15-mailboxes.conf <<'CONF'
namespace inbox {
inbox = yes
mailbox Drafts {
special_use = \Drafts
auto = subscribe
}
mailbox Junk {
special_use = \Junk
auto = subscribe
}
mailbox Trash {
special_use = \Trash
auto = subscribe
}
mailbox Sent {
special_use = \Sent
auto = subscribe
}
mailbox Archive {
special_use = \Archive
auto = create
}
}
CONF
# Auth
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
auth_cache_size = 10M
auth_cache_ttl = 1 hour
CONF
# SQL-Anbindung (Passwörter aus App-DB)
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
password_query = SELECT u.email AS user, u.password_hash AS password FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%u' AND u.is_active = 1 AND u.can_login = 1 AND u.password_hash IS NOT NULL AND d.is_active = 1 LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
# Auth-SQL → userdb static auf vmail:mail (Home unter /var/mail/vhosts/%d/%n)
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=mail home=/var/mail/vhosts/%d/%n
}
CONF
chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext
chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
# ──────────────────────────────────────────────────────────────────────────────
# 3) IMAP Optimierung (iOS/IDLE-freundlich)
# ──────────────────────────────────────────────────────────────────────────────
cat > /etc/dovecot/conf.d/20-imap.conf <<'CONF'
# IMAP-spezifische Einstellungen
imap_idle_notify_interval = 2 mins
imap_hibernate_timeout = 0
protocol imap {
mail_max_userip_connections = 20
imap_logout_format = in=%i out=%o deleted=%{deleted} expunged=%{expunged}
}
CONF
# ──────────────────────────────────────────────────────────────────────────────
# 4) Master Services (LMTP, AUTH, IMAP, POP3, STATS)
# ──────────────────────────────────────────────────────────────────────────────
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
unix_listener auth-userdb {
mode = 0660
user = vmail
group = mail
}
process_limit = 1
}
service imap-login {
inet_listener imap {
port = 143
}
inet_listener imaps {
port = 993
ssl = yes
}
process_limit = 128
process_min_avail = 10
service_count = 0
vsz_limit = 512M
}
service pop3-login {
inet_listener pop3 {
port = 110
}
inet_listener pop3s {
port = 995
ssl = yes
}
process_limit = 50
service_count = 0
}
CONF
# --- Dovecot: doveadm-server für App-Zugriff ---
cat >/etc/dovecot/conf.d/99-mailwolt-perms.conf <<'CONF'
service auth {
unix_listener auth-userdb {
mode = 0660
user = vmail
group = mail
}
}
service stats {
unix_listener stats-reader {
mode = 0660
user = vmail
group = mail
}
unix_listener stats-writer {
mode = 0660
user = vmail
group = mail
}
}
CONF
# ──────────────────────────────────────────────────────────────────────────────
# 5) SSL-Konfiguration (ohne DH-Param-Erzeugung)
# ──────────────────────────────────────────────────────────────────────────────
DOVECOT_SSL_CONF="/etc/dovecot/conf.d/10-ssl.conf"
touch "$DOVECOT_SSL_CONF"
grep -q '^ssl\s*=' "$DOVECOT_SSL_CONF" 2>/dev/null || echo "ssl = required" >> "$DOVECOT_SSL_CONF"
if grep -q '^\s*ssl_cert\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_cert\s*=.*|ssl_cert = <${MAIL_CERT}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_cert = <${MAIL_CERT}" >> "$DOVECOT_SSL_CONF"
fi
if grep -q '^\s*ssl_key\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_key\s*=.*|ssl_key = <${MAIL_KEY}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_key = <${MAIL_KEY}" >> "$DOVECOT_SSL_CONF"
fi
grep -q '^ssl_min_protocol' "$DOVECOT_SSL_CONF" || echo "ssl_min_protocol = TLSv1.2" >> "$DOVECOT_SSL_CONF"
grep -q '^ssl_prefer_server_ciphers' "$DOVECOT_SSL_CONF" || echo "ssl_prefer_server_ciphers = yes" >> "$DOVECOT_SSL_CONF"
grep -q '^ssl_dh' "$DOVECOT_SSL_CONF" || echo "ssl_dh = </etc/ssl/private/dhparam.pem" >> "$DOVECOT_SSL_CONF"
# ──────────────────────────────────────────────────────────────────────────────
# 6) Verzeichnisse & Rechte prüfen
# ──────────────────────────────────────────────────────────────────────────────
mkdir -p /var/spool/postfix/private
chown root:root /var/spool/postfix
chmod 0755 /var/spool/postfix
chown postfix:postfix /var/spool/postfix/private
chmod 0755 /var/spool/postfix/private
# ──────────────────────────────────────────────────────────────────────────────
# 7) Abschluss
# ──────────────────────────────────────────────────────────────────────────────
log "Dovecot-Konfiguration abgeschlossen."

View File

@ -1,319 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Rspamd + OpenDKIM einrichten …"
# ──────────────────────────────────────────────────────────────
# ENV laden
# ──────────────────────────────────────────────────────────────
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN:-sysmail.${BASE_DOMAIN}}" # z.B. sysmail.example.com
DKIM_ENABLE="${DKIM_ENABLE:-1}" # 1=OpenDKIM aktiv
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}" # z.B. mwl1
DKIM_GENERATE="${DKIM_GENERATE:-0}" # 1=Key generieren, falls fehlt
RSPAMD_CONTROLLER_PASSWORD="${RSPAMD_CONTROLLER_PASSWORD:-admin}"
# ──────────────────────────────────────────────────────────────
# Rspamd (Controller + Milter)
# ──────────────────────────────────────────────────────────────
install -d -m 0750 /etc/rspamd/local.d
if command -v rspamadm >/dev/null 2>&1; then
RSPAMD_HASH="$(rspamadm pw -p "${RSPAMD_CONTROLLER_PASSWORD}")"
else
RSPAMD_HASH="${RSPAMD_CONTROLLER_PASSWORD}"
fi
cat >/etc/rspamd/local.d/worker-controller.inc <<CONF
worker "controller" {
bind_socket = "127.0.0.1:11334";
password = "${RSPAMD_HASH}";
}
CONF
cat >/etc/rspamd/local.d/statistic.conf <<CONF
classifier "bayes" {
backend = "redis";
autolearn = true;
autolearn_threshold = 6.0;
ham_symbols = ["BAYES_HAM"];
spam_symbols = ["BAYES_SPAM"];
min_learns = 10;
store_tokens = true;
per_user = false;
}
CONF
cat >/etc/rspamd/local.d/worker-proxy.inc <<'CONF'
worker "proxy" {
bind_socket = "127.0.0.1:11332";
milter = yes;
timeout = 120s;
upstream "scan" {
default = yes;
self_scan = yes;
servers = "127.0.0.1:11333";
}
}
CONF
cat >/etc/rspamd/local.d/worker-normal.inc <<'CONF'
worker "normal" {
bind_socket = "127.0.0.1:11333";
}
CONF
cat >/etc/rspamd/local.d/milter_headers.conf <<'CONF'
use = ["authentication-results"];
header = "Authentication-Results";
CONF
cat >/etc/rspamd/local.d/options.inc <<'CONF'
dns {
servers = ["9.9.9.9:53", "1.1.1.1:53"];
timeout = 5s;
retransmits = 2;
}
CONF
# ──────────────────────────────────────────────────────────────
# Rspamd Redis-Konfiguration
# ──────────────────────────────────────────────────────────────
log "Rspamd Redis konfigurieren …"
: "${REDIS_PASS:=}"
cat >/etc/rspamd/local.d/redis.conf <<CONF
servers = "127.0.0.1:6379";
${REDIS_PASS:+password = "${REDIS_PASS}";}
db = 0;
CONF
# Eigentümer und Rechte setzen
chown root:_rspamd /etc/rspamd/local.d /etc/rspamd/local.d/redis.conf
chmod 750 /etc/rspamd/local.d
chmod 640 /etc/rspamd/local.d/redis.conf
# Testweise prüfen, ob Redis erreichbar ist (nicht kritisch)
if command -v redis-cli >/dev/null 2>&1; then
if [[ -n "${REDIS_PASS}" ]]; then
if redis-cli -h 127.0.0.1 -p 6379 -a "${REDIS_PASS}" ping >/dev/null 2>&1; then
log "[✓] Redis erreichbar und Passwort akzeptiert."
else
log "[!] Warnung: Redis antwortet nicht oder Passwort falsch."
fi
else
if redis-cli -h 127.0.0.1 -p 6379 ping >/dev/null 2>&1; then
log "[✓] Redis erreichbar (ohne Passwort)."
else
log "[!] Warnung: Redis antwortet nicht."
fi
fi
fi
systemctl enable --now rspamd || true
# ──────────────────────────────────────────────────────────────
# OpenDKIM nur wenn DKIM_ENABLE=1
# ──────────────────────────────────────────────────────────────
if [[ "${DKIM_ENABLE}" != "1" ]]; then
log "DKIM_ENABLE=0 → OpenDKIM wird übersprungen."
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332"
/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
exit 0
fi
install -d -m 0755 /etc/opendkim
install -d -m 0750 /etc/opendkim/keys
chown -R opendkim:opendkim /etc/opendkim
chmod 750 /etc/opendkim/keys
# TrustedHosts
cat >/etc/opendkim/TrustedHosts <<'CONF'
127.0.0.1
::1
localhost
CONF
chown opendkim:opendkim /etc/opendkim/TrustedHosts
chmod 640 /etc/opendkim/TrustedHosts
# ── Key-Verzeichnis für SYSMAIL_DOMAIN vorbereiten ───────────────────────────
KEY_DIR="/etc/opendkim/keys/${SYSMAIL_DOMAIN}"
KEY_PRIV="${KEY_DIR}/${DKIM_SELECTOR}.private"
KEY_DNSTXT="${KEY_DIR}/${DKIM_SELECTOR}.txt"
install -d -m 0750 -o opendkim -g opendkim "${KEY_DIR}"
# ── Key optional generieren (nur wenn gewünscht) ─────────────────────────────
if [[ ! -s "${KEY_PRIV}" && "${DKIM_GENERATE}" = "1" ]]; then
if command -v opendkim-genkey >/dev/null 2>&1; then
opendkim-genkey -b 2048 -s "${DKIM_SELECTOR}" -d "${SYSMAIL_DOMAIN}" -D "${KEY_DIR}"
chown opendkim:opendkim "${KEY_DIR}/${DKIM_SELECTOR}.private" || true
chmod 600 "${KEY_DIR}/${DKIM_SELECTOR}.private" || true
else
echo "[!] opendkim-genkey fehlt kann DKIM-Key nicht generieren."
fi
fi
# ── Key-/SigningTable nur anlegen, nicht leeren ───────────────────────────────
touch /etc/opendkim/KeyTable /etc/opendkim/SigningTable
chown opendkim:opendkim /etc/opendkim/KeyTable /etc/opendkim/SigningTable
chmod 640 /etc/opendkim/KeyTable /etc/opendkim/SigningTable
if [[ -s "${KEY_PRIV}" && "${BASE_DOMAIN}" != "example.com" ]]; then
LINE_KT="${DKIM_SELECTOR}._domainkey.${SYSMAIL_DOMAIN} ${SYSMAIL_DOMAIN}:${DKIM_SELECTOR}:${KEY_PRIV}"
LINE_ST="*@${SYSMAIL_DOMAIN} ${DKIM_SELECTOR}._domainkey.${SYSMAIL_DOMAIN}"
grep -Fqx "$LINE_KT" /etc/opendkim/KeyTable || echo "$LINE_KT" >> /etc/opendkim/KeyTable
grep -Fqx "$LINE_ST" /etc/opendkim/SigningTable || echo "$LINE_ST" >> /etc/opendkim/SigningTable
else
echo "[i] Kein Private Key unter ${KEY_PRIV} App-Helper trägt später ein."
fi
# ── Hauptkonfiguration ───────────────────────────────────────────────────────
cat >/etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
PidFile /run/opendkim/opendkim.pid
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
KeyTable /etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
UserID opendkim:opendkim
AutoRestart yes
AutoRestartRate 10/1h
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
SyslogSuccess yes
CONF
# ── systemd Drop-in: /run/opendkim sicherstellen ─────────────────────────────
install -d -m 0755 /etc/systemd/system/opendkim.service.d
cat >/etc/systemd/system/opendkim.service.d/override.conf <<'EOF'
[Service]
RuntimeDirectory=opendkim
RuntimeDirectoryMode=0755
EOF
install -d -o opendkim -g opendkim -m 0755 /run/opendkim
# ──────────────────────────────────────────────────────────────
# Root-Helper: DKIM installieren / entfernen + sudoers-Regel
# ──────────────────────────────────────────────────────────────
install -d -m 0750 /usr/local/sbin
# --- mailwolt-install-dkim ------------------------------------
cat > /usr/local/sbin/mailwolt-install-dkim <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1"
SELECTOR="$2"
SRC_PRIV="$3"
SRC_TXT="${4:-}"
OKDIR="/etc/opendkim"
KEYDIR="${OKDIR}/keys/${DOMAIN}"
KEYPRI="${KEYDIR}/${SELECTOR}.private"
install -d -m 0750 -o opendkim -g opendkim "${KEYDIR}"
install -m 0600 -o opendkim -g opendkim "${SRC_PRIV}" "${KEYPRI}"
KT="${OKDIR}/KeyTable"
ST="${OKDIR}/SigningTable"
touch "$KT" "$ST"
chown opendkim:opendkim "$KT" "$ST"
chmod 0640 "$KT" "$ST"
LINE_KT="${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:${KEYPRI}"
LINE_ST="*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}"
grep -Fqx "$LINE_KT" "$KT" || echo "$LINE_KT" >> "$KT"
grep -Fqx "$LINE_ST" "$ST" || echo "$LINE_ST" >> "$ST"
if [[ -n "${SRC_TXT}" && -s "${SRC_TXT}" ]]; then
install -d -m 0755 /etc/mailwolt/dns
cp -f "${SRC_TXT}" "/etc/mailwolt/dns/dkim-${DOMAIN}.txt"
fi
systemctl is-active --quiet opendkim && systemctl reload opendkim || true
echo "OK"
EOSH
chmod 0750 /usr/local/sbin/mailwolt-install-dkim
chown root:root /usr/local/sbin/mailwolt-install-dkim
# --- 2) mailwolt-remove-dkim ----------------------------------
cat >/usr/local/sbin/mailwolt-remove-dkim <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="$1" # z.B. kunden.tld oder sysmail.example.com
SELECTOR="$2" # z.B. mwl1
OKDIR="/etc/opendkim"
KEYDIR="${OKDIR}/keys/${DOMAIN}"
KEYPRI="${KEYDIR}/${SELECTOR}.private"
KT="${OKDIR}/KeyTable"
ST="${OKDIR}/SigningTable"
# Key-Datei löschen (falls vorhanden)
[[ -f "${KEYPRI}" ]] && rm -f "${KEYPRI}"
# Zeilen aus KeyTable und SigningTable entfernen
if [[ -f "$KT" ]]; then
tmp="$(mktemp)"; grep -v -F "${SELECTOR}._domainkey.${DOMAIN} ${DOMAIN}:${SELECTOR}:" "$KT" >"$tmp" && mv "$tmp" "$KT"
chown opendkim:opendkim "$KT"; chmod 0640 "$KT"
fi
if [[ -f "$ST" ]]; then
tmp="$(mktemp)"; grep -v -F "*@${DOMAIN} ${SELECTOR}._domainkey.${DOMAIN}" "$ST" >"$tmp" && mv "$tmp" "$ST"
chown opendkim:opendkim "$ST"; chmod 0640 "$ST"
fi
# Verzeichnis ggf. aufräumen
rmdir "${KEYDIR}" 2>/dev/null || true
# Dienst neu laden, falls aktiv
if systemctl is-active --quiet opendkim; then
systemctl reload opendkim || true
fi
echo "OK"
EOSH
chown root:root /usr/local/sbin/mailwolt-remove-dkim
chmod 0750 /usr/local/sbin/mailwolt-remove-dkim
# ── Dienst + Postfix-Milter aktivieren ─────────────────────────
systemctl daemon-reload
systemctl enable opendkim || true
touch /run/mailwolt.need-apply-milters || true
chgrp _rspamd /etc/rspamd/local.d/*.inc /etc/rspamd/local.d/*.conf || true
chmod 0640 /etc/rspamd/local.d/*.inc /etc/rspamd/local.d/*.conf || true
#/usr/sbin/postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
#/usr/sbin/postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
log "[✓] Rspamd + OpenDKIM eingerichtet (läuft; signiert, sobald Keys vorhanden sind)."

View File

@ -1,63 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "OpenDMARC installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
# Paket sicherstellen
if ! dpkg -s opendmarc >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y opendmarc
fi
# Config-Verzeichnisse
install -d -m 0755 /etc/opendmarc
install -d -m 0755 /run/opendmarc
# IgnoreHosts
cat >/etc/opendmarc/ignore.hosts <<'EOF'
127.0.0.1
::1
localhost
EOF
chmod 0644 /etc/opendmarc/ignore.hosts
# Hauptkonfiguration
cat >/etc/opendmarc.conf <<'EOF'
AuthservID mailwolt
TrustedAuthservIDs mailwolt
IgnoreHosts /etc/opendmarc/ignore.hosts
Syslog true
SoftwareHeader true
Socket local:/run/opendmarc/opendmarc.sock
RejectFailures false
EOF
chmod 0644 /etc/opendmarc.conf
# systemd Drop-in für RuntimeDirectory (robust nach Reboot)
install -d -m 0755 /etc/systemd/system/opendmarc.service.d
cat >/etc/systemd/system/opendmarc.service.d/override.conf <<'EOF'
[Service]
RuntimeDirectory=opendmarc
RuntimeDirectoryMode=0755
EOF
systemctl daemon-reload
# Dienst nach Flag
if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
systemctl enable --now opendmarc
else
systemctl disable --now opendmarc || true
fi
# Postfix-Milter-Kette konsistent setzen (Rspamd + OpenDKIM + optional OpenDMARC)
touch /run/mailwolt.need-apply-milters || true
log "[✓] OpenDMARC (ENABLE=${OPENDMARC_ENABLE}) bereit."

View File

@ -1,59 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "ClamAV (clamav-daemon) installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
# Pakete
if ! dpkg -s clamav-daemon >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y clamav clamav-daemon
fi
# Signaturen aktualisieren (erst Freshclam starten)
systemctl stop clamav-freshclam 2>/dev/null || true
freshclam || true
systemctl start clamav-freshclam || true
# clamd LocalSocket setzen
sed -i 's|^#\?LocalSocket .*|LocalSocket /run/clamav/clamd.ctl|' /etc/clamav/clamd.conf || true
install -d -m 0755 /run/clamav
chown clamav:clamav /run/clamav
# Dienst nach Flag
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
systemctl enable --now clamav-daemon
else
systemctl disable --now clamav-daemon || true
fi
# Rspamd-Integration (nur wenn aktiv)
AV_CONF="/etc/rspamd/local.d/antivirus.conf"
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
cat >"$AV_CONF" <<'EOF'
clamav {
symbol = "CLAM_VIRUS";
type = "clamav";
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = true;
scan_text_mime = true;
max_size = 50mb;
log_clean = false;
action = "reject";
}
EOF
chown root:_rspamd "$AV_CONF" || true
chmod 0640 "$AV_CONF" || true
systemctl reload rspamd || systemctl restart rspamd
else
rm -f "$AV_CONF" || true
systemctl reload rspamd || true
fi
log "[✓] ClamAV (ENABLE=${CLAMAV_ENABLE}) konfiguriert."

View File

@ -1,230 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Fail2Ban installieren/konfigurieren …"
# Flags laden
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
# Paket
if ! dpkg -s fail2ban >/dev/null 2>&1; then
apt-get update -qq
apt-get install -y fail2ban sqlite3
fi
install -d -m 0755 /etc/fail2ban/jail.d
# ---------------------------------------------------------------
# Basis-Jails (praxisnah)
# ---------------------------------------------------------------
cat >/etc/fail2ban/jail.d/mailwolt.conf <<'EOF'
[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log
[postfix]
enabled = true
logpath = /var/log/mail.log
port = smtp,ssmtp,submission,465
[dovecot]
enabled = true
logpath = /var/log/mail.log
port = pop3,pop3s,imap,imaps,submission,465,587,993
[rspamd-controller]
enabled = true
port = 11334
filter = rspamd
logpath = /var/log/rspamd/rspamd.log
maxretry = 5
EOF
# einfacher Filter für Rspamd-Controller
if [ ! -f /etc/fail2ban/filter.d/rspamd.conf ]; then
cat >/etc/fail2ban/filter.d/rspamd.conf <<'EOF'
[Definition]
failregex = .*Authentication failed for user.* from <HOST>
ignoreregex =
EOF
fi
# ---------------------------------------------------------------
# Fail2Ban-Backend auf SQLite umstellen
# ---------------------------------------------------------------
log "SQLite-Backend aktivieren …"
cat >/etc/fail2ban/fail2ban.local <<'EOF'
[Definition]
loglevel = INFO
logtarget = /var/log/fail2ban.log
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 86400
EOF
# Datenbankverzeichnis sicherstellen
install -d -o fail2ban -g fail2ban -m 0750 /var/lib/fail2ban
# Falls DB nicht existiert, Dummy anlegen (wird vom Dienst erweitert)
if [ ! -f /var/lib/fail2ban/fail2ban.sqlite3 ]; then
sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 "VACUUM;"
fi
chown fail2ban:fail2ban /var/lib/fail2ban/fail2ban.sqlite3
chmod 0640 /var/lib/fail2ban/fail2ban.sqlite3
# ---------------------------------------------------------------
# sudoers für Web-UI
# ---------------------------------------------------------------
# Fail2Ban Blacklist-Jail
cat >/etc/fail2ban/jail.d/mailwolt-blacklist.local <<'EOF'
[mailwolt-blacklist]
enabled = true
filter = none
port = anyport
bantime = -1
findtime = 1
maxretry = 1
EOF
cat >/etc/fail2ban/filter.d/none.conf <<'EOF'
[Definition]
failregex =
ignoreregex =
EOF
chmod 0640 /etc/fail2ban/filter.d/none.conf
SUDOERS_F2B="/etc/sudoers.d/mailwolt-fail2ban"
cat > "${SUDOERS_F2B}" <<'EOF'
Defaults:www-data !requiretty
www-data ALL=(root) NOPASSWD: \
/usr/bin/fail2ban-client, \
/usr/bin/fail2ban-client ping, \
/usr/bin/fail2ban-client status, \
/usr/bin/fail2ban-client status *, \
/usr/bin/fail2ban-client get *, \
/usr/bin/fail2ban-client set * banip *, \
/usr/bin/fail2ban-client set * unbanip *, \
/usr/bin/fail2ban-client reload, \
/usr/bin/journalctl, \
/bin/journalctl, \
/usr/bin/zgrep, \
/bin/zgrep, \
/usr/bin/grep, \
/bin/grep, \
/usr/bin/tail, \
/bin/tail, \
/usr/bin/sqlite3, \
/usr/bin/tee /etc/fail2ban/jail.d/*
EOF
chown root:root "${SUDOERS_F2B}"
chmod 440 "${SUDOERS_F2B}"
if ! visudo -c -f "${SUDOERS_F2B}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_F2B} entferne Datei."
rm -f "${SUDOERS_F2B}"
fi
# ---------------------------------------------------------------
# Dienst aktivieren/deaktivieren
# ---------------------------------------------------------------
if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
systemctl enable --now fail2ban
else
systemctl disable --now fail2ban || true
fi
log "[✓] Fail2Ban (ENABLE=${FAIL2BAN_ENABLE}) bereit."
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "Fail2Ban installieren/konfigurieren …"
#
## Flags laden
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#
## Paket
#if ! dpkg -s fail2ban >/dev/null 2>&1; then
# apt-get update -qq
# apt-get install -y fail2ban
#fi
#
#install -d -m 0755 /etc/fail2ban/jail.d
#
## Basis-Jails (praxisnah)
#cat >/etc/fail2ban/jail.d/mailwolt.conf <<'EOF'
#[DEFAULT]
#bantime = 1h
#findtime = 10m
#maxretry = 5
#backend = auto
#
#[sshd]
#enabled = true
#port = ssh
#logpath = /var/log/auth.log
#
#[postfix]
#enabled = true
#logpath = /var/log/mail.log
#port = smtp,ssmtp,submission,465
#
#[dovecot]
#enabled = true
#logpath = /var/log/mail.log
#port = pop3,pop3s,imap,imaps,submission,465,587,993
#
#[rspamd-controller]
#enabled = true
#port = 11334
#filter = rspamd
#logpath = /var/log/rspamd/rspamd.log
#maxretry = 5
#EOF
#
## einfacher Filter für Rspamd-Controller
#if [ ! -f /etc/fail2ban/filter.d/rspamd.conf ]; then
# cat >/etc/fail2ban/filter.d/rspamd.conf <<'EOF'
#[Definition]
#failregex = .*Authentication failed for user.* from <HOST>
#ignoreregex =
#EOF
#fi
#
#SUDOERS_F2B="/etc/sudoers.d/mailwolt-fail2ban"
#cat > "${SUDOERS_F2B}" <<'EOF'
#www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status, /usr/bin/fail2ban-client status *
#EOF
#chown root:root "${SUDOERS_F2B}"
#chmod 440 "${SUDOERS_F2B}"
#
#if ! visudo -c -f "${SUDOERS_F2B}" >/dev/null 2>&1; then
# echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_F2B} entferne Datei."
# rm -f "${SUDOERS_F2B}"
#fi
#
#sudo tee /etc/sudoers.d/mailwolt-fail2ban >/dev/null <<'EOF'
#www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client status, /usr/bin/fail2ban-client status *
#EOF
#sudo visudo -cf /etc/sudoers.d/mailwolt-fail2ban
#
## Dienst nach Flag
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# systemctl enable --now fail2ban
#else
# systemctl disable --now fail2ban || true
#fi
#
#log "[✓] Fail2Ban (ENABLE=${FAIL2BAN_ENABLE}) bereit."

View File

@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# nur ausführen, wenn vorherige Schritte das Flag gesetzt haben
if [[ -f /run/mailwolt.need-apply-milters ]]; then
if command -v /usr/local/sbin/mailwolt-apply-milters >/dev/null 2>&1; then
log "Setze Postfix-Milter-Kette (Rspamd/OpenDKIM[/OpenDMARC]) …"
/usr/local/sbin/mailwolt-apply-milters || true
else
# Fallback (ident wie im Tool)
/usr/sbin/postconf -e "milter_default_action = accept"
/usr/sbin/postconf -e "milter_protocol = 6"
CHAIN="inet:127.0.0.1:11333, inet:127.0.0.1:8891"
systemctl is-active --quiet opendmarc && CHAIN="$CHAIN, inet:127.0.0.1:8893" || true
/usr/sbin/postconf -e "smtpd_milters = $CHAIN"
/usr/sbin/postconf -e "non_smtpd_milters = $CHAIN"
systemctl reload postfix || true
fi
rm -f /run/mailwolt.need-apply-milters || true
log "[✓] Milter-Kette angewandt."
else
log "Milter-Kette: kein Bedarf (Flag nicht gesetzt) überspringe."
fi

View File

@ -1,521 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Nginx konfigurieren …"
# ── Flags/Umgebung (vom Bootstrap gesetzt; hier Fallbacks) ────────────────
DEV_MODE="${DEV_MODE:-0}" # 1 = DEV (Vite-Proxy aktiv), 0 = PROD
PROXY_MODE="${PROXY_MODE:-0}" # 1 = NPM/Proxy davor, Backend spricht nur HTTP:80
NPM_IP="${NPM_IP:-}" # z.B. 10.10.20.20
# Erwartet vom Bootstrap/Installer exportiert:
: "${UI_HOST:?UI_HOST fehlt}"
: "${WEBMAIL_HOST:?WEBMAIL_HOST fehlt}"
: "${APP_DIR:?APP_DIR fehlt}"
ACME_ROOT="/var/www/letsencrypt"
install -d -m 0755 "$ACME_ROOT"
# Default-Sites entfernen (verhindert doppelten default_server)
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
# HTTP/2-Unterstützung erkennen
NGINX_HTTP2_SUFFIX=""
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUFFIX=" http2"
fi
# PHP-FPM Socket/TCP finden → fastcgi_pass bauen
detect_php_fpm_sock(){
for v in 8.3 8.2 8.1 8.0 7.4; do
s="/run/php/php${v}-fpm.sock"
[[ -S "$s" ]] && { echo "unix:${s}"; return; }
done
[[ -S "/run/php/php-fpm.sock" ]] && { echo "unix:/run/php/php-fpm.sock"; return; }
echo "127.0.0.1:9000"
}
PHP_FPM_TARGET="$(detect_php_fpm_sock)"
if [[ "$PHP_FPM_TARGET" == unix:* ]]; then
FASTCGI_PASS="fastcgi_pass ${PHP_FPM_TARGET};"
else
FASTCGI_PASS="fastcgi_pass ${PHP_FPM_TARGET};"
fi
# ── Builder 1: HTTP-only (Proxy-Mode: TLS endet im NPM) ───────────────────
## $1=host, $2=outfile
#build_site_http_only(){
# local host="$1" outfile="$2"
#
# local def=""
# [[ "${DEV_MODE}" = "1" ]] && def=" default_server"
# [[ -z "${host}" || "${host}" = "_" ]] && host="_"
#
# cat > "$outfile" <<CONF
## --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy) ---
#server {
# listen 80;
# listen [::]:80;
# server_name ${host};
#
# # ACME HTTP-01 (optional; meist übernimmt das der Proxy)
# location ^~ /.well-known/acme-challenge/ {
# root ${ACME_ROOT};
# allow all;
# }
#
# root ${APP_DIR}/public;
# index index.php index.html;
#
# access_log /var/log/nginx/${host}_access.log;
# error_log /var/log/nginx/${host}_error.log;
#
# client_max_body_size 25m;
#
# location / { try_files \$uri \$uri/ /index.php?\$query_string; }
#
# location ~ \.php\$ {
# include snippets/fastcgi-php.conf;
# ${FASTCGI_PASS}
# }
#
# location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
# location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
#
# # WebSocket: Laravel Reverb (Backend intern HTTP)
# location /ws/ {
# proxy_http_version 1.1;
# proxy_set_header Upgrade \$http_upgrade;
# proxy_set_header Connection "Upgrade";
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/;
# }
#
# # Reverb HTTP API
# location /apps/ {
# proxy_http_version 1.1;
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/apps/;
# }
#CONF
#
# if [[ "${DEV_MODE}" = "1" ]]; then
# cat >> "$outfile" <<'CONF'
# # DEV: Vite-Proxy (HMR)
# location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
# location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
# location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
#CONF
# fi
#
# echo "}" >> "$outfile"
#}
#build_site_http_only(){
# local host="$1" outfile="$2"
#
# # DEV: IP-Zugriff ohne Hostname → default_server + server_name _
# local def=""
# if [[ "${DEV_MODE}" = "1" ]]; then
# def=" default_server"
# host="_"
# fi
# [[ -z "${host}" || "${host}" = "_" ]] && host="_"
#
# cat > "$outfile" <<CONF
## --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy/DEV) ---
#server {
# listen 80${def};
# listen [::]:80${def};
# server_name ${host};
#
# # ACME HTTP-01 (optional; meist übernimmt das der Proxy)
# location ^~ /.well-known/acme-challenge/ {
# root ${ACME_ROOT};
# allow all;
# }
#
# root ${APP_DIR}/public;
# index index.php index.html;
#
# access_log /var/log/nginx/${host/_/__}_access.log;
# error_log /var/log/nginx/${host/_/__}_error.log;
#
# client_max_body_size 25m;
#
# location / { try_files \$uri \$uri/ /index.php?\$query_string; }
#
# location ~ \.php\$ {
# include snippets/fastcgi-php.conf;
# ${FASTCGI_PASS}
# }
#
# location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
# location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
#
# # WebSocket: Laravel Reverb
# location /ws/ {
# proxy_http_version 1.1;
# proxy_set_header Upgrade \$http_upgrade;
# proxy_set_header Connection "Upgrade";
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/;
# }
#
# # Reverb HTTP API
# location /apps/ {
# proxy_http_version 1.1;
# proxy_set_header Host \$host;
# proxy_read_timeout 60s;
# proxy_send_timeout 60s;
# proxy_pass http://127.0.0.1:8080/apps/;
# }
#CONF
#
# if [[ "${DEV_MODE}" = "1" ]]; then
# cat >> "$outfile" <<'CONF'
# # DEV: Vite-Proxy (HMR)
# location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
# location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
# location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
#CONF
# fi
#
# echo "}" >> "$outfile"
#}
# $1=host, $2=outfile, $3=default_flag (default|nodefault)
build_site_http_only(){
local host="$1" outfile="$2" def_flag="${3:-default}"
local def=""
if [[ "${DEV_MODE}" = "1" && "${def_flag}" = "default" ]]; then
def=" default_server"
fi
[[ -z "${host}" || "${host}" = "_" ]] && host="_"
cat > "$outfile" <<CONF
# --- ${host} : HTTP (kein Redirect, kein TLS; läuft hinter Reverse-Proxy/DEV) ---
server {
listen 80${def};
listen [::]:80${def};
server_name ${host};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${host/_/__}_access.log;
error_log /var/log/nginx/${host/_/__}_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
${FASTCGI_PASS}
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
# WebSocket: Laravel Reverb
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/;
}
# Reverb HTTP API
location /apps/ {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/apps/;
}
CONF
if [[ "${DEV_MODE}" = "1" ]]; then
cat >> "$outfile" <<'CONF'
# DEV: Vite-Proxy (HMR)
location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; }
location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; }
location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; }
CONF
fi
echo "}" >> "$outfile"
}
# ── Builder 2: 80→443 Redirect + 443/TLS (Live-Server) ────────────────────
# $1=host, $2=cert_dir (/etc/ssl/ui | /etc/ssl/webmail), $3=outfile
build_site_tls(){
local host="$1" cert_dir="$2" outfile="$3"
local cert="${cert_dir}/fullchain.pem"
local key="${cert_dir}/privkey.pem"
cat > "$outfile" <<CONF
# --- ${host} : HTTP (ACME + Redirect) ---
server {
listen 80;
listen [::]:80;
server_name ${host};
# ACME HTTP-01 auf Port 80
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
return 301 https://\$host\$request_uri;
}
# --- ${host} : HTTPS ---
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name ${host};
ssl_certificate ${cert};
ssl_certificate_key ${key};
ssl_protocols TLSv1.2 TLSv1.3;
# WICHTIG: ACME auch auf 443, sonst 404 bei Redirects
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
allow all;
}
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${host}_ssl_access.log;
error_log /var/log/nginx/${host}_ssl_error.log;
client_max_body_size 25m;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
${FASTCGI_PASS}
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
# WebSocket: Laravel Reverb (Backend intern HTTP)
location /ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/;
}
# Reverb HTTP API
location /apps/ {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_pass http://127.0.0.1:8080/apps/;
}
CONF
if [[ "${DEV_MODE}" = "1" ]]; then
cat >> "$outfile" <<'CONF'
# DEV: Vite-Proxy
location ^~ /@vite/ { proxy_pass http://127.0.0.1:5173/@vite/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location ^~ /node_modules/ { proxy_pass http://127.0.0.1:5173/node_modules/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
location ^~ /resources/ { proxy_pass http://127.0.0.1:5173/resources/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; }
CONF
fi
echo "}" >> "$outfile"
}
build_site_acme_only(){
local host="$1" outfile="$2"
cat > "$outfile" <<CONF
# --- ${host} : ACME-only (80 + 443), KEIN App-Root ---
server {
listen 80;
listen [::]:80;
server_name ${host};
# HTTP-01 Challenge exakt ausliefern
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
default_type "text/plain";
try_files \$uri =404;
}
# Alles andere → nach https
location / { return 301 https://\$host\$request_uri; }
}
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX};
listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name ${host};
ssl_certificate /etc/ssl/mail/fullchain.pem;
ssl_certificate_key /etc/ssl/mail/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Auch via https die Challenge bedienen (falls Redirects gefolgt werden)
location ^~ /.well-known/acme-challenge/ {
root ${ACME_ROOT};
default_type "text/plain";
try_files \$uri =404;
}
# Sonst nichts preisgeben
location / { return 444; }
}
CONF
}
# ── Builder Webmail: nur /webmail/* erlaubt, root → redirect ───────────────
build_webmail_http_only(){
local host="$1" outfile="$2" def_flag="${3:-nodefault}"
local def=""
[[ "${DEV_MODE}" = "1" && "${def_flag}" = "default" ]] && def=" default_server"
[[ -z "${host}" || "${host}" = "_" ]] && host="_"
cat > "$outfile" <<CONF
# --- ${host} : Webmail (domain-routing, volle Laravel-App) ---
server {
listen 80${def};
listen [::]:80${def};
server_name ${host};
location ^~ /.well-known/acme-challenge/ { root ${ACME_ROOT}; allow all; }
root ${APP_DIR}/public;
index index.php;
access_log /var/log/nginx/${host/_/__}_webmail_access.log;
error_log /var/log/nginx/${host/_/__}_webmail_error.log;
client_max_body_size 25m;
location = / { return 301 http://\$host/login; }
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; ${FASTCGI_PASS} }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(css|js|ico|svg|woff2?|ttf|jpg|jpeg|png|gif)\$ { expires 30d; access_log off; }
}
CONF
}
build_webmail_tls(){
local host="$1" cert_dir="$2" outfile="$3"
cat > "$outfile" <<CONF
# --- ${host} : Webmail TLS (domain-routing, volle Laravel-App) ---
server {
listen 80; listen [::]:80; server_name ${host};
location ^~ /.well-known/acme-challenge/ { root ${ACME_ROOT}; allow all; }
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl${NGINX_HTTP2_SUFFIX}; listen [::]:443 ssl${NGINX_HTTP2_SUFFIX};
server_name ${host};
ssl_certificate ${cert_dir}/fullchain.pem;
ssl_certificate_key ${cert_dir}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location ^~ /.well-known/acme-challenge/ { root ${ACME_ROOT}; allow all; }
root ${APP_DIR}/public;
index index.php;
access_log /var/log/nginx/${host}_webmail_ssl_access.log;
error_log /var/log/nginx/${host}_webmail_ssl_error.log;
client_max_body_size 25m;
location = / { return 301 https://\$host/login; }
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ { include snippets/fastcgi-php.conf; ${FASTCGI_PASS} }
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(css|js|ico|svg|woff2?|ttf|jpg|jpeg|png|gif)\$ { expires 30d; access_log off; }
}
CONF
}
# ── Sites erzeugen ─────────────────────────────────────────────────────────
MX_SITE="/etc/nginx/sites-available/mx-mailwolt.conf"
UI_SITE="/etc/nginx/sites-available/ui-mailwolt.conf"
WEBMAIL_SITE="/etc/nginx/sites-available/webmail-mailwolt.conf"
# UI & Webmail wie gehabt …
#if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
# build_site_http_only "$UI_HOST" "$UI_SITE"
# build_site_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
#else
# build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
# build_site_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
#fi
# UI & Webmail …
if [[ "${DEV_MODE}" = "1" ]]; then
build_site_http_only "_" "$UI_SITE" "default"
build_webmail_http_only "_" "$WEBMAIL_SITE" "nodefault"
else
if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
build_site_http_only "$UI_HOST" "$UI_SITE"
build_webmail_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
else
build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
build_webmail_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
fi
fi
#if [[ "${DEV_MODE}" = "1" ]]; then
# # DEV: per IP erreichbar → Catch-All („_“) und HTTP-only
# build_site_http_only "_" "$UI_SITE"
# build_site_http_only "_" "$WEBMAIL_SITE"
#else
# if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
# build_site_http_only "$UI_HOST" "$UI_SITE"
# build_site_http_only "$WEBMAIL_HOST" "$WEBMAIL_SITE"
# else
# build_site_tls "$UI_HOST" "/etc/ssl/ui" "$UI_SITE"
# build_site_tls "$WEBMAIL_HOST" "/etc/ssl/webmail" "$WEBMAIL_SITE"
# fi
#fi
# MX: **immer** ACME-only (kein Laravel dahinter)
build_site_acme_only "${MAIL_HOSTNAME}" "$MX_SITE"
ln -sf "$UI_SITE" /etc/nginx/sites-enabled/ui-mailwolt.conf
ln -sf "$WEBMAIL_SITE" /etc/nginx/sites-enabled/webmail-mailwolt.conf
ln -sf "$MX_SITE" /etc/nginx/sites-enabled/mx-mailwolt.conf
# ── Real-IP nur, wenn Proxy davor ──────────────────────────────────────────
if [[ "${PROXY_MODE}" -eq 1 && -n "${NPM_IP}" ]]; then
cat > /etc/nginx/conf.d/realip.conf <<NGX
real_ip_header X-Forwarded-For;
set_real_ip_from ${NPM_IP};
real_ip_recursive on;
NGX
else
rm -f /etc/nginx/conf.d/realip.conf || true
fi
# ── Test & reload ──────────────────────────────────────────────────────────
if nginx -t; then
systemctl enable --now nginx >/dev/null 2>&1 || true
systemctl reload nginx || true
else
die "nginx -t fehlgeschlagen siehe /var/log/nginx/*.log"
fi

View File

@ -1,121 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
ACME_WEBROOT="/var/www/letsencrypt"
install -d -m 0755 "${ACME_WEBROOT}/.well-known/acme-challenge"
# Staging optional (verbraucht kein Live-Limit)
CERTBOT_EXTRA=()
LE_STAGING="${LE_STAGING:-0}"
[[ "$LE_STAGING" = "1" ]] && CERTBOT_EXTRA+=(--test-cert)
# Einheitliche LE-Mail (Fallback)
LE_MAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
resolve_ok() {
local host="$1"
local pats=()
[[ -n "${SERVER_PUBLIC_IPV4:-}" ]] && pats+=("${SERVER_PUBLIC_IPV4//./\\.}")
[[ -n "${SERVER_PUBLIC_IPV6:-}" ]] && pats+=("${SERVER_PUBLIC_IPV6//:/\\:}")
[[ ${#pats[@]} -eq 0 ]] && return 0
getent ahosts "$host" | awk '{print $1}' | sort -u \
| grep -Eq "^($(IFS='|'; echo "${pats[*]}"))$"
}
probe_http() {
local host="$1"
echo test > "${ACME_WEBROOT}/.well-known/acme-challenge/_probe"
curl -fsS --max-time 5 -4 "http://${host}/.well-known/acme-challenge/_probe" >/dev/null \
|| curl -fsS --max-time 5 -6 "http://${host}/.well-known/acme-challenge/_probe" >/dev/null
}
issue() {
local host="${1:-}"
[[ -z "$host" ]] && return 0
echo "[i] Versuche LE für ${host}"
if ! resolve_ok "$host"; then
echo "[!] DNS zeigt (noch) nicht hierher überspringe: ${host}"
return 0
fi
if ! probe_http "$host"; then
echo "[!] ACME-HTTP-Check für ${host} fehlgeschlagen (Port 80/IPv6/Firewall/Nginx prüfen)."
# wir versuchen trotzdem Certbot meldet sich, falls es scheitert
fi
EXTRA_ARGS=()
# Für MX den Key wiederverwenden → stabiler TLSA (3 1 1)
[[ "$host" == "${MAIL_HOSTNAME}" ]] && EXTRA_ARGS+=(--reuse-key)
# WICHTIG: Deploy-Wrapper anhängen, damit Symlinks/Nginx gesetzt werden
certbot certonly \
--agree-tos -m "${LE_MAIL}" --non-interactive \
--webroot -w "${ACME_WEBROOT}" -d "${host}" \
--deploy-hook /usr/local/sbin/mailwolt-deploy.sh \
"${EXTRA_ARGS[@]}" "${CERTBOT_EXTRA[@]}" || true
}
if [[ "${BASE_DOMAIN}" != "example.com" ]]; then
issue "${UI_HOST:-}"
issue "${WEBMAIL_HOST:-}"
issue "${MAIL_HOSTNAME:-}"
# Nginx nur neu laden, wenn aktiv
if systemctl is-active --quiet nginx; then
systemctl reload nginx || true
fi
else
echo "[i] BASE_DOMAIN=example.com LE wird übersprungen."
fi
# ──────────────────────────────────────────────────────────────────────────────
# FIX: Validierung & Reparatur des Mail-Zertifikats
# ──────────────────────────────────────────────────────────────────────────────
MAIL_SSL_DIR="/etc/ssl/mail"
install -d -m 0755 "$MAIL_SSL_DIR"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
HOST="${MAIL_HOSTNAME:-}"
LE_DIR=""
[[ -n "$HOST" ]] && LE_DIR="/etc/letsencrypt/live/${HOST}"
need_fix=0
# Ist der vorhandene Key gültig? (leer/nicht vorhanden/ungültig -> fix)
if [[ ! -s "$MAIL_KEY" ]] || ! openssl pkey -in "$MAIL_KEY" -noout >/dev/null 2>&1; then
need_fix=1
fi
# Wenn Fix nötig: aus Let's Encrypt Live kopieren
if [[ $need_fix -eq 1 ]]; then
echo "[!] Ungültiger oder fehlender Mail-Private-Key versuche Reparatur …"
if [[ -n "$LE_DIR" && -r "${LE_DIR}/privkey.pem" && -r "${LE_DIR}/fullchain.pem" ]]; then
cp -f "${LE_DIR}/privkey.pem" "$MAIL_KEY"
cp -f "${LE_DIR}/fullchain.pem" "$MAIL_CERT"
chown root:root "$MAIL_CERT" "$MAIL_KEY"
chmod 600 "$MAIL_KEY"
chmod 644 "$MAIL_CERT"
echo "[+] Zertifikate neu kopiert aus ${LE_DIR}."
# Reload NICHT sofort flaggen für 90-services
touch /run/mailwolt.need-dovecot-reload
else
echo "[!] Konnte ${LE_DIR}/privkey.pem oder fullchain.pem nicht lesen bitte prüfen."
fi
else
echo "[✓] Mail-Zertifikat & -Key sind gültig."
fi
# Optionaler Live-Check (nur wenn Host gesetzt)
if [[ -n "$HOST" ]]; then
if openssl s_client -connect "${HOST}:993" -servername "${HOST}" </dev/null 2>/dev/null \
| grep -q "Verify return code: 0"; then
echo "[✓] TLS-Handshake erfolgreich auf imaps://${HOST}:993."
else
echo "[!] TLS-Handshake auf imaps://${HOST}:993 fehlgeschlagen (Dovecot Reload folgt in 90-services, falls Flag gesetzt)."
fi
fi

View File

@ -1,343 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# --- Helper: sicherer Frontend-Build als APP_USER ---------------------------
safe_frontend_build() {
echo "[i] Frontend build …"
# Verzeichnisse & Rechte vorbereiten (Gruppen-sticky & ACL)
install -d -m 2775 -o "$APP_USER" -g "$APP_GROUP" \
"${APP_DIR}/public/build" "${APP_DIR}/node_modules" "${APP_DIR}/.npm-cache"
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}"
find "${APP_DIR}" -type d -exec chmod 2775 {} \;
find "${APP_DIR}" -type f -exec chmod 664 {} \;
setfacl -R -m g:"$APP_GROUP":rwX -m d:g:"$APP_GROUP":rwX "${APP_DIR}" || true
# Vite-/Build-Reste bereinigen (falls mal root dort gebaut hat)
rm -rf "${APP_DIR}/node_modules/.vite" "${APP_DIR}/public/build/"* 2>/dev/null || true
# npm auf projektlokales Cache konfigurieren
sudo -u "$APP_USER" -H bash -lc "cat > ~/.npmrc <<'RC'
fund=false
audit=false
prefer-offline=true
cache=${APP_DIR}/.npm-cache
RC"
# Node ggf. installieren
if ! command -v node >/dev/null 2>&1; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
fi
# Dependencies + Build (als App-User)
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && (npm ci --no-audit --no-fund || npm install --no-audit --no-fund) && npm run build"; then
return 0
fi
echo "[!] Build fehlgeschlagen Rechtefix + Clean + Retry …"
rm -rf "${APP_DIR}/node_modules/.vite" "${APP_DIR}/public/build/"* 2>/dev/null || true
chown -R "$APP_USER":"$APP_GROUP" "${APP_DIR}"
find "${APP_DIR}" -type d -exec chmod 2775 {} \;
find "${APP_DIR}" -type f -exec chmod 664 {} \;
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build"
}
relink_and_reload() {
if [[ -d /etc/letsencrypt/renewal-hooks/deploy ]]; then
run-parts /etc/letsencrypt/renewal-hooks/deploy || true
fi
if systemctl is-active --quiet nginx; then
systemctl reload nginx || true
fi
}
log "App bereitstellen …"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
# Repo holen oder Laravel anlegen passe GIT_REPO/GIT_BRANCH bei Bedarf an
GIT_REPO="${GIT_REPO:-https://git.nexlab.at/boban/mailwolt.git}"
GIT_BRANCH="${GIT_BRANCH:-main}"
if [[ "${GIT_REPO}" == "https://example.com/your-repo-placeholder.git" ]]; then
[[ -d "$APP_DIR" && -n "$(ls -A "$APP_DIR" 2>/dev/null || true)" ]] || \
sudo -u "$APP_USER" -H bash -lc "cd /var/www && composer create-project laravel/laravel ${APP_USER} --no-interaction"
else
if [[ ! -d "${APP_DIR}/.git" ]]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git reset --hard origin/${GIT_BRANCH}"
fi
[[ -f "${APP_DIR}/composer.json" ]] && sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist"
fi
ENV_FILE="${APP_DIR}/.env"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
grep -q '^APP_KEY=' "$ENV_FILE" || echo "APP_KEY=" >> "$ENV_FILE"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
# --- App-URL/Hosts ----------------------------------------------------------
#SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-}"
#if [[ -z "$SERVER_PUBLIC_IPV4" ]] && command -v curl >/dev/null 2>&1; then
# SERVER_PUBLIC_IPV4="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
# [[ "$SERVER_PUBLIC_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || SERVER_PUBLIC_IPV4=""
#fi
#[[ -n "$SERVER_PUBLIC_IPV4" ]] || SERVER_PUBLIC_IPV4="$(detect_ip)"
#
#UI_CERT="/etc/ssl/ui/fullchain.pem"
#UI_KEY="/etc/ssl/ui/privkey.pem"
#
#if [[ -n "${UI_HOST:-}" ]]; then
# APP_HOST_VAL="$UI_HOST"
# APP_URL_VAL="https://${UI_HOST}"
#else
# APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
# SCHEME="http"
# [[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
# APP_URL_VAL="${SCHEME}://${SERVER_PUBLIC_IPV4}"
#fi
SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-}"
if [[ -z "$SERVER_PUBLIC_IPV4" ]] && command -v curl >/dev/null 2>&1; then
SERVER_PUBLIC_IPV4="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
[[ "$SERVER_PUBLIC_IPV4" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || SERVER_PUBLIC_IPV4=""
fi
[[ -n "$SERVER_PUBLIC_IPV4" ]] || SERVER_PUBLIC_IPV4="$(detect_ip)"
UI_CERT="/etc/ssl/ui/fullchain.pem"
UI_KEY="/etc/ssl/ui/privkey.pem"
# DEV-Modus: immer IP als Host, http (kein example.com / keine Fake-Domain)
if [[ "${DEV_MODE:-0}" = "1" || "${APP_ENV:-production}" = "local" ]]; then
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
APP_URL_VAL="http://${APP_HOST_VAL}"
else
# PROD/normal: wenn UI_HOST gesetzt → benutzen, sonst IP
if [[ -n "${UI_HOST:-}" ]]; then
APP_HOST_VAL="$UI_HOST"
APP_URL_VAL="https://${UI_HOST}"
else
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
SCHEME="http"
[[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
APP_URL_VAL="${SCHEME}://${APP_HOST_VAL}"
fi
fi
SECURE=$([[ "${APP_ENV}" = "production" ]] && echo true || echo false)
# --- .env schreiben ---------------------------------------------------------
upsert_env APP_URL "${APP_URL_VAL}"
if [[ "${PROXY_MODE:-0}" -eq 1 ]]; then
TP_LIST="127.0.0.1,::1"
[[ -n "${NPM_IP:-}" ]] && TP_LIST="${TP_LIST},${NPM_IP}"
upsert_env TRUSTED_PROXIES "$TP_LIST"
upsert_env TRUSTED_HEADERS "x-forwarded-all"
else
upsert_env TRUSTED_PROXIES ""
upsert_env TRUSTED_HEADERS "x-forwarded-all"
fi
upsert_env APP_HOST "${APP_HOST_VAL}"
upsert_env APP_NAME "${APP_NAME}"
upsert_env APP_ENV "${APP_ENV:-production}"
upsert_env APP_DEBUG "${APP_DEBUG:-false}"
upsert_env APP_TIMEZONE "${APP_TZ:-UTC}"
upsert_env APP_LOCALE "${APP_LOCALE:-de}"
upsert_env APP_FALLBACK_LOCALE "en"
upsert_env SERVER_PUBLIC_IPV4 "${SERVER_PUBLIC_IPV4}"
upsert_env SERVER_PUBLIC_IPV6 "${SERVER_PUBLIC_IPV6:-}"
upsert_env SYSMAIL_SUB "${SYSMAIL_SUB}"
upsert_env SYSMAIL_DOMAIN "${SYSMAIL_DOMAIN}"
upsert_env DKIM_ENABLE "${DKIM_ENABLE}"
upsert_env DKIM_SELECTOR "${DKIM_SELECTOR}"
upsert_env DKIM_GENERATE "${DKIM_GENERATE}"
upsert_env BASE_DOMAIN "${BASE_DOMAIN}"
upsert_env UI_SUB "${UI_SUB}"
upsert_env WEBMAIL_SUB "${WEBMAIL_SUB}"
upsert_env MTA_SUB "${MTA_SUB}"
upsert_env LE_EMAIL "${LE_EMAIL:-admin@${BASE_DOMAIN}}"
upsert_env DB_CONNECTION "mysql"
upsert_env DB_HOST "127.0.0.1"
upsert_env DB_PORT "3306"
upsert_env DB_DATABASE "${DB_NAME}"
upsert_env DB_USERNAME "${DB_USER}"
upsert_env DB_PASSWORD "${DB_PASS}"
upsert_env CACHE_SETTINGS_STORE "redis"
upsert_env CACHE_STORE "redis"
upsert_env CACHE_DRIVER "redis"
upsert_env CACHE_PREFIX "${APP_USER_PREFIX}_cache:"
upsert_env SESSION_DRIVER "redis"
upsert_env SESSION_SECURE_COOKIE "${SECURE}" # DEV=false, PROD=true
upsert_env SESSION_SAMESITE "lax"
upsert_env REDIS_CLIENT "phpredis"
upsert_env REDIS_HOST "127.0.0.1"
upsert_env REDIS_PORT "6379"
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
upsert_env REDIS_DB "0"
upsert_env REDIS_CACHE_DB "1"
upsert_env REDIS_CACHE_CONNECTION "cache"
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
upsert_env BROADCAST_DRIVER "reverb"
upsert_env QUEUE_CONNECTION "redis"
upsert_env LOG_CHANNEL "daily"
upsert_env REVERB_APP_ID "${APP_USER_PREFIX}"
grep -q '^REVERB_APP_KEY=' "$ENV_FILE" || upsert_env REVERB_APP_KEY "${APP_USER_PREFIX}_$(openssl rand -hex 16)"
grep -q '^REVERB_APP_SECRET=' "$ENV_FILE" || upsert_env REVERB_APP_SECRET "${APP_USER_PREFIX}_$(openssl rand -hex 32)"
upsert_env REVERB_HOST "\${APP_HOST}"
upsert_env REVERB_PORT "443"
upsert_env REVERB_SCHEME "https"
upsert_env REVERB_PATH "/ws"
upsert_env REVERB_SCALING_ENABLED "true"
upsert_env REVERB_SCALING_CHANNEL "reverb"
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
upsert_env VITE_REVERB_HOST "\${REVERB_HOST}"
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
upsert_env REVERB_SERVER_APP_KEY "\${REVERB_APP_KEY}"
upsert_env REVERB_SERVER_HOST "127.0.0.1"
upsert_env REVERB_SERVER_PORT "8080"
upsert_env REVERB_SERVER_PATH ""
upsert_env REVERB_SERVER_SCHEME "http"
# --- DEV Block (optional) ---------------------------------------------------
DEV_MODE="${DEV_MODE:-0}"
if [[ "$DEV_MODE" = "1" ]]; then
sed -i '/^# --- MailWolt DEV/,/^# --- \/MailWolt DEV/d' "${ENV_FILE}"
cat >> "${ENV_FILE}" <<CONF
# --- MailWolt DEV ---
VITE_DEV_HOST=127.0.0.1
VITE_DEV_PORT=5173
VITE_HMR_PROTOCOL=wss
VITE_HMR_CLIENT_PORT=443
VITE_HMR_HOST=${SERVER_PUBLIC_IPV4}
VITE_DEV_ORIGIN=$(grep '^APP_URL=' "${ENV_FILE}" | cut -d= -f2-)
# --- /MailWolt DEV ---
CONF
fi
# --- Zertifikate in /etc/ssl/* bereitstellen, bevor Laravel irgendwas liest --
relink_and_reload
# --- RECHTE FIXEN: storage & bootstrap/cache (www-data + mailwolt) ----------
log "Setze korrekte Rechte für Laravel-Verzeichnisse …"
cd "${APP_DIR}"
chgrp -R www-data storage bootstrap/cache || true
find storage bootstrap/cache -type d -exec chmod 2775 {} \; || true
find storage bootstrap/cache -type f -exec chmod 0664 {} \; || true
setfacl -R -m u:www-data:rwx,u:${APP_USER}:rwx storage bootstrap/cache || true
setfacl -dR -m u:www-data:rwx,u:${APP_USER}:rwx storage bootstrap/cache || true
log "[✓] Schreibrechte für Laravel korrigiert."
# --- DKIM: Verzeichnisse & Basisrechte --------------------------------------
install -d -m 2775 -o "$APP_USER" -g www-data "$APP_DIR/storage/app/private"
install -d -m 2775 -o "$APP_USER" -g www-data "$APP_DIR/storage/app/private/dkim"
setfacl -R -m u:${APP_USER}:rwx,u:www-data:rwx "$APP_DIR/storage/app/private" || true
setfacl -dR -m u:${APP_USER}:rwx,u:www-data:rwx "$APP_DIR/storage/app/private" || true
# --- OpenDKIM: keys & DNS-Verzeichnis --------------------------------------
install -d -m 0750 -o opendkim -g opendkim /etc/opendkim
install -d -m 0750 -o opendkim -g opendkim /etc/opendkim/keys
install -d -m 0755 -o root -g root /etc/mailwolt
install -d -m 0755 -o root -g root /etc/mailwolt/dns
# --- Caches leeren, Migrationen ausführen -----------------------------------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan migrate --force"
# --- Seeder (legt Domains/DKIM etc. an) -------------------------------------
if [[ "${BASE_DOMAIN}" != "example.com" ]]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemDomainSeeder --force"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemBackupSeeder --force"
fi
# --- DKIM für SYSMAIL_DOMAIN via App erzeugen & per Helper einhängen --------
DKIM_ENABLE="${DKIM_ENABLE:-1}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
SYSMAIL_DOMAIN="${SYSMAIL_DOMAIN:-sysmail.${BASE_DOMAIN}}"
if [[ "${DKIM_ENABLE}" = "1" && -n "${SYSMAIL_DOMAIN}" ]]; then
log "Erzeuge/aktualisiere DKIM für ${SYSMAIL_DOMAIN} (Selector: ${DKIM_SELECTOR}) …"
# 1) In der App generieren (als mailwolt), und Pfad + TXT zurückgeben
OUT="$(sudo -u "${APP_USER}" -H bash -lc "
set -e
cd '${APP_DIR}'
php -r '
require \"vendor/autoload.php\";
\$app=require \"bootstrap/app.php\";
\$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
\$d = App\\Models\\Domain::firstOrCreate([\"domain\"=>\"${SYSMAIL_DOMAIN}\"],[\"is_active\"=>1,\"is_system\"=>1]);
\$r = app(App\\Services\\DkimService::class)->generateForDomain(\$d, 2048, \"${DKIM_SELECTOR}\");
echo \$r[\"priv_path\"], \"\\n\";
echo \$r[\"dns_txt\"], \"\\n\";
'
")"
PRIV_PATH="$(printf '%s\n' "$OUT" | sed -n '1p')"
DNS_TXT="$(printf '%s\n' "$OUT" | sed -n '2,$p')"
if [[ -z "$PRIV_PATH" || ! -s "$PRIV_PATH" ]]; then
echo "[!] DKIM priv_path fehlt oder Datei leer: $PRIV_PATH" >&2
exit 1
fi
TMP_TXT="$(mktemp /tmp/dkim_txt_XXXXXX.txt)"
printf '%s' "$DNS_TXT" >"$TMP_TXT"
# 2) Root-Helper ausführen (hängt Key ein, pflegt Key/SigningTable, kopiert TXT)
if [[ -x /usr/local/sbin/mailwolt-install-dkim ]]; then
/usr/local/sbin/mailwolt-install-dkim "${SYSMAIL_DOMAIN}" "${DKIM_SELECTOR}" "${PRIV_PATH}" "${TMP_TXT}"
else
echo "[!] Helper /usr/local/sbin/mailwolt-install-dkim fehlt oder ist nicht ausführbar." >&2
fi
rm -f "$TMP_TXT" || true
# 3) OpenDKIM neu laden
touch /run/mailwolt.need-opendkim-reload || true
else
log "DKIM übersprungen (DKIM_ENABLE=${DKIM_ENABLE}, SYSMAIL_DOMAIN='${SYSMAIL_DOMAIN}')."
fi
# --- TLSA aus App heraus (idempotent; läuft, wenn Zert lesbar ist) ----------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan dns:tlsa:refresh || true"
# --- Build Frontend (nur wenn nötig) ----------------------------------------
if [[ -f "${APP_DIR}/package.json" && ! -f "${APP_DIR}/public/build/manifest.json" ]]; then
safe_frontend_build
fi
# --- Abschluss: Caches + Rechte + Reloads -----------------------------------
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache && php artisan optimize:clear"
# Konsistente Rechte/ACL für das gesamte App-Verzeichnis
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 2775 {} \;
find "$APP_DIR" -type f -exec chmod 664 {} \;
setfacl -R -m g:"$APP_GROUP":rwX -m d:g:"$APP_GROUP":rwX "$APP_DIR" || true
# Laravel-Write-Dirs sicherstellen (mit setgid & ACL)
install -d -m 2775 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/storage" "$APP_DIR/bootstrap/cache"
chgrp -R www-data "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true
find "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" -type d -exec chmod 2775 {} \; || true
find "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" -type f -exec chmod 0664 {} \; || true
setfacl -R -m u:www-data:rwx,u:${APP_USER}:rwx "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true
setfacl -dR -m u:www-data:rwx,u:${APP_USER}:rwx "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" || true

View File

@ -1,264 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Update-Wrapper & Sudoers …"
WRAPPER="/usr/local/sbin/mailwolt-update"
LOGFILE="/var/log/mailwolt-update.log"
STATEDIR="/var/lib/mailwolt/update"
SUDOERS="/etc/sudoers.d/mailwolt-update"
VERSION_FILE="/var/lib/mailwolt/version"
SUDOERS_SERVICES="/etc/sudoers.d/mailwolt-services"
SUDOERS_ARTISAN="/etc/sudoers.d/mailwolt-artisan"
# Kandidaten: wo liegt update.sh?
CANDIDATES=(
/opt/mailwolt-installer/scripts/update.sh
/mailwolt-installer/scripts/update.sh
/usr/local/lib/mailwolt/update.sh
)
# State/Log vorbereiten
install -d -m 0755 "$(dirname "$LOGFILE")"
install -d -m 0755 "$STATEDIR"
: > "$LOGFILE" || true
chmod 0644 "$LOGFILE"
# Wrapper erzeugen
cat > "$WRAPPER" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
LOG="/var/log/mailwolt-update.log"
STATE_DIR="/var/lib/mailwolt/update"
APP_DIR="/var/www/mailwolt"
WEB_USER="www-data"
CANDIDATES=(
/opt/mailwolt-installer/scripts/update.sh
/mailwolt-installer/scripts/update.sh
/usr/local/lib/mailwolt/update.sh
)
install -d -m 0755 "$(dirname "$LOG")" "$STATE_DIR" /var/lib/mailwolt
: > "$LOG" || true
chmod 0644 "$LOG"
echo "running" > "$STATE_DIR/state"
{
echo "===== $(date -Is) :: Update gestartet ====="
# --- Update-Script finden --------------------------------------------------
SCRIPT=""
for p in "${CANDIDATES[@]}"; do
if [[ -x "$p" ]]; then SCRIPT="$p"; break; fi
if [[ -f "$p" && -r "$p" ]]; then SCRIPT="$p"; break; fi
done
if [[ -z "$SCRIPT" ]]; then
echo "[!] update.sh nicht gefunden (versucht: ${CANDIDATES[*]})"
rc=127
else
echo "[i] benutze: $SCRIPT"
if [[ "$(id -u)" -ne 0 ]]; then
echo "[!] Bitte als root ausführen"
rc=1
else
if [[ -x "$SCRIPT" ]]; then
ALLOW_DIRTY=1 "$SCRIPT"
else
ALLOW_DIRTY=1 bash "$SCRIPT"
fi
rc=$?
fi
fi
echo "===== $(date -Is) :: Update-Script beendet (rc=$rc) ====="
# --- Nach dem Update: Assets neu bauen & Laravel optimieren ---------------
if [ -d "$APP_DIR" ]; then
cd "$APP_DIR" || exit 1
echo "[i] Führe Composer aus (falls vorhanden) ..."
if [ -f composer.json ]; then
sudo -u "$WEB_USER" composer install --no-dev --prefer-dist --no-interaction -q || true
fi
echo "[i] Baue Frontend-Assets neu ..."
if command -v npm >/dev/null 2>&1 && [ -f package.json ]; then
sudo -u "$WEB_USER" npm ci --silent || true
sudo -u "$WEB_USER" npm run build --silent || true
fi
echo "[i] Führe Migrationen & Cache-Optimierungen durch ..."
sudo -u "$WEB_USER" php artisan migrate --force || true
sudo -u "$WEB_USER" php artisan config:cache || true
sudo -u "$WEB_USER" php artisan optimize:clear || true
sudo -u "$WEB_USER" php artisan route:cache || true
sudo -u "$WEB_USER" php artisan view:cache || true
echo "[i] Hebe Wartungsmodus auf ..."
sudo -u "$WEB_USER" php artisan up >/dev/null 2>&1 || true
fi
# --- Version aktualisieren -------------------------------------------------
echo "[i] Aktualisiere Version ..."
if command -v git >/dev/null 2>&1; then
SRC="/var/www/mailwolt"
if [ ! -d "$SRC/.git" ]; then
SRC="/opt/mailwolt-installer"
fi
git config --global --add safe.directory "$SRC" || true
if [ -f "$SRC/.git/shallow" ]; then
git -C "$SRC" fetch --unshallow --quiet || true
fi
git -C "$SRC" fetch --tags --quiet origin || true
raw="$(git -C "$SRC" describe --tags --always --dirty 2>/dev/null || echo "unknown")"
norm="$(printf '%s' "$raw" | sed -E 's/^[vV]//; s/-.*$//')"
printf '%s\n' "$raw" > /var/lib/mailwolt/version_raw
printf '%s\n' "$norm" > /var/lib/mailwolt/version
chmod 0644 /var/lib/mailwolt/version_raw /var/lib/mailwolt/version
echo "[i] Version aktualisiert: raw=$raw norm=$norm (Quelle: $SRC)"
else
echo "unknown" > /var/lib/mailwolt/version_raw
echo "0.0.0" > /var/lib/mailwolt/version
chmod 0644 /var/lib/mailwolt/version_raw /var/lib/mailwolt/version
fi
# --- Services neu starten --------------------------------------------------
echo "[i] Starte MailWolt-Dienste neu ..."
sudo -u "$WEB_USER" php artisan mailwolt:restart-services || true
# --- Abschluss -------------------------------------------------------------
printf '%s\n' "$rc" > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
echo "===== $(date -Is) :: Update beendet ====="
exit "$rc"
} | tee -a "$LOG"
EOF
chmod 0755 "$WRAPPER"
chown root:root "$WRAPPER"
# Sudoers: www-data (Laravel) & mailwolt dürfen den Wrapper laufen lassen
cat > "$SUDOERS" <<'EOF'
Defaults!/usr/local/sbin/mailwolt-update !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
EOF
chown root:root "$SUDOERS"
chmod 440 "$SUDOERS"
if ! visudo -c -f "$SUDOERS" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS entferne Datei."
rm -f "$SUDOERS"
fi
cat > "$SUDOERS_SERVICES" <<'EOF'
Defaults!/usr/bin/systemctl !requiretty
Cmnd_Alias MW_SERVICES = \
/usr/bin/systemctl reload nginx.service, \
/usr/bin/systemctl try-reload-or-restart nginx.service, \
/usr/bin/systemctl try-reload-or-restart postfix.service, \
/usr/bin/systemctl try-reload-or-restart dovecot.service, \
/usr/bin/systemctl try-reload-or-restart rspamd.service, \
/usr/bin/systemctl try-reload-or-restart opendkim.service, \
/usr/bin/systemctl try-reload-or-restart opendmarc.service, \
/usr/bin/systemctl try-reload-or-restart clamav-daemon.service, \
/usr/bin/systemctl try-reload-or-restart redis-server.service
www-data ALL=(root) NOPASSWD: MW_SERVICES
EOF
chmod 440 "$SUDOERS_SERVICES"
chown root:root "$SUDOERS_SERVICES"
# Prüfen, ob Syntax gültig ist
if ! visudo -c -f "$SUDOERS_SERVICES" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS_SERVICES entferne Datei."
rm -f "$SUDOERS_SERVICES"
else
echo "[✓] Sudoers für Dienststeuerung angelegt: $SUDOERS_SERVICES"
fi
# Version-File initial anlegen, falls nicht existiert
if [[ ! -f "$VERSION_FILE" ]]; then
echo "unknown" > "$VERSION_FILE"
chmod 0644 "$VERSION_FILE"
fi
cat > "$SUDOERS_ARTISAN" <<'EOF'
# mailwolt darf artisan im App-Verzeichnis als www-data ausführen (ohne Passwort)
mailwolt ALL=(www-data) NOPASSWD: /usr/bin/php /var/www/mailwolt/artisan *
EOF
chown root:root "$SUDOERS_ARTISAN"
chmod 440 "$SUDOERS_ARTISAN"
if ! visudo -c -f "$SUDOERS_ARTISAN" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $SUDOERS_ARTISAN entferne Datei."
rm -f "$SUDOERS_ARTISAN"
else
echo "[✓] Sudoers für Artisan-Kommandos angelegt: $SUDOERS_ARTISAN"
fi
log "[✓] Update-Wrapper bereit: $WRAPPER"
log "[✓] Version wird unter $VERSION_FILE gespeichert"
# ─── Installer-Wrapper ────────────────────────────────────────────────────────
INSTALL_WRAPPER="/usr/local/sbin/mailwolt-install"
INSTALL_SUDOERS="/etc/sudoers.d/mailwolt-install"
INSTALL_STATE_DIR="/var/lib/mailwolt/install"
INSTALL_LOG="/var/log/mailwolt-install.log"
# State/Log vorbereiten
install -d -m 0755 "$INSTALL_STATE_DIR"
: > "$INSTALL_LOG" || true
chmod 0644 "$INSTALL_LOG"
# Installer-Wrapper aus scripts/install-wrapper.sh kopieren
INSTALL_SRC=""
for candidate in \
"$(dirname "$0")/install-wrapper.sh" \
/opt/mailwolt-installer/scripts/install-wrapper.sh \
/var/www/mailwolt/mailwolt-installer/scripts/install-wrapper.sh; do
[[ -f "$candidate" ]] && INSTALL_SRC="$candidate" && break
done
if [[ -n "$INSTALL_SRC" ]]; then
cp "$INSTALL_SRC" "$INSTALL_WRAPPER"
chmod 0755 "$INSTALL_WRAPPER"
chown root:root "$INSTALL_WRAPPER"
echo "[✓] Installer-Wrapper angelegt: $INSTALL_WRAPPER"
else
echo "[!] install-wrapper.sh nicht gefunden Installer-Wrapper wird übersprungen."
fi
# Sudoers: www-data & mailwolt dürfen den Installer-Wrapper laufen lassen
cat > "$INSTALL_SUDOERS" <<'SUDOEOF'
Defaults!/usr/local/sbin/mailwolt-install !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install *
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-install *
SUDOEOF
chown root:root "$INSTALL_SUDOERS"
chmod 440 "$INSTALL_SUDOERS"
if ! visudo -c -f "$INSTALL_SUDOERS" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in $INSTALL_SUDOERS entferne Datei."
rm -f "$INSTALL_SUDOERS"
else
echo "[✓] Sudoers für Installer angelegt: $INSTALL_SUDOERS"
fi

View File

@ -1,134 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "systemd Units (Reverb / Scheduler / Queue / Mail) …"
cat > /etc/systemd/system/${APP_USER}-ws.service <<EOF
[Unit]
Description=${APP_NAME} WebSocket Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=NODE_ENV=production WS_PORT=8080
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-ws.log
StandardError=append:/var/log/${APP_USER}-ws.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-schedule.service <<EOF
[Unit]
Description=${APP_NAME} Laravel Scheduler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-schedule.log
StandardError=append:/var/log/${APP_USER}-schedule.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-queue.service <<EOF
[Unit]
Description=${APP_NAME} Queue Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan queue:work --queue=default,notify --tries=1
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-queue.log
StandardError=append:/var/log/${APP_USER}-queue.log
KillSignal=SIGINT
TimeoutStopSec=15
UMask=0002
[Install]
WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/${APP_USER}-*.service
chmod 644 /etc/systemd/system/${APP_USER}-*.service
touch /var/log/${APP_USER}-ws.log /var/log/${APP_USER}-schedule.log /var/log/${APP_USER}-queue.log
chown ${APP_USER}:${APP_GROUP} /var/log/${APP_USER}-*.log
chmod 664 /var/log/${APP_USER}-*.log
systemctl daemon-reload
# App-Dienste
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi | grep -qE '(^| )reverb:start( |$)'"; then
systemctl enable --now ${APP_USER}-ws
else
systemctl disable --now ${APP_USER}-ws >/dev/null 2>&1 || true
fi
systemctl enable --now ${APP_USER}-schedule
systemctl enable --now ${APP_USER}-queue
# Mail-Dienste starten
systemctl enable --now rspamd opendkim postfix dovecot || true
# PHP-FPM: Unit erkennen, enable + (re)load
enable_and_touch_php_fpm() {
for u in php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm php7.4-fpm php-fpm; do
if systemctl list-unit-files | grep -q "^${u}\.service"; then
systemctl enable --now "$u" || true
systemctl reload "$u" || systemctl restart "$u" || true
echo "[i] PHP-FPM unit: $u"
return 0
fi
done
echo "[!] Keine passende php-fpm Unit gefunden."
}
enable_and_touch_php_fpm
# Falls in 80-app.sh DKIM installiert wurde: jetzt einmal reloaden
if [[ -e /run/mailwolt.need-opendkim-reload ]]; then
systemctl reload opendkim || true
rm -f /run/mailwolt.need-opendkim-reload || true
fi
# Falls Zert-Fix markiert ist: Dovecot neu laden
if [[ -e /run/mailwolt.need-dovecot-reload ]]; then
systemctl reload dovecot || true
rm -f /run/mailwolt.need-dovecot-reload || true
fi
# Falls DB-Migration schon durch: einmal reload
db_ready(){ mysql -u"${DB_USER}" -p"${DB_PASS}" -h 127.0.0.1 -D "${DB_NAME}" -e "SHOW TABLES LIKE 'migrations'\G" >/dev/null 2>&1; }
if db_ready; then
systemctl reload postfix || true
fi
# Mini-Portcheck (hilft beim Installer-Output)
echo "Listening (25/465/587):"
ss -ltnp | awk '$4 ~ /:(25|465|587)$/ {print " " $0}'

View File

@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Sudoers: npm-Build ohne Passwort für user 'mailwolt' …"
# 1) npm-Binary ermitteln (normal: /usr/bin/npm)
NPM_BIN="$(command -v npm || true)"
if [[ -z "$NPM_BIN" ]]; then
warn "npm wurde nicht gefunden sudoers wird vorbereitet, aber ohne Validierung. Stelle sicher, dass Node/npm installiert ist."
# Fallback die meisten Distros legen hier an
NPM_BIN="/usr/bin/npm"
fi
SUDOERS_FILE="/etc/sudoers.d/mailwolt-npm"
# 2) Sudoers-Datei schreiben
cat > "$SUDOERS_FILE" <<EOF
Defaults!${NPM_BIN} !requiretty
mailwolt ALL=(root) NOPASSWD: ${NPM_BIN}
EOF
chown root:root "$SUDOERS_FILE"
chmod 440 "$SUDOERS_FILE"
# 3) Validieren
if visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then
log "[✓] sudoers OK: ${SUDOERS_FILE} erlaubt 'mailwolt' → ${NPM_BIN} ohne Passwort."
else
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_FILE} entferne Datei."
rm -f "$SUDOERS_FILE"
fi

View File

@ -1,284 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Backup/Restore Tools, Config & Timer (installer.env) …"
# ─────────────────────────────────────────────────────────────
# 1) installer.env laden (ENV > installer.env > Defaults)
# ─────────────────────────────────────────────────────────────
if [[ -f /etc/mailwolt/installer.env ]]; then
# automatisch exportieren, damit ${VAR} später überall wirkt
set -a
# shellcheck disable=SC1091
source /etc/mailwolt/installer.env
set +a
else
log "[i] /etc/mailwolt/installer.env nicht gefunden nutze Defaults."
fi
# ─────────────────────────────────────────────────────────────
# 2) Pfade & Defaults (werden durch ENV/installer.env überschrieben)
# ─────────────────────────────────────────────────────────────
CONF_DIR="/etc/mailwolt"
CONF_FILE="${CONF_DIR}/backup.conf"
BIN_DIR="/usr/local/sbin"
UNIT_DIR="/etc/systemd/system"
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
# DB-Parameter aus installer.env (bzw. ENV) oder Fallbacks
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_NAME="${DB_NAME:-mailwolt}"
DB_USER="${DB_USER:-mailwolt}"
DB_PASS="${DB_PASS:-}"
# Backup-Settings aus installer.env (bzw. ENV)
BACKUP_DIR="${BACKUP_DIR:-/var/backups/mailwolt}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
BACKUP_USE_ZSTD="${BACKUP_USE_ZSTD:-1}"
BACKUP_ENABLED="${BACKUP_ENABLED:-0}" # 0|1
BACKUP_INTERVAL="${BACKUP_INTERVAL:-daily}" # daily|weekly|monthly
install -d -m 0755 "$CONF_DIR" "$BACKUP_DIR"
SUDOERS_BACKUP_FILE="/etc/sudoers.d/mailwolt-backup"
# 2) Sudoers-Datei schreiben
cat > "${SUDOERS_BACKUP_FILE} " <<EOF
Defaults!/usr/local/sbin/mailwolt-backup !requiretty
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup
mailwolt ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup
EOF
chown root:root "${SUDOERS_BACKUP_FILE}"
chmod 440 "${SUDOERS_BACKUP_FILE}"
if ! visudo -c -f "${SUDOERS_BACKUP_FILE}" >/dev/null 2>&1; then
echo "[!] Ungültiger sudoers-Eintrag in ${SUDOERS_BACKUP_FILE} entferne Datei."
rm -f "${SUDOERS_BACKUP_FILE}"
fi
# ─────────────────────────────────────────────────────────────
# 3) /etc/mailwolt/backup.conf (von UI/APP überschreibbar)
# ─────────────────────────────────────────────────────────────
cat > "$CONF_FILE" <<EOF
# MailWolt Backup Konfiguration (UI kann überschreiben)
APP_DIR="$APP_DIR"
BACKUP_DIR="$BACKUP_DIR"
RETENTION_DAYS="$BACKUP_RETENTION_DAYS"
USE_ZSTD="$BACKUP_USE_ZSTD"
# DB-Parameter
MYSQL_DB="$DB_NAME"
MYSQL_USER="$DB_USER"
MYSQL_PASS="$DB_PASS"
MYSQL_HOST="$DB_HOST"
MYSQL_PORT="3306"
EOF
chmod 0644 "$CONF_FILE"
log "[✓] config geschrieben: $CONF_FILE"
# ─────────────────────────────────────────────────────────────
# 4) /usr/local/sbin/mailwolt-backup (schreibt backup.status)
# ─────────────────────────────────────────────────────────────
cat > "${BIN_DIR}/mailwolt-backup" <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
log(){ echo "[$(date -Is)] $*"; }
# Konfiguration laden (ENV > Datei)
CONF="/etc/mailwolt/backup.conf"
[[ -f "$CONF" ]] && # shellcheck disable=SC1090
source "$CONF"
APP_DIR="${APP_DIR:-/var/www/mailwolt}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/mailwolt}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
USE_ZSTD="${USE_ZSTD:-1}"
MYSQL_DB="${MYSQL_DB:-mailwolt}"
MYSQL_USER="${MYSQL_USER:-mailwolt}"
MYSQL_PASS="${MYSQL_PASS:-}"
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
STATE_DIR="/var/lib/mailwolt"
STATUS_FILE="${STATE_DIR}/backup.status"
install -d -m 0755 "$STATE_DIR" "$BACKUP_DIR"
START_TS="$(date +%s)"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
TMP="$(mktemp -d /tmp/mwbackup.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
fail(){
local msg="${1:-backup failed}"
local now="$(date -Is)"
{
echo "time=${now}"
echo "size=0"
echo "dur=$(( $(date +%s) - START_TS ))s"
echo "ok=0"
echo "error=${msg}"
} > "$STATUS_FILE"
echo "[$now] ${msg}" >&2
exit 1
}
trap 'fail "unexpected error (exit $?)"' ERR
OUT="${BACKUP_DIR}/mailwolt-${TS}.tar"
log "⇒ starte Backup in $OUT"
# 1) DB
log " • mysqldump …"
MYSQL_PWD="$MYSQL_PASS" mysqldump \
-h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" \
--single-transaction --routines --events --triggers \
"$MYSQL_DB" > "$TMP/mysql.sql"
# 2) Maildir
log " • Maildir …"
tar -C / -cf "$TMP/mail.tar" var/mail/vhosts 2>/dev/null || true
# 3) App (ohne heavy dirs)
log " • App …"
tar -C / -cf "$TMP/app.tar" \
--exclude='var/www/mailwolt/vendor' \
--exclude='var/www/mailwolt/node_modules' \
--exclude='var/www/mailwolt/public/build' \
var/www/mailwolt
# 4) Configs
log " • Configs …"
mkdir -p "$TMP/files"
cp -a /etc/mailwolt "$TMP/files/" 2>/dev/null || true
cp -a /etc/postfix "$TMP/files/" 2>/dev/null || true
cp -a /etc/dovecot "$TMP/files/" 2>/dev/null || true
cp -a /etc/opendkim "$TMP/files/" 2>/dev/null || true
cp -a /etc/opendmarc "$TMP/files/" 2>/dev/null || true
cp -a /etc/rspamd "$TMP/files/" 2>/dev/null || true
cp -a /etc/ssl/ui "$TMP/files/" 2>/dev/null || true
tar -C "$TMP" -cf "$TMP/files.tar" files
# 5) Paket
log " • Archiviere …"
tar -C "$TMP" -cf "$OUT" mysql.sql mail.tar app.tar files.tar
# 6) Komprimieren (optional)
if [[ "${USE_ZSTD:-1}" = "1" ]] && command -v zstd >/dev/null 2>&1; then
log " • komprimiere (zstd) …"
zstd -f --rm -19 "$OUT"
OUT="${OUT}.zst"
fi
# 7) Retention
if [[ "$RETENTION_DAYS" =~ ^[0-9]+$ ]]; then
log " • Retention: lösche älter als ${RETENTION_DAYS} Tage …"
find "$BACKUP_DIR" -type f -mtime +"$RETENTION_DAYS" -name 'mailwolt-*' -delete || true
fi
# 8) Statusfile fürs UI
SIZE_BYTES="$(stat -c '%s' "$OUT" 2>/dev/null || echo 0)"
{
echo "time=$(date -Is)"
echo "size=${SIZE_BYTES}"
echo "dur=$(( $(date +%s) - START_TS ))s"
echo "ok=1"
echo "file=${OUT}"
} > "$STATUS_FILE"
chmod 0644 "$STATUS_FILE" 2>/dev/null || true
log "[✓] Backup fertig: $OUT"
EOSH
chmod 0755 "${BIN_DIR}/mailwolt-backup"
# ─────────────────────────────────────────────────────────────
# 5) /usr/local/sbin/mailwolt-restore
# ─────────────────────────────────────────────────────────────
cat > "${BIN_DIR}/mailwolt-restore" <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
log(){ echo "[$(date -Is)] $*"; }
ARCHIVE="${1:-}"
[[ -n "$ARCHIVE" ]] || { echo "Usage: mailwolt-restore <backup.tar[.zst]>"; exit 1; }
[[ -f "$ARCHIVE" ]] || { echo "Backup nicht gefunden: $ARCHIVE"; exit 1; }
TMP="$(mktemp -d /tmp/mwrestore.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
case "$ARCHIVE" in
*.zst) zstd -d -c "$ARCHIVE" > "$TMP/backup.tar" ;;
*) cp -a "$ARCHIVE" "$TMP/backup.tar" ;;
esac
log "⇒ entpacke …"
tar -C "$TMP" -xf "$TMP/backup.tar"
# Reihenfolge: DB → App → Mail → Config
if [[ -f "$TMP/mysql.sql" ]]; then
log " • MySQL wiederherstellen …"
mysql < "$TMP/mysql.sql"
fi
if [[ -f "$TMP/app.tar" ]]; then
log " • App → /var/www/mailwolt …"
tar -C / -xf "$TMP/app.tar"
fi
if [[ -f "$TMP/mail.tar" ]]; then
log " • Maildir → /var/mail/vhosts …"
tar -C / -xf "$TMP/mail.tar"
fi
if [[ -f "$TMP/files.tar" ]]; then
log " • Configs → /etc/* …"
tar -C / -xf "$TMP/files.tar"
fi
log "[✓] Restore abgeschlossen."
EOSH
chmod 0755 "${BIN_DIR}/mailwolt-restore"
log "[✓] Tools installiert: ${BIN_DIR}/mailwolt-backup, mailwolt-restore"
# ─────────────────────────────────────────────────────────────
# 6) systemd Service + Timer (Timer default via installer.env)
# ─────────────────────────────────────────────────────────────
cat > "${UNIT_DIR}/mailwolt-backup.service" <<'EOSVC'
[Unit]
Description=MailWolt Backup
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mailwolt-backup
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOSVC
cat > "${UNIT_DIR}/mailwolt-backup.timer" <<EOTIM
[Unit]
Description=MailWolt Backup Timer
[Timer]
OnCalendar=${BACKUP_ONCALENDAR:-*-*-* 03:00:00}
Persistent=true
[Install]
WantedBy=timers.target
EOTIM
systemctl daemon-reload
if [[ "${BACKUP_ENABLED}" = "1" ]]; then
log "Aktiviere Backup-Timer (${BACKUP_ONCALENDAR}) …"
systemctl enable --now mailwolt-backup.timer
else
log "Timer bleibt deaktiviert (BACKUP_ENABLED=0)."
systemctl disable --now mailwolt-backup.timer >/dev/null 2>&1 || true
fi
log "[✓] Backup-Setup abgeschlossen."

View File

@ -1,47 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Monit konfigurieren …"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcp ssl then restart
if failed port 587 type tcp then restart
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
check process mariadb with pidfile /run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
check process redis-server with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
EOF
chmod 600 /etc/monit/monitrc
monit -t && systemctl enable --now monit
monit reload || true
log "[✓] Monit konfiguriert und gestartet"
# ── mailwolt-update ins System kopieren ─────────────────────────────
install -m 0750 -o root -g root scripts/update.sh /usr/local/sbin/mailwolt-update
log "[✓] mailwolt-update installiert → ausführbar via 'sudo mailwolt-update'"

View File

@ -1,491 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "WoltGuard (Monit + Self-Heal) einrichten …"
# ─────────────────────────────────────────────────────────────
# Env nur nachladen, wenn Flags nicht bereits exportiert sind
# ─────────────────────────────────────────────────────────────
INSTALLER_ENV="/etc/mailwolt/installer.env"
: "${CLAMAV_ENABLE:=}" ; : "${OPENDMARC_ENABLE:=}" ; : "${FAIL2BAN_ENABLE:=}"
if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
# shellcheck disable=SC1090
. "$INSTALLER_ENV"
fi
CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-0}"
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
# ─────────────────────────────────────────────────────────────
# Monit installieren & aktivieren
# ─────────────────────────────────────────────────────────────
command -v monit >/dev/null || { apt-get update -qq; apt-get install -y monit; }
systemctl enable --now monit
# ─────────────────────────────────────────────────────────────
# Helper-Skripte (laufen später eigenständig → Env selbst laden)
# ─────────────────────────────────────────────────────────────
install -d -m 0755 /usr/local/sbin
# Redis-Ping (nimmt REDIS_PASSWORD aus installer.env oder .env)
cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
# Defaults
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
REDIS_PASS="${REDIS_PASS:-}" # Legacy
# Installer-Env (falls vorhanden)
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
# Falls .env existiert: Werte ergänzen, die noch leer sind
if [[ -r "$APP_ENV" ]]; then
[[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 -E '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 -E '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 -E '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
fi
# Legacy-Fallback: wenn PASSWORD leer, aber PASS gesetzt → übernehmen
[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
# Quotes strippen
strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/'; }
REDIS_HOST="$(strip "${REDIS_HOST:-}")"
REDIS_PORT="$(strip "${REDIS_PORT:-}")"
REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
# redis-cli muss vorhanden sein
command -v redis-cli >/dev/null 2>&1 || exit 1
BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
if [[ -n "$REDIS_PASSWORD" ]]; then
CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping)
else
CMD=("${BASE[@]}" ping)
fi
# Erfolgreich nur bei exakt "PONG"
[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
EOSH
chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
# Rspamd-Heal (setzt Laufzeitverzeichnis, leert alte Socke, restarts rspamd)
cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
if [[ -z "${REDIS_PASSWORD}" && -r "$APP_ENV" ]]; then
REDIS_PASSWORD="$(grep -E '^REDIS_PASSWORD=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
fi
# Rspamd Runtime fixen
install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
# Neustart
systemctl restart rspamd
# Mini-Healthcheck
sleep 2
ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
exit 0
EOSH
chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
# ─────────────────────────────────────────────────────────────
# WoltGuard Wrapper + Unit
# ─────────────────────────────────────────────────────────────
cat >/usr/local/bin/woltguard <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-status}" in
start) systemctl enable --now monit ;;
stop) systemctl stop monit ;;
status) monit summary || systemctl status monit || true ;;
heal) monit reload || true; sleep 1; monit restart all || true ;;
monitor) monit monitor all || true ;;
unmonitor) monit unmonitor all || true ;;
*) echo "Usage: woltguard {start|stop|status|heal|monitor|unmonitor}"; exit 2;;
esac
EOSH
chmod 0755 /usr/local/bin/woltguard
cat >/etc/systemd/system/woltguard.service <<'EOF'
[Unit]
Description=WoltGuard Self-Healing Monitor for MailWolt
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/woltguard start
ExecStop=/usr/local/bin/woltguard stop
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now woltguard
# ─────────────────────────────────────────────────────────────
# Monit Basis + includes
# ─────────────────────────────────────────────────────────────
sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
grep -q 'include /etc/monit/conf.d/*' /etc/monit/monitrc || echo 'include /etc/monit/conf.d/*' >>/etc/monit/monitrc
install -d -m 0755 /etc/monit/conf.d
# ─────────────────────────────────────────────────────────────
# Monit Checks
# ─────────────────────────────────────────────────────────────
# 10 Redis zuerst (abhängig für rspamd)
cat >/etc/monit/conf.d/10-redis.conf <<'EOF'
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 for 2 cycles then restart
if 5 restarts within 5 cycles then alert
check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
EOF
# 20 Rspamd (hängt von Redis ab), robust über process-matching
cat >/etc/monit/conf.d/20-rspamd.conf <<'EOF'
check process rspamd matching "/usr/bin/rspamd"
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
depends on redis
if failed host 127.0.0.1 port 11333 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if failed host 127.0.0.1 port 11334 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if 5 restarts within 5 cycles then alert
EOF
# 30 Maildienste
cat >/etc/monit/conf.d/30-postfix.conf <<'EOF'
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcpssl then restart
if failed port 587 type tcp then restart
if 5 restarts within 5 cycles then alert
EOF
cat >/etc/monit/conf.d/30-dovecot.conf <<'EOF'
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 993 type tcpssl for 2 cycles then restart
if failed port 24 protocol lmtp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
# 40 Web/PHP
cat >/etc/monit/conf.d/40-nginx.conf <<'EOF'
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcpssl then restart
if 5 restarts within 5 cycles then alert
EOF
# 50 DKIM/DMARC
cat >/etc/monit/conf.d/50-opendkim.conf <<'EOF'
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
# optional: OpenDMARC
if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/55-opendmarc.conf <<'EOF'
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 5 cycles then alert
EOF
else
rm -f /etc/monit/conf.d/55-opendmarc.conf || true
fi
# 60 optional: ClamAV
if [[ "$CLAMAV_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/60-clamav.conf <<'EOF'
check process clamd with pidfile /run/clamav/clamd.pid
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
else
rm -f /etc/monit/conf.d/60-clamav.conf || true
fi
# 70 Fail2Ban (optional, standardmäßig aktiv)
if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
cat >/etc/monit/conf.d/70-fail2ban.conf <<'EOF'
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 5 cycles then alert
EOF
else
rm -f /etc/monit/conf.d/70-fail2ban.conf || true
fi
# ─────────────────────────────────────────────────────────────
# Monit neu laden
# ─────────────────────────────────────────────────────────────
monit -t
systemctl reload monit || systemctl restart monit
systemctl status monit --no-pager || true
log "[✓] WoltGuard aktiv."
##!/usr/bin/env bash
#set -euo pipefail
#source ./lib.sh
#
#log "WoltGuard (Monit + Self-Heal) einrichten …"
#
#set +u
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
#set -u
#CLAMAV_ENABLE="${CLAMAV_ENABLE:-0}"
#OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-0}"
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#
## Pakete sicherstellen
#command -v monit >/dev/null || { apt-get update -qq; apt-get install -y monit; }
#systemctl enable --now monit
#
## Helper-Skripte
#install -d -m 0755 /usr/local/sbin
#cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#PASS=""
#[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env || true
#if command -v redis-cli >/dev/null 2>&1; then
# [[ -n "${REDIS_PASS:-}" ]] \
# && redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASS" ping | grep -q PONG \
# || redis-cli -h 127.0.0.1 -p 6379 ping | grep -q PONG
#else
# exit 1
#fi
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
#
#cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASSWORD="${REDIS_PASSWORD:-}"
#
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#REDIS_CLI="$(command -v redis-cli || true)"
#SYSTEMCTL="$(command -v systemctl || true)"
#RSPAMD_SERVICE="rspamd"
#
#if [ -r "$INSTALLER_ENV" ]; then . "$INSTALLER_ENV"; fi
#if [ -z "${REDIS_PASSWORD}" ] && [ -r "$APP_ENV" ]; then
# REDIS_PASSWORD="$(grep -E '^REDIS_PASSWORD=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
#fi
#
#if [ -n "$REDIS_CLI" ]; then
# echo "[INFO] Prüfe Redis Verbindung..."
# if [ -n "${REDIS_PASSWORD}" ]; then
# if ! "$REDIS_CLI" -h "$REDIS_HOST" -p "$REDIS_PORT" -a "$REDIS_PASSWORD" ping | grep -q '^PONG$'; then
# echo "[WARN] Redis antwortet nicht oder Passwort falsch!"
# else
# echo "[OK] Redis antwortet (auth ok)."
# fi
# else
# if ! "$REDIS_CLI" -h "$REDIS_HOST" -p "$REDIS_PORT" ping | grep -q '^PONG$'; then
# echo "[WARN] Redis antwortet nicht (ohne Passwort)."
# else
# echo "[OK] Redis antwortet (kein Passwort)."
# fi
# fi
#else
# echo "[WARN] redis-cli nicht gefunden überspringe Test."
#fi
#
#echo "[INFO] Prüfe Rspamd Socket & Verzeichnis..."
#install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
#[ -S /var/lib/rspamd/rspamd.sock ] && rm -f /var/lib/rspamd/rspamd.sock || true
#
#echo "[INFO] Starte Rspamd neu..."
#if [ -n "$SYSTEMCTL" ]; then
# "$SYSTEMCTL" restart "$RSPAMD_SERVICE"
# echo "[OK] Rspamd erfolgreich neu gestartet."
#else
# echo "[ERROR] systemctl nicht gefunden kein Neustart möglich."
# exit 1
#fi
#
#echo "[INFO] Healthcheck (Port 11334)..."
#sleep 3
#if ss -tln | grep -q ':11334'; then
# echo "[OK] Rspamd Controller läuft auf Port 11334."
#else
# echo "[WARN] Rspamd Controller Port 11334 nicht erreichbar."
#fi
#
#echo "[DONE] Mailwolt Rspamd-Heal abgeschlossen."
#exit 0
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
#
## WoltGuard Wrapper + Unit
#cat >/usr/local/bin/woltguard <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#case "${1:-status}" in
# start) systemctl enable --now monit ;;
# stop) systemctl stop monit ;;
# status) monit summary || systemctl status monit || true ;;
# heal) monit reload || true; sleep 1; monit restart all || true ;;
# monitor) monit monitor all || true ;;
# unmonitor) monit unmonitor all || true ;;
# *) echo "Usage: woltguard {start|stop|status|heal|monitor|unmonitor}"; exit 2;;
#esac
#EOSH
#chmod 0755 /usr/local/bin/woltguard
#
#cat >/etc/systemd/system/woltguard.service <<'EOF'
#[Unit]
#Description=WoltGuard Self-Healing Monitor for MailWolt
#After=network.target
#[Service]
#Type=oneshot
#ExecStart=/usr/local/bin/woltguard start
#ExecStop=/usr/local/bin/woltguard stop
#RemainAfterExit=yes
#[Install]
#WantedBy=multi-user.target
#EOF
#systemctl daemon-reload
#systemctl enable --now woltguard
#
## Monit Basis + include
#sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
#grep -q 'include /etc/monit/conf.d/*' /etc/monit/monitrc || echo 'include /etc/monit/conf.d/*' >>/etc/monit/monitrc
#install -d -m 0755 /etc/monit/conf.d
#
## Checks
#cat >/etc/monit/conf.d/postfix.conf <<'EOF'
#check process postfix with pidfile /var/spool/postfix/pid/master.pid
# start program = "/bin/systemctl start postfix"
# stop program = "/bin/systemctl stop postfix"
# if failed port 25 protocol smtp then restart
# if failed port 465 type tcpssl then restart
# if failed port 587 type tcp then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/dovecot.conf <<'EOF'
#check process dovecot with pidfile /run/dovecot/master.pid
# start program = "/bin/systemctl start dovecot"
# stop program = "/bin/systemctl stop dovecot"
# if failed port 993 type tcpssl for 2 cycles then restart
# if failed port 24 protocol lmtp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/nginx.conf <<'EOF'
#check process nginx with pidfile /run/nginx.pid
# start program = "/bin/systemctl start nginx"
# stop program = "/bin/systemctl stop nginx"
# if failed port 80 type tcp then restart
# if failed port 443 type tcpssl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/redis.conf <<'EOF'
#check process redis with pidfile /run/redis/redis-server.pid
# start program = "/bin/systemctl start redis-server"
# stop program = "/bin/systemctl stop redis-server"
# if failed host 127.0.0.1 port 6379 for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#
#check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
# if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
#EOF
#
#cat >/etc/monit/conf.d/rspamd.conf <<'EOF'
#check process rspamd with pidfile /run/rspamd/rspamd.pid
# start program = "/bin/systemctl start rspamd"
# stop program = "/bin/systemctl stop rspamd"
# if failed port 11333 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if failed port 11334 for 2 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if 5 restarts within 5 cycles then alert
#EOF
#
#cat >/etc/monit/conf.d/opendkim.conf <<'EOF'
#check process opendkim with pidfile /run/opendkim/opendkim.pid
# start program = "/bin/systemctl start opendkim"
# stop program = "/bin/systemctl stop opendkim"
# if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## optional: OpenDMARC
#if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/opendmarc.conf <<'EOF'
#check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
# start program = "/bin/systemctl start opendmarc"
# stop program = "/bin/systemctl stop opendmarc"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/opendmarc.conf || true
#fi
#
## optional: ClamAV
#if [[ "$CLAMAV_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/clamav.conf <<'EOF'
#check process clamd with pidfile /run/clamav/clamd.pid
# start program = "/bin/systemctl start clamav-daemon"
# stop program = "/bin/systemctl stop clamav-daemon"
# if failed unixsocket /run/clamav/clamd.ctl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/clamav.conf || true
#fi
#
## optional: Fail2Ban
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# cat >/etc/monit/conf.d/fail2ban.conf <<'EOF'
#check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
# start program = "/bin/systemctl start fail2ban"
# stop program = "/bin/systemctl stop fail2ban"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/conf.d/fail2ban.conf || true
#fi
#
#monit -t
#systemctl reload monit || systemctl restart monit
#systemctl status monit --no-pager || true
#log "[✓] WoltGuard aktiv."

View File

@ -1,439 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Flags laden (falls vorhanden)
INSTALLER_ENV="/etc/mailwolt/installer.env"
: "${CLAMAV_ENABLE:=}"; : "${OPENDMARC_ENABLE:=}"; : "${FAIL2BAN_ENABLE:=}"; : "${MONIT_HTTP:=}"
if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
. "$INSTALLER_ENV"
fi
CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
MONIT_HTTP="${MONIT_HTTP:-1}"
# ── Monit so konfigurieren, dass NUR monitrc.d/* geladen wird ────────────────
install -d -m 0755 /etc/monit/monitrc.d
install -d -m 0755 /etc/monit/conf.d # passiver Ablageort (NICHT includiert)
# Poll-Intervall (30s)
sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
# alle alten include-Zeilen raus und monitrc.d setzen
sed -i 's|^#\?\s*include .*$||g' /etc/monit/monitrc
grep -q '^include /etc/monit/monitrc.d/\*' /etc/monit/monitrc \
|| echo 'include /etc/monit/monitrc.d/*' >> /etc/monit/monitrc
# Optional: HTTP-UI nur einschalten, wenn explizit gewünscht
if [[ "$MONIT_HTTP" = "1" ]]; then
grep -q '^set httpd port 2812' /etc/monit/monitrc || cat >>/etc/monit/monitrc <<'HTTP'
set httpd port 2812 and
use address localhost
allow localhost
HTTP
fi
# KEIN Löschen mehr der Dateien wir verschieben je nach Status
# (vorher stand hier rm -rf /etc/monit/monitrc.d/* und rm -f /etc/monit/conf.d/*.conf)
# ── Helper-Skripte ──────────────────────────────────────────────────────────
install -d -m 0755 /usr/local/sbin
# Redis-Ping (Password: REDIS_PASSWORD aus installer.env oder .env)
cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
REDIS_PASS="${REDIS_PASS:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
if [[ -r "$APP_ENV" ]]; then
[[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
[[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
fi
[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'; }
REDIS_HOST="$(strip "${REDIS_HOST:-}")"
REDIS_PORT="$(strip "${REDIS_PORT:-}")"
REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
command -v redis-cli >/dev/null 2>&1 || exit 1
BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
[[ -n "$REDIS_PASSWORD" ]] && CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping) || CMD=("${BASE[@]}" ping)
[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
EOSH
chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
# Rspamd-Heal (Socke aufräumen, restart, Mini-Port-Check)
cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ENV="/etc/mailwolt/installer.env"
APP_ENV="/var/www/mailwolt/.env"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
REDIS_PASS="${REDIS_PASS:-}"
[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
if [[ -z "${REDIS_PASS}" && -r "$APP_ENV" ]]; then
REDIS_PASS="$(grep -E '^REDIS_PASS=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
fi
# Rspamd Runtime fixen
install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
echo "$(date '+%F %T') heal run" >> /var/log/rspamd-heal.log
# Neustart
systemctl restart rspamd
# Mini-Healthcheck
sleep 2
ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
exit 0
EOSH
chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
# ── Monit-Checks (nummeriert) fixe Dienste immer aktiv ────────────────────
# 10 Redis
cat >/etc/monit/monitrc.d/10-redis.conf <<'EOF'
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 for 2 cycles then restart
if 5 restarts within 5 cycles then alert
check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
EOF
# 20 Rspamd (robust via process-matching + Heal)
cat >/etc/monit/monitrc.d/20-rspamd.conf <<'EOF'
check process rspamd matching "rspamd: main process"
start program = "/bin/systemctl start rspamd" with timeout 120 seconds
stop program = "/bin/systemctl stop rspamd"
depends on redis
if failed host 127.0.0.1 port 11333 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if failed host 127.0.0.1 port 11334 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
if does not exist for 2 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
# 30 Postfix
cat >/etc/monit/monitrc.d/30-postfix.conf <<'EOF'
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed host 127.0.0.1 port 25 type tcp with timeout 15 seconds for 3 cycles then restart
if failed host 127.0.0.1 port 465 type tcpssl with timeout 10 seconds then restart
if failed host 127.0.0.1 port 587 type tcp with timeout 10 seconds then restart
if 5 restarts within 5 cycles then alert
EOF
# 30 Dovecot (IMAPS; LMTP oft Unix-Socket → kein TCP-Fehlalarm)
cat >/etc/monit/monitrc.d/30-dovecot.conf <<'EOF'
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 993 type tcpssl for 3 cycles then restart
if 5 restarts within 10 cycles then alert
EOF
# 40 Nginx
cat >/etc/monit/monitrc.d/40-nginx.conf <<'EOF'
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcpssl then restart
if 5 restarts within 5 cycles then alert
EOF
# 50 OpenDKIM
cat >/etc/monit/monitrc.d/50-opendkim.conf <<'EOF'
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then alert
EOF
move_monit_conf() {
local name="$1" # z.B. 55-opendmarc
local enabled="$2" # "0" oder "1"
local src="/etc/monit/conf.d/${name}.conf"
local dst="/etc/monit/monitrc.d/${name}.conf"
mkdir -p /etc/monit/conf.d /etc/monit/monitrc.d
# Falls Datei nirgends existiert → in conf.d anlegen (lesbare Quelle)
if [[ ! -f "$src" && ! -f "$dst" ]]; then
cat >"$src" <<'EOF_PAYLOAD'
__PAYLOAD__
EOF_PAYLOAD
fi
if [[ "$enabled" = "1" ]]; then
# Aktiv: in monitrc.d haben
if [[ -f "$src" && ! -f "$dst" ]]; then
mv -f "$src" "$dst"
fi
else
# Inaktiv: in conf.d haben
if [[ -f "$dst" && ! -f "$src" ]]; then
mv -f "$dst" "$src"
fi
fi
}
move_monit_conf "55-opendmarc" "${OPENDMARC_ENABLE:-0}" <<'EOF'
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 5 cycles then alert
EOF
move_monit_conf "60-clamav" "${CLAMAV_ENABLE:-0}" <<'EOF'
check process clamd matching "clamd"
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
move_monit_conf "70-fail2ban" "${FAIL2BAN_ENABLE:-0}" <<'EOF'
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 5 cycles then alert
EOF
# ── Monit neu laden ─────────────────────────────────────────────────────────
monit -t
systemctl reload monit || systemctl restart monit
# Optionaler Sichttest (CLI funktioniert auch ohne HTTP-UI)
# sleep 2
# monit summary || true
##!/usr/bin/env bash
#set -euo pipefail
#
## Flags laden (falls vorhanden)
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#: "${CLAMAV_ENABLE:=}"; : "${OPENDMARC_ENABLE:=}"; : "${FAIL2BAN_ENABLE:=}"; : "${MONIT_HTTP:=}"
#if [[ -z "${CLAMAV_ENABLE}${OPENDMARC_ENABLE}${FAIL2BAN_ENABLE}" && -r "$INSTALLER_ENV" ]]; then
# . "$INSTALLER_ENV"
#fi
#CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
#OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
#FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#MONIT_HTTP="${MONIT_HTTP:-1}"
#
## ── Monit so konfigurieren, dass NUR monitrc.d/* geladen wird ────────────────
#install -d -m 0755 /etc/monit/monitrc.d
## Poll-Intervall (30s)
#sed -i 's/^set daemon .*/set daemon 30/' /etc/monit/monitrc || true
## alle alten include-Zeilen raus und monitrc.d setzen
#sed -i 's|^#\?\s*include .*$||g' /etc/monit/monitrc
#grep -q '^include /etc/monit/monitrc.d/\*' /etc/monit/monitrc \
# || echo 'include /etc/monit/monitrc.d/*' >> /etc/monit/monitrc
#
## Optional: HTTP-UI nur einschalten, wenn explizit gewünscht
#if [[ "$MONIT_HTTP" = "1" ]]; then
# grep -q '^set httpd port 2812' /etc/monit/monitrc || cat >>/etc/monit/monitrc <<'HTTP'
#set httpd port 2812 and
# use address localhost
# allow localhost
#HTTP
#fi
#
#sudo mkdir -p /etc/monit/monitrc.d
#sudo rm -rf /etc/monit/monitrc.d/* 2>/dev/null || true
#sudo rm -f /etc/monit/conf.d/*.conf 2>/dev/null || true
#
## ── Helper-Skripte ──────────────────────────────────────────────────────────
#install -d -m 0755 /usr/local/sbin
#
## Redis-Ping (Password: REDIS_PASSWORD aus installer.env oder .env)
#cat >/usr/local/sbin/mailwolt-redis-ping.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASSWORD="${REDIS_PASSWORD:-}"
#REDIS_PASS="${REDIS_PASS:-}"
#
#[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV" || true
#if [[ -r "$APP_ENV" ]]; then
# [[ -z "${REDIS_HOST}" ]] && REDIS_HOST="$(grep -m1 '^REDIS_HOST=' "$APP_ENV" | cut -d= -f2- || true)"
# [[ -z "${REDIS_PORT}" ]] && REDIS_PORT="$(grep -m1 '^REDIS_PORT=' "$APP_ENV" | cut -d= -f2- || true)"
# [[ -z "${REDIS_PASSWORD}" ]] && REDIS_PASSWORD="$(grep -m1 '^REDIS_PASSWORD=' "$APP_ENV" | cut -d= -f2- || true)"
#fi
#[[ -z "${REDIS_PASSWORD}" && -n "${REDIS_PASS}" ]] && REDIS_PASSWORD="$REDIS_PASS"
#
#strip(){ printf '%s' "$1" | sed -E 's/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'; }
#REDIS_HOST="$(strip "${REDIS_HOST:-}")"
#REDIS_PORT="$(strip "${REDIS_PORT:-}")"
#REDIS_PASSWORD="$(strip "${REDIS_PASSWORD:-}")"
#
#command -v redis-cli >/dev/null 2>&1 || exit 1
#BASE=(timeout 2 redis-cli --no-auth-warning --raw -h "$REDIS_HOST" -p "$REDIS_PORT")
#[[ -n "$REDIS_PASSWORD" ]] && CMD=("${BASE[@]}" -a "$REDIS_PASSWORD" ping) || CMD=("${BASE[@]}" ping)
#[[ "$("${CMD[@]}" 2>/dev/null || true)" == "PONG" ]]
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-redis-ping.sh
#
## Rspamd-Heal (Socke aufräumen, restart, Mini-Port-Check)
#cat >/usr/local/sbin/mailwolt-rspamd-heal.sh <<'EOSH'
##!/usr/bin/env bash
#set -euo pipefail
#
#INSTALLER_ENV="/etc/mailwolt/installer.env"
#APP_ENV="/var/www/mailwolt/.env"
#
#REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
#REDIS_PORT="${REDIS_PORT:-6379}"
#REDIS_PASS="${REDIS_PASS:-}"
#
#[[ -r "$INSTALLER_ENV" ]] && . "$INSTALLER_ENV"
#if [[ -z "${REDIS_PASS}" && -r "$APP_ENV" ]]; then
# REDIS_PASS="$(grep -E '^REDIS_PASS=' "$APP_ENV" | head -n1 | cut -d= -f2- || true)"
#fi
#
## Rspamd Runtime fixen
#install -d -m 0755 -o _rspamd -g _rspamd /run/rspamd || true
#[[ -S /var/lib/rspamd/rspamd.sock ]] && rm -f /var/lib/rspamd/rspamd.sock || true
#
#echo "$(date '+%F %T') heal run" >> /var/log/rspamd-heal.log
#
## Neustart
#systemctl restart rspamd
#
## Mini-Healthcheck
#sleep 2
#ss -tln | grep -q ':11334' || echo "[WARN] Rspamd Controller Port 11334 nicht sichtbar"
#
#exit 0
#EOSH
#chmod 0755 /usr/local/sbin/mailwolt-rspamd-heal.sh
#
## ── Monit-Checks (nummeriert) ───────────────────────────────────────────────
## 10 Redis
#cat >/etc/monit/monitrc.d/10-redis.conf <<'EOF'
#check process redis with pidfile /run/redis/redis-server.pid
# start program = "/bin/systemctl start redis-server"
# stop program = "/bin/systemctl stop redis-server"
# if failed host 127.0.0.1 port 6379 for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#
#check program redis_ping path "/usr/local/sbin/mailwolt-redis-ping.sh"
# if status != 0 for 2 cycles then exec "/bin/systemctl restart redis-server"
#EOF
#
## 20 Rspamd (robust via process-matching + Heal)
#cat >/etc/monit/monitrc.d/20-rspamd.conf <<'EOF'
#check process rspamd matching "rspamd: main process"
# start program = "/bin/systemctl start rspamd" with timeout 120 seconds
# stop program = "/bin/systemctl stop rspamd"
# depends on redis
# if failed host 127.0.0.1 port 11333 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if failed host 127.0.0.1 port 11334 for 3 cycles then exec "/usr/local/sbin/mailwolt-rspamd-heal.sh"
# if does not exist for 2 cycles then restart
# if 5 restarts within 10 cycles then unmonitor
#EOF
#
## 30 Postfix
#cat >/etc/monit/monitrc.d/30-postfix.conf <<'EOF'
#check process postfix with pidfile /var/spool/postfix/pid/master.pid
# start program = "/bin/systemctl start postfix"
# stop program = "/bin/systemctl stop postfix"
# if failed host 127.0.0.1 port 25 type tcp with timeout 15 seconds for 3 cycles then restart
# if failed host 127.0.0.1 port 465 type tcpssl with timeout 10 seconds then restart
# if failed host 127.0.0.1 port 587 type tcp with timeout 10 seconds then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 30 Dovecot (IMAPS; LMTP oft Unix-Socket → kein TCP-Fehlalarm)
#cat >/etc/monit/monitrc.d/30-dovecot.conf <<'EOF'
#check process dovecot with pidfile /run/dovecot/master.pid
# start program = "/bin/systemctl start dovecot"
# stop program = "/bin/systemctl stop dovecot"
# if failed port 993 type tcpssl for 3 cycles then restart
# if 5 restarts within 10 cycles then alert
#EOF
#
## 40 Nginx
#cat >/etc/monit/monitrc.d/40-nginx.conf <<'EOF'
#check process nginx with pidfile /run/nginx.pid
# start program = "/bin/systemctl start nginx"
# stop program = "/bin/systemctl stop nginx"
# if failed port 80 type tcp then restart
# if failed port 443 type tcpssl then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 50 OpenDKIM
#cat >/etc/monit/monitrc.d/50-opendkim.conf <<'EOF'
#check process opendkim with pidfile /run/opendkim/opendkim.pid
# start program = "/bin/systemctl start opendkim"
# stop program = "/bin/systemctl stop opendkim"
# if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
# if 5 restarts within 5 cycles then alert
#EOF
#
## 55 OpenDMARC (optional)
#if [[ "$OPENDMARC_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/55-opendmarc.conf <<'EOF'
#check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
# start program = "/bin/systemctl start opendmarc"
# stop program = "/bin/systemctl stop opendmarc"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/monitrc.d/55-opendmarc.conf || true
#fi
#
## 60 ClamAV (über Socket)
#if [[ "$CLAMAV_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/60-clamav.conf <<'EOF'
#check process clamd matching "clamd"
# start program = "/bin/systemctl start clamav-daemon"
# stop program = "/bin/systemctl stop clamav-daemon"
# if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
# if 5 restarts within 10 cycles then unmonitor
#EOF
#else
# rm -f /etc/monit/monitrc.d/60-clamav.conf || true
#fi
#
## 70 Fail2Ban (optional)
#if [[ "$FAIL2BAN_ENABLE" = "1" ]]; then
# cat >/etc/monit/monitrc.d/70-fail2ban.conf <<'EOF'
#check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
# start program = "/bin/systemctl start fail2ban"
# stop program = "/bin/systemctl stop fail2ban"
# if 5 restarts within 5 cycles then alert
#EOF
#else
# rm -f /etc/monit/monitrc.d/70-fail2ban.conf || true
#fi
#
## ── Monit neu laden ─────────────────────────────────────────────────────────
#monit -t
#systemctl reload monit || systemctl restart monit
#
## Optionaler Sichttest (CLI funktioniert auch ohne HTTP-UI)
##sleep 2
##monit summary || true

View File

@ -1,150 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "MOTD installieren …"
install -d /usr/local/bin
cat >/usr/local/bin/mw-motd <<'SH'
#!/usr/bin/env bash
# MOTD MailWolt
# bewusst KEIN "set -e" und KEIN pipefail; MOTD darf nie hart abbrechen
set -u
# ---------- Farben ----------
NC="\033[0m"; WH="\033[1;37m"; CY="\033[1;36m"; GY="\033[0;90m"
GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"
# ---------- Breite / Zentrierung ----------
W=110
term_cols=$(tput cols 2>/dev/null || echo $W)
[ "$term_cols" -gt "$W" ] && pad=$(( (term_cols - W)/2 )) || pad=0
sp(){ [ "$1" -gt 0 ] && printf "%${1}s" " " || true; }
center() { local s="$1"; local n=$(( (W - ${#s})/2 )); sp $((pad+n)); printf "%s\n" "$s"; }
rule(){ sp "$pad"; printf "%0.s=" $(seq 1 "$W"); printf "\n"; }
title(){ sp "$pad"; local t="$1"; local lf=$(( (W - ${#t} - 2)/2 )); local rf=$(( W - ${#t} - 2 - lf )); \
printf "%s" "$(printf '─%.0s' $(seq 1 $lf))"; printf " %s " "$t"; printf "%s\n" "$(printf '─%.0s' $(seq 1 $rf))"; }
kv(){ sp "$pad"; printf "%-12s: %s\n" "$1" "$2"; }
# ---------- Installer-/App-Variablen ----------
UI_HOST=""; WEBMAIL_HOST=""; MAIL_HOSTNAME=""
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env || true
# ---------- Systemdaten ----------
now="$(date '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || echo '-')"
upt="$(uptime -p 2>/dev/null || echo '-')"
cores="$(nproc 2>/dev/null || echo 1)"
load_raw="$(awk '{printf "%s / %s / %s",$1,$2,$3}' /proc/loadavg 2>/dev/null || echo '0.00 / 0.00 / 0.00')"
load1="$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo 0)"
# RAM/SWAP
mem_total="$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
mem_avail="$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
mem_used=$(( mem_total - mem_avail ))
swap_total="$(awk '/SwapTotal/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
swap_free="$(awk '/SwapFree/ {print int($2/1024)}' /proc/meminfo 2>/dev/null || echo 0)"
swap_used=$(( swap_total - swap_free ))
pct(){ local u="$1" t="$2"; [ "$t" -gt 0 ] || { echo 0; return; }; awk -v u="$u" -v t="$t" 'BEGIN{printf "%d",(u*100)/t}' ; }
ram_pct=$(pct "$mem_used" "$mem_total")
swap_pct=$(pct "$swap_used" "$swap_total")
# Disks
df_line(){ df -hP "$1" 2>/dev/null | awk 'NR==2{printf "%s / %s (%s)",$3,$2,$5}'; }
df_pct(){ df -P "$1" 2>/dev/null | awk 'NR==2{gsub("%","",$5);print $5+0}'; }
disk_root="$(df_line /)"; pct_root="$(df_pct /)"
disk_var="$(df_line /var 2>/dev/null)"; [ -n "$disk_var" ] || disk_var="-"
pct_var="$(df_pct /var 2>/dev/null)"; [ -n "$pct_var" ] || pct_var=0
# IPs (int/ext)
ipv4_int="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i!~/:/){print $i;exit}}')"
ipv6_int="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i~/:/){print $i;exit}}')"
ipv4_ext="$(curl -4fsS --max-time 1 https://ifconfig.me 2>/dev/null || true)"
ipv6_ext="$(curl -6fsS --max-time 1 https://ifconfig.me 2>/dev/null || true)"
# ---------- Status-Farben ----------
mark(){ # value thresholdY thresholdR
local v="$1" y="$2" r="$3"
if [ "$v" -ge "$r" ]; then printf "${RD}[HIGH]${NC}"
elif [ "$v" -ge "$y" ]; then printf "${YE}[WARN]${NC}"
else printf "${GR}[OK]${NC}"
fi
}
# Load/CPU-Schwellen (pro Core)
load_pct=$(awk -v l="$load1" -v c="$cores" 'BEGIN{if(c<1)c=1; printf "%d", (l/c)*100}')
m_load="$(mark "$load_pct" 70 100)"
m_ram="$(mark "$ram_pct" 75 90)"
m_swap="$(mark "$swap_pct" 10 50)"
m_root="$(mark "$pct_root" 75 90)"
m_var="$(mark "$pct_var" 75 90)"
# ---------- Header ----------
rule
center ""
center ":::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::"
center ":+:+:+ :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: "
center ":+: +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ "
center "+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+ "
center "+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+ "
center "#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+# "
center "### ### ### ### ########### ########## ### ### ######## ########## ### "
center ""
rule
# ---------- System ----------
kv "Date / Time" "${YE}${now}${NC}"
sp "$pad"; printf "%-12s: int %-40s ext %s\n" "IPv4" "${ipv4_int:--}" "${ipv4_ext:--}"
sp "$pad"; printf "%-12s: int %-40s ext %s\n" "IPv6" "${ipv6_int:--}" "${ipv6_ext:--}"
kv "Uptime" "$upt"
sp "$pad"; printf "%-12s: %s cores, load %s %b\n" "CPU" "$cores" "$load_raw" "$m_load"
sp "$pad"; printf "%-12s: %s MiB / %s MiB (%d%%) %b %-5s %s MiB / %s MiB (%d%%) %b\n" \
"RAM" "$mem_used" "$mem_total" "$ram_pct" "$m_ram" "SWAP:" "$swap_used" "$swap_total" "$swap_pct" "$m_swap"
sp "$pad"; printf "%-12s: / %s %b %-5s %s %b\n" \
"Disk" "$disk_root" "$m_root" "/var:" "$disk_var" "$m_var"
echo
# ---------- Domains ----------
title "Domains"
[ -n "${UI_HOST:-}" ] && kv "UI" "${UI_HOST}"
[ -n "${WEBMAIL_HOST:-}" ] && kv "Webmail" "${WEBMAIL_HOST}"
[ -n "${MAIL_HOSTNAME:-}" ]&& kv "MX" "${MAIL_HOSTNAME}"
echo
# ---------- Services (4 Spalten, bündig) ----------
title "Services"
svc_state(){ systemctl is-active --quiet "$1" && printf "${GR}[OK]${NC}" || printf "${RD}[FAIL]${NC}"; }
SVC=( nginx mariadb redis-server postfix dovecot rspamd opendkim opendmarc clamav-daemon fail2ban mailwolt-ws mailwolt-queue mailwolt-schedule )
i=0; line=""
for s in "${SVC[@]}"; do
st="$(svc_state "$s")"
seg="$(printf "%-18s %-7s" "$s" "$st")"
line="$line$seg"
i=$((i+1))
if [ $((i%4)) -eq 0 ]; then sp "$pad"; echo "$line"; line=""; else line="$line "; fi
done
[ -n "$line" ] && { sp "$pad"; echo "$line"; }
echo
exit 0
SH
chmod 755 /usr/local/bin/mw-motd
# update-motd Hook
if [[ -d /etc/update-motd.d ]]; then
cat >/etc/update-motd.d/10-mailwolt <<'SH'
#!/usr/bin/env bash
/usr/local/bin/mw-motd
SH
chmod +x /etc/update-motd.d/10-mailwolt
[[ -f /etc/update-motd.d/50-motd-news ]] && chmod -x /etc/update-motd.d/50-motd-news || true
else
# Fallback für Systeme ohne dynamic MOTD
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
SH
fi
: > /etc/motd 2>/dev/null || true
log "[✓] MOTD installiert."

View File

@ -1,212 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# ─────────────────────────────────────────────────────────────
# MailWolt Abschluss / Summary (Dienste, Zertifikate, Smoke-Test)
# ─────────────────────────────────────────────────────────────
# Farben & Deko
NC="\033[0m"; BOLD="\033[1m"; DIM="\033[2m"
GREEN="\033[1;32m"; RED="\033[1;31m"; YELLOW="\033[1;33m"; CYAN="\033[1;36m"; GREY="\033[0;90m"
OKS="${GREEN}OK${NC}"; FAILS="${RED}FAIL${NC}"
bar(){ printf "${CYAN}%s${NC}\n" "──────────────────────────────────────────────────────────────────────────────"; }
ok(){ printf " [${OKS}]\n"; }
fail(){ printf " [${FAILS}]\n"; }
# Installer-Variablen laden (falls vorhanden)
set +u
[ -r /etc/mailwolt/installer.env ] && . /etc/mailwolt/installer.env
set -u
# Defaults / Umgebung
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
UI_HOST="${UI_HOST:-}"
WEBMAIL_HOST="${WEBMAIL_HOST:-}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-}"
APP_ENV="${APP_ENV:-production}"
PROXY_MODE="${PROXY_MODE:-}" # leer = nicht anzeigen; "1"=Proxy, "dev"=Dev, sonst "nein"
NPM_IP="${NPM_IP:-}"
LE_EMAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
ACME_WEBROOT="/var/www/letsencrypt"
# Zert-Pfade (werden via Hook nach /etc/ssl/* verlinkt)
UI_CERT="/etc/ssl/ui/fullchain.pem"
UI_KEY="/etc/ssl/ui/privkey.pem"
WEBMAIL_CERT="/etc/ssl/webmail/fullchain.pem"
MAIL_CERT="/etc/ssl/mail/fullchain.pem"
# IPs (aus lib.sh)
SERVER_PUBLIC_IPV4="${SERVER_PUBLIC_IPV4:-$(detect_ip)}"
SERVER_PUBLIC_IPV6="${SERVER_PUBLIC_IPV6:-$(detect_ipv6)}"
# URLs (https nur, wenn UI-Cert+Key vorhanden)
SCHEME="http"
[[ -s "$UI_CERT" && -s "$UI_KEY" ]] && SCHEME="https"
APP_URL="${SCHEME}://${UI_HOST:-$SERVER_PUBLIC_IPV4}"
WEBMAIL_URL="${SCHEME}://${WEBMAIL_HOST:-$SERVER_PUBLIC_IPV4}"
# Ziel eines Symlinks auflösen
real_target(){ readlink -f -- "$1" 2>/dev/null || true; }
# "LE" werten, wenn live/* ODER archive/* (auch fullchainN.pem) getroffen wird
is_le_path(){
local p="$1"
[[ "$p" == /etc/letsencrypt/live/*/fullchain.pem || "$p" == /etc/letsencrypt/archive/*/fullchain*.pem ]]
}
UI_CERT_TARGET="$(real_target "$UI_CERT")"
WEBMAIL_CERT_TARGET="$(real_target "$WEBMAIL_CERT")"
MAIL_CERT_TARGET="$(real_target "$MAIL_CERT")"
is_le_path() {
case "$1" in
/etc/letsencrypt/live/*) return 0 ;;
*) return 1 ;;
esac
}
# robust gegen set -u: immer ${var:-}
UI_LE="self-signed/none"
if [ -s "${UI_CERT:-}" ] && [ -n "${UI_CERT_TARGET:-}" ] && is_le_path "${UI_CERT_TARGET:-}"; then
UI_LE="LE"
fi
WEBMAIL_LE="self-signed/none"
if [ -s "${WEBMAIL_CERT:-}" ] && [ -n "${WEBMAIL_CERT_TARGET:-}" ] && is_le_path "${WEBMAIL_CERT_TARGET:-}"; then
WEBMAIL_LE="LE"
fi
MAIL_LE="self-signed/none"
if [ -s "${MAIL_CERT:-}" ] && [ -n "${MAIL_CERT_TARGET:-}" ] && is_le_path "${MAIL_CERT_TARGET:-}"; then
MAIL_LE="LE"
fi
echo
bar
printf " %s\n" "✔ MailWolt Bootstrap fertig"
bar
# Kopf-Infos
printf " %-14s %s\n" "Aufruf UI:" "${APP_URL}"
printf " %-14s %s\n" "Webmail:" "${WEBMAIL_URL}"
printf " %-14s %s\n" "App Root:" "${APP_DIR}"
printf " %-14s %s\n" "Mail-FQDN:" "${MAIL_HOSTNAME:-$SERVER_PUBLIC_IPV4}"
printf " %-14s %s\n" "BASE_DOMAIN:" "${BASE_DOMAIN}"
printf " %-14s %s\n" "LE-Email:" "${LE_EMAIL}"
printf " %-14s %s\n" "APP_ENV:" "${APP_ENV}"
# Proxy-Block nur anzeigen, wenn Variable gesetzt ist
if [[ -n "$PROXY_MODE" ]]; then
if [[ "$PROXY_MODE" == "1" ]]; then
printf " %-14s %s\n" "Proxy-Mode:" "ja (NPM: ${NPM_IP:-unbekannt})"
elif [[ "$PROXY_MODE" == "dev" ]]; then
printf " %-14s %s\n" "Proxy-Mode:" "Entwicklungsmodus"
else
printf " %-14s %s\n" "Proxy-Mode:" "nein"
fi
fi
printf " %-14s %s\n" "Server IPv6:" "${SERVER_PUBLIC_IPV6:-}"
printf " %-14s %s\n" "ACME Webroot:" "${ACME_WEBROOT}"
echo
printf " %-14s UI=%s, Webmail=%s, MX=%s\n" "Zertifikate:" "$UI_LE" "$WEBMAIL_LE" "$MAIL_LE"
echo
echo " Anmeldung: Keine vordefinierten Admin-Daten."
echo " Bitte zuerst registrieren (Erst-User wird Admin, danach"
echo " wird die Registrierung automatisch gesperrt)."
echo
# ── Dienste ────────────────────────────────────────────────────────────────
bar
echo " Services"
bar
OK_LIST=()
FAIL_LIST=()
svc(){
local unit="$1" label="${2:-$1}"
printf " • %-18s … " "$label"
if systemctl is-active --quiet "$unit"; then
ok
OK_LIST+=("$label")
else
fail
FAIL_LIST+=("$label")
fi
}
# Kern-Services
svc nginx
svc mariadb
svc redis-server
svc postfix
svc dovecot
# App-Worker (tolerant)
svc "${APP_USER}-ws" "mailwolt-ws" || true
svc "${APP_USER}-schedule" "mailwolt-schedule" || true
svc "${APP_USER}-queue" "mailwolt-queue" || true
echo
if ((${#OK_LIST[@]})); then
printf " ${GREEN}OK:${NC} %s\n" "$(IFS=', '; echo "${OK_LIST[*]}")"
fi
if ((${#FAIL_LIST[@]})); then
printf " ${RED}FAIL:${NC} %s\n" "$(IFS=', '; echo "${FAIL_LIST[*]}")"
echo " ${YELLOW}Hinweis:${NC} Details mit: journalctl -u <dienst> -b --no-pager"
fi
echo
# ── Smoke-Test ─────────────────────────────────────────────────────────────
bar
echo " Smoke-Test (SMTP/IMAP/POP3 mit/ohne TLS)"
bar
check_port(){
local tag="$1" cmd="$2" desc="$3"
printf " [%-3s] %-35s … " "$tag" "$desc"
if timeout 8s bash -lc "$cmd" >/dev/null 2>&1; then ok; else fail; fi
}
# kleines Delay nach Erststart
sleep 6 || true
# SMTP
check_port "25" 'printf "EHLO x\r\nQUIT\r\n" | nc -w 3 127.0.0.1 25' \
"SMTP (EHLO)"
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof' \
"SMTPS (TLS + EHLO)"
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof' \
"Submission (STARTTLS)"
# POP/IMAP
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110' \
"POP3 (QUIT)"
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof' \
"POP3S (TLS + QUIT)"
check_port "143" 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | nc -w 3 127.0.0.1 143' \
"IMAP (CAPABILITY/LOGOUT)"
check_port "993" 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof' \
"IMAPS (TLS + CAPABILITY/LOGOUT)"
echo
# Hinweise nur ausgeben, wenn wirklich kein LE für UI/Webmail
if [[ "$UI_LE" != "LE" || "$WEBMAIL_LE" != "LE" ]]; then
echo -e " ${YELLOW}Hinweis:${NC} UI/Webmail verwenden noch kein Let's-Encrypt-Zertifikat."
echo -e " Prüfe Symlinks unter /etc/ssl/{ui,webmail} und den LE-Hook (21/75-Skripte)."
echo
fi
# Proxy-Info (optional)
if [[ "$PROXY_MODE" == "1" ]]; then
echo -e " ${GREY}Proxy-Hinweis:${NC} App erwartet TLS am Proxy (Backend ohne https-Redirects)."
echo
fi

View File

@ -1,633 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# --- Farbschema für whiptail (libnewt) hohe Lesbarkeit (dunkler Input, schwarze Schrift) ---
export NEWT_COLORS='
root=,blue
border=black,lightgray
window=black,lightgray
textbox=black,lightgray
label=black,lightgray
entry=black,cyan
button=black,cyan
actlistbox=black,cyan
actsellistbox=black,cyan
'
# optionales Backtitle (erscheint oben)
export DIALOGOPTS="--backtitle MailWolt Setup"
# ──────────────────────────────────────────────────────────────
# MailWolt Interaktiver Bootstrap (whiptail + Fallback)
# ──────────────────────────────────────────────────────────────
DEV_MODE=0
PROXY_MODE=0
NPM_IP=""
while [[ $# -gt 0 ]]; do
case "$1" in
-dev) DEV_MODE=1 ;;
-proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
esac
shift
done
APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
export DB_PASS REDIS_PASS
cd "$(dirname "$0")"
source ./lib.sh
require_root
header
# ── Defaults ──────────────────────────────────────────────────
APP_NAME="${APP_NAME:-MailWolt}"
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
UI_SUB="${UI_SUB:-ui}"
WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
MTA_SUB="${MTA_SUB:-mx}"
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
SERVER_PUBLIC_IPV4="$(detect_ip)"
SERVER_PUBLIC_IPV6="$(detect_ipv6)"
DEFAULT_TZ="$(detect_timezone)"
DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
# ── Helpers ───────────────────────────────────────────────────
have_whiptail(){ command -v whiptail >/dev/null 2>&1; }
#valid_fqdn(){
# [[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$ ]]
#}
# ── Host-Validierung & DEV-Erkennung ────────────────────────────────────────
valid_fqdn_prod(){ [[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?\.)+[a-z]{2,}$ ]]; }
valid_host_dev(){
# erlaubt: single-label (ui, webmail), FQDNs, IPv4
[[ "$1" =~ ^([a-z0-9]([-a-z0-9]*[a-z0-9])?)(\.[a-z0-9-]+)*$ ]] || [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
}
is_local_like(){
local h="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
[[ "$h" =~ \.local$ || "$h" =~ \.loc$ || "$h" =~ \.dev$ || "$h" =~ \.test$ || "$h" = "localhost" ]] && return 0
[[ "$h" =~ ^10\. || "$h" =~ ^192\.168\. || "$h" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. || "$h" =~ ^127\. ]] && return 0
return 1
}
normalize_host(){
# $1=input $2=default (nutzt DEV_MODE für die passende Prüflogik)
local inp="$1" def="$2"
if [[ "${DEV_MODE}" = "1" ]]; then
valid_host_dev "$inp" && { echo "$inp"; return; }
else
valid_fqdn_prod "$inp" && { echo "$inp"; return; }
fi
echo "$def"
}
ask_tty_domain(){
local label="$1" example="$2" def="$3" outvar="$4" inp
echo -e "${CYAN}${label}${NC}"
echo -e " z.B. ${YELLOW}${example}${NC}"
echo -e " Default: ${GREY}${def}${NC}"
read -r -p " Eingabe (Enter=Default): " inp || true
inp="${inp:-$def}"
if ! valid_fqdn "$inp"; then
echo -e "${YELLOW}[!] Ungültiger FQDN, nehme Default: ${def}${NC}"
inp="$def"
fi
eval "$outvar='$inp'"
}
# ── Interaktive Eingaben (whiptail oder Fallback) ─────────────
MTA_DEFAULT="${MTA_SUB}.${BASE_DOMAIN}"
UI_DEFAULT="${UI_SUB}.${BASE_DOMAIN}"
WEBMAIL_DEFAULT="${WEBMAIL_SUB}.${BASE_DOMAIN}"
CLAMAV_ENABLE=1
OPENDMARC_ENABLE=1
FAIL2BAN_ENABLE=1
if command -v whiptail >/dev/null 2>&1; then
TITLE="MailWolt Setup"
# Hinweise zu erlaubten DEV-Hosts
MSG_SUFFIX="\n\nHinweis: Im DEV-Modus sind auch single-label Hosts (z.B. ui, webmail), *.local/*.dev und IPs erlaubt."
_mta_in="$(whiptail --title "$TITLE" --inputbox "Mailserver-Host (MX)\nBeispiele: mx.domain.tld | mx.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$MTA_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
_ui_in="$(whiptail --title "$TITLE" --inputbox "UI / Admin-Panel Host\nBeispiele: ui.domain.tld | ui.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$UI_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
_wm_in="$(whiptail --title "$TITLE" --inputbox "Webmail Host\nBeispiele: webmail.domain.tld | web.local | 10.0.0.10${MSG_SUFFIX}" 13 70 "$WEBMAIL_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# ZUERST provisorisch prüfen, ob „lokal“ → DEV erzwingen
if is_local_like "$_mta_in" || is_local_like "$_ui_in" || is_local_like "$_wm_in"; then
DEV_MODE=1; APP_ENV="local"; APP_DEBUG="true"
fi
export DEV_MODE APP_ENV APP_DEBUG
# Jetzt mit passender Logik normalisieren
MTA_FQDN="$(normalize_host "$_mta_in" "$MTA_DEFAULT")"
UI_FQDN="$(normalize_host "$_ui_in" "$UI_DEFAULT")"
WEBMAIL_FQDN="$(normalize_host "$_wm_in" "$WEBMAIL_DEFAULT")"
CHOICES="$(whiptail --title "$TITLE" --checklist "Optionale Dienste aktivieren" 15 70 6 \
"ClamAV" "Virenscan (clamd/clamav-daemon)" ON \
"OpenDMARC" "DMARC-Auswertung" ON \
"Fail2Ban" "Brute-Force-Schutz" ON \
3>&1 1>&2 2>&3)" || true
CLAMAV_ENABLE=0; [[ "$CHOICES" == *"ClamAV"* ]] && CLAMAV_ENABLE=1
OPENDMARC_ENABLE=0; [[ "$CHOICES" == *"OpenDMARC"* ]] && OPENDMARC_ENABLE=1
FAIL2BAN_ENABLE=0; [[ "$CHOICES" == *"Fail2Ban"* ]] && FAIL2BAN_ENABLE=1
else
echo -e "${GREY}[i] whiptail nicht gefunden TTY-Fallback.${NC}\n"
read -r -p "Mailserver-Host (MX) [${MTA_DEFAULT}]: " _mta_in; _mta_in="${_mta_in:-$MTA_DEFAULT}"
read -r -p "UI / Admin-Panel Host [${UI_DEFAULT}]: " _ui_in; _ui_in="${_ui_in:-$UI_DEFAULT}"
read -r -p "Webmail Host [${WEBMAIL_DEFAULT}]: " _wm_in; _wm_in="${_wm_in:-$WEBMAIL_DEFAULT}"
if is_local_like "$_mta_in" || is_local_like "$_ui_in" || is_local_like "$_wm_in"; then
DEV_MODE=1; APP_ENV="local"; APP_DEBUG="true"
fi
export DEV_MODE APP_ENV APP_DEBUG
MTA_FQDN="$(normalize_host "$_mta_in" "$MTA_DEFAULT")"
UI_FQDN="$(normalize_host "$_ui_in" "$UI_DEFAULT")"
WEBMAIL_FQDN="$(normalize_host "$_wm_in" "$WEBMAIL_DEFAULT")"
read -r -p "ClamAV aktivieren? (1/0, Enter=1): " CLAMAV_ENABLE; CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
read -r -p "OpenDMARC aktivieren? (1/0, Enter=1): " OPENDMARC_ENABLE; OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
read -r -p "Fail2Ban aktivieren? (1/0, Enter=1): " FAIL2BAN_ENABLE; FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
fi
#if have_whiptail; then
# TITLE="MailWolt Setup"
#
# MTA_FQDN="$(whiptail --title "$TITLE" --inputbox "Mailserver-FQDN (MX)\nBeispiel: mx.domain.tld" 11 70 "$MTA_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$MTA_FQDN" || MTA_FQDN="$MTA_DEFAULT"
#
# UI_FQDN="$(whiptail --title "$TITLE" --inputbox "UI / Admin-Panel FQDN\nBeispiel: ui.domain.tld" 11 70 "$UI_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$UI_FQDN" || UI_FQDN="$UI_DEFAULT"
#
# WEBMAIL_FQDN="$(whiptail --title "$TITLE" --inputbox "Webmail FQDN\nBeispiel: webmail.domain.tld" 11 70 "$WEBMAIL_DEFAULT" 3>&1 1>&2 2>&3)" || exit 1
# valid_fqdn "$WEBMAIL_FQDN" || WEBMAIL_FQDN="$WEBMAIL_DEFAULT"
#
# CHOICES="$(whiptail --title "$TITLE" --checklist "Optionale Dienste aktivieren" 15 70 6 \
# "ClamAV" "Virenscan (clamd/clamav-daemon)" ON \
# "OpenDMARC" "DMARC-Auswertung" ON \
# "Fail2Ban" "Brute-Force-Schutz" ON \
# 3>&1 1>&2 2>&3)" || true
#
# CLAMAV_ENABLE=0; [[ "$CHOICES" == *"ClamAV"* ]] && CLAMAV_ENABLE=1
# OPENDMARC_ENABLE=0; [[ "$CHOICES" == *"OpenDMARC"* ]] && OPENDMARC_ENABLE=1
# FAIL2BAN_ENABLE=0; [[ "$CHOICES" == *"Fail2Ban"* ]] && FAIL2BAN_ENABLE=1
#
# whiptail --title "$TITLE" --msgbox "Zusammenfassung:
#
#MX : $MTA_FQDN
#UI : $UI_FQDN
#Webmail : $WEBMAIL_FQDN
#
#ClamAV : $([[ $CLAMAV_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#OpenDMARC : $([[ $OPENDMARC_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#Fail2Ban : $([[ $FAIL2BAN_ENABLE -eq 1 ]] && echo Aktiv || echo Deaktiv)
#" 16 70
#
#else
# echo -e "${GREY}[i] whiptail nicht gefunden nutze TTY-Prompts.${NC}\n"
# ask_tty_domain "Mailserver-FQDN (MX)" "mx.domain.tld" "$MTA_DEFAULT" MTA_FQDN
# ask_tty_domain "UI / Admin-Panel FQDN" "ui.domain.tld" "$UI_DEFAULT" UI_FQDN
# ask_tty_domain "Webmail FQDN" "webmail.domain.tld" "$WEBMAIL_DEFAULT" WEBMAIL_FQDN
#
# read -r -p "ClamAV aktivieren? (1/0, Enter=1): " CLAMAV_ENABLE; CLAMAV_ENABLE="${CLAMAV_ENABLE:-1}"
# read -r -p "OpenDMARC aktivieren? (1/0, Enter=1): " OPENDMARC_ENABLE; OPENDMARC_ENABLE="${OPENDMARC_ENABLE:-1}"
# read -r -p "Fail2Ban aktivieren? (1/0, Enter=1): " FAIL2BAN_ENABLE; FAIL2BAN_ENABLE="${FAIL2BAN_ENABLE:-1}"
#fi
# ── Defaults/Kompatibilität ──────────────────────────────────
MTA_FQDN="${MTA_FQDN:-${MTA_DEFAULT}}"
UI_FQDN="${UI_FQDN:-${UI_DEFAULT}}"
WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_DEFAULT}}"
DKIM_ENABLE="${DKIM_ENABLE:-1}"
DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
DKIM_GENERATE="${DKIM_GENERATE:-1}"
# BASE_DOMAIN/Subs aus FQDNs ableiten
if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then MTA_SUB="${BASH_REMATCH[1]}"; BASE_DOMAIN="${BASH_REMATCH[2]}"; fi
if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then UI_SUB="${BASH_REMATCH[1]}"; fi
if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then WEBMAIL_SUB="${BASH_REMATCH[1]}"; fi
SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
MAIL_HOSTNAME="${MTA_FQDN}"
UI_HOST="${UI_FQDN}"
WEBMAIL_HOST="${WEBMAIL_FQDN}"
APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
# ── Export & persist ─────────────────────────────────────────
export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
export DB_NAME DB_USER
export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
export CLAMAV_ENABLE OPENDMARC_ENABLE FAIL2BAN_ENABLE
install -d -m 0755 /etc/mailwolt
cat >/etc/mailwolt/installer.env <<EOF
SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
APP_TZ=${APP_TZ}
APP_LOCALE=${APP_LOCALE}
BASE_DOMAIN=${BASE_DOMAIN}
MTA_SUB=${MTA_SUB}
UI_SUB=${UI_SUB}
WEBMAIL_SUB=${WEBMAIL_SUB}
MAIL_HOSTNAME=${MAIL_HOSTNAME}
UI_HOST=${UI_HOST}
WEBMAIL_HOST=${WEBMAIL_HOST}
LE_EMAIL=${LE_EMAIL:-admin@${BASE_DOMAIN}}
SYSMAIL_SUB=${SYSMAIL_SUB}
SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
DKIM_ENABLE=${DKIM_ENABLE}
DKIM_SELECTOR=${DKIM_SELECTOR}
DKIM_GENERATE=${DKIM_GENERATE}
DB_HOST=127.0.0.1
DB_NAME=${DB_NAME}
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASS=${REDIS_PASS}
SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
APP_ENV=${APP_ENV}
CLAMAV_ENABLE=${CLAMAV_ENABLE}
OPENDMARC_ENABLE=${OPENDMARC_ENABLE}
FAIL2BAN_ENABLE=${FAIL2BAN_ENABLE}
BACKUP_ONCALENDAR="${BACKUP_ONCALENDAR:-*-*-* 03:00:00}"
BACKUP_ENABLED=0
BACKUP_INTERVAL=daily
BACKUP_RETENTION_DAYS=7
BACKUP_DIR=/var/backups/mailwolt
BACKUP_USE_ZSTD=1
EOF
chmod 600 /etc/mailwolt/installer.env
# ── Installer-Sequenz ────────────────────────────────────────
for STEP in \
10-provision \
20-ssl 21-le-deploy-hook 22-dkim-helper \
30-db 40-postfix 50-dovecot \
60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 64-apply-milters \
70-nginx 75-le-issue 80-app 88-update-wrapper 90-services \
92-sudoers-npm 93-backup-tools 95-woltguard 98-motd 99-summary
do
log ">>> Running ${STEP}.sh"
bash "./${STEP}.sh"
done
##!/usr/bin/env bash
#set -euo pipefail
#
## --- Flags / Modi ---
#DEV_MODE=0
#PROXY_MODE=0
#NPM_IP=""
#
#while [[ $# -gt 0 ]]; do
# case "$1" in
# -dev) DEV_MODE=1 ;;
# -proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
# esac
# shift
#done
#
#APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
#APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
#export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
#
#DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
#REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
#
#export DB_PASS REDIS_PASS
#
#cd "$(dirname "$0")"
#source ./lib.sh
#require_root
#header
#
## ── Defaults ────────────────────────────────────────────────────────────────
#APP_NAME="${APP_NAME:-MailWolt}"
#APP_USER="${APP_USER:-mailwolt}"
#APP_GROUP="${APP_GROUP:-www-data}"
#APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
#APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
#
#BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
#UI_SUB="${UI_SUB:-ui}"
#WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
#MTA_SUB="${MTA_SUB:-mx}"
#
#DB_NAME="${DB_NAME:-${APP_USER}}"
#DB_USER="${DB_USER:-${APP_USER}}"
#
#SERVER_PUBLIC_IPV4="$(detect_ip)"
#SERVER_PUBLIC_IPV6="$(detect_ipv6)"
#DEFAULT_TZ="$(detect_timezone)"
#DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
#
#echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
#
## ── Schöne, farbige Abfragen ────────────────────────────────────────────────
#echo -e "${CYAN}"
#echo "──────────────────────────────────────────────"
#echo -e " 📧 MailWolt Setup Domain Konfiguration"
#echo "──────────────────────────────────────────────"
#echo -e "${NC}"
#
#MTA_DEFAULT="${MTA_SUB}.${BASE_DOMAIN}"
#UI_DEFAULT="${UI_SUB}.${BASE_DOMAIN}"
#WEBMAIL_DEFAULT="${WEBMAIL_SUB}.${BASE_DOMAIN}"
#
#ask_domain() {
# local __outvar="$1" label="$2" example="$3" defval="$4" input=""
# echo -e "${GREEN}[?]${NC} ${label}"
# echo -e " z.B. ${YELLOW}${example}${NC}"
# echo -e " Default: ${CYAN}${defval}${NC}"
# echo -ne " → Eingabe: ${CYAN}"
# read -r input
# echo -e "${NC}"
# if [[ -z "$input" ]]; then
# eval "$__outvar='$defval'"
# else
# eval "$__outvar='$input'"
# fi
#}
#
#ask_toggle() {
# local __outvar="$1" label="$2" defval="${3:-1}" input=""
# echo -ne "${GREEN}[?]${NC} ${label} (${CYAN}1${NC}=Ja / ${YELLOW}0${NC}=Nein) [Enter=${defval}]: "
# read -r input
# input="${input:-$defval}"
# case "$input" in
# 1|0) ;;
# *) echo -e "${YELLOW}Ungültig, nehme Default=${defval}.${NC}"; input="$defval" ;;
# esac
# eval "$__outvar='$input'"
#}
#
#ask_domain "MTA_FQDN" "Mailserver-FQDN (MX)" "mx.domain.tld" "$MTA_DEFAULT"
#ask_domain "UI_FQDN" "UI / Admin-Panel" "ui.domain.tld" "$UI_DEFAULT"
#ask_domain "WEBMAIL_FQDN" "Webmail-FQDN" "webmail.domain.tld" "$WEBMAIL_DEFAULT"
#
#echo -e "${CYAN}"
#echo "──────────────────────────────────────────────"
#echo -e " 🛡 Optionale Dienste"
#echo "──────────────────────────────────────────────"
#echo -e "${NC}"
#
#ask_toggle "CLAMAV_ENABLE" "ClamAV Virenscan aktivieren?" 1
#ask_toggle "OPENDMARC_ENABLE" "OpenDMARC auswerten?" 1
#ask_toggle "FAIL2BAN_ENABLE" "Fail2Ban aktivieren?" 1
#echo
#
## Defaults, wenn Enter gedrückt (Abwärtskompatibilität)
#MTA_FQDN="${MTA_FQDN:-${MTA_SUB}.${BASE_DOMAIN}}"
#UI_FQDN="${UI_FQDN:-${UI_SUB}.${BASE_DOMAIN}}"
#WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_SUB}.${BASE_DOMAIN}}"
#DKIM_ENABLE="${DKIM_ENABLE:-1}"
#DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
#DKIM_GENERATE="${DKIM_GENERATE:-1}"
#
## BASE_DOMAIN und Sub-Labels aus MTA/UI/WEBMAIL ableiten (robust)
#if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# MTA_SUB="${BASH_REMATCH[1]}"
# BASE_DOMAIN="${BASH_REMATCH[2]}"
#fi
#if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# UI_SUB="${BASH_REMATCH[1]}"
#fi
#if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
# WEBMAIL_SUB="${BASH_REMATCH[1]}"
#fi
#
#SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
#SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
## Kanonische Host-Variablen (NIE wieder zusammenbauen nimm die FQDNs)
#MAIL_HOSTNAME="${MTA_FQDN}"
#UI_HOST="${UI_FQDN}"
#WEBMAIL_HOST="${WEBMAIL_FQDN}"
#
## Zeitzone/Locale sinnvoll setzen
#APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
#APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
#
## ── Variablen exportieren ───────────────────────────────────────────────────
#export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
#export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
#export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
#export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
#export DB_NAME DB_USER
#export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
#export CLAMAV_ENABLE OPENDMARC_ENABLE FAIL2BAN_ENABLE
#
#install -d -m 0755 /etc/mailwolt
#cat >/etc/mailwolt/installer.env <<EOF
#BASE_DOMAIN=${BASE_DOMAIN}
#MTA_SUB=${MTA_SUB}
#UI_SUB=${UI_SUB}
#WEBMAIL_SUB=${WEBMAIL_SUB}
#
#MAIL_HOSTNAME=${MAIL_HOSTNAME}
#UI_HOST=${UI_HOST}
#WEBMAIL_HOST=${WEBMAIL_HOST}
#
#SYSMAIL_SUB=${SYSMAIL_SUB}
#SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
#
#DKIM_ENABLE=${DKIM_ENABLE}
#DKIM_SELECTOR=${DKIM_SELECTOR}
#DKIM_GENERATE=${DKIM_GENERATE}
#
#DB_HOST=127.0.0.1
#DB_NAME=${DB_NAME}
#DB_USER=${DB_USER}
#DB_PASS=${DB_PASS}
#REDIS_PASS=${REDIS_PASS}
#
#SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
#SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
#APP_ENV=${APP_ENV}
#
#CLAMAV_ENABLE=${CLAMAV_ENABLE}
#OPENDMARC_ENABLE=${OPENDMARC_ENABLE}
#FAIL2BAN_ENABLE=${FAIL2BAN_ENABLE}
#EOF
#
#chmod 600 /etc/mailwolt/installer.env
#
## ── Sequenz ────────────────────────────────────────────────────────────────
#for STEP in 10-provision 20-ssl 21-le-deploy-hook 22-dkim-helper 30-db 40-postfix 50-dovecot 60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 70-nginx 75-le-issue 80-app 90-services 95-woltguard 98-motd 99-summary
#do
# log ">>> Running ${STEP}.sh"
# bash "./${STEP}.sh"
#done
###!/usr/bin/env bash
##set -euo pipefail
##
### --- Flags / Modi ---
##DEV_MODE=0
##PROXY_MODE=0
##NPM_IP=""
##
##while [[ $# -gt 0 ]]; do
## case "$1" in
## -dev) DEV_MODE=1 ;;
## -proxy) PROXY_MODE=1; NPM_IP="${2:-}"; shift ;;
## esac
## shift
##done
##
##APP_ENV="${APP_ENV:-$([[ $DEV_MODE -eq 1 ]] && echo local || echo production)}"
##APP_DEBUG="${APP_DEBUG:-$([[ $DEV_MODE -eq 1 ]] && echo true || echo false)}"
##export DEV_MODE PROXY_MODE NPM_IP APP_ENV APP_DEBUG
##
##DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
##REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
##
##export DB_PASS REDIS_PASS
##
##cd "$(dirname "$0")"
##source ./lib.sh
##require_root
##header
##
### ── Defaults ────────────────────────────────────────────────────────────────
##APP_NAME="${APP_NAME:-MailWolt}"
##APP_USER="${APP_USER:-mailwolt}"
##APP_GROUP="${APP_GROUP:-www-data}"
##APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
##APP_DIR="${APP_DIR:-/var/www/${APP_USER}}"
##
##BASE_DOMAIN="${BASE_DOMAIN:-example.com}"
##UI_SUB="${UI_SUB:-ui}"
##WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
##MTA_SUB="${MTA_SUB:-mx}"
##
##DB_NAME="${DB_NAME:-${APP_USER}}"
##DB_USER="${DB_USER:-${APP_USER}}"
##
##SERVER_PUBLIC_IPV4="$(detect_ip)"
##SERVER_PUBLIC_IPV6="$(detect_ipv6)"
##DEFAULT_TZ="$(detect_timezone)"
##DEFAULT_LOCALE="$(guess_locale_from_tz "$DEFAULT_TZ")"
##
##echo -e "${GREY}Erkannte IP (v4): ${SERVER_PUBLIC_IPV4} v6: ${SERVER_PUBLIC_IPV6:-}${NC}"
##
### ── FQDNs abfragen ───────────────────────────────────────────────────────────
##read -r -p "Mailserver FQDN (MX, z.B. mx.domain.tld) [Enter=${MTA_SUB}.${BASE_DOMAIN}]: " MTA_FQDN
##read -r -p "UI / Admin-Panel FQDN (z.B. ui.domain.tld) [Enter=${UI_SUB}.${BASE_DOMAIN}]: " UI_FQDN
##read -r -p "Webmail FQDN (z.B. webmail.domain.tld) [Enter=${WEBMAIL_SUB}.${BASE_DOMAIN}]: " WEBMAIL_FQDN
##
### Defaults, wenn Enter gedrückt
##MTA_FQDN="${MTA_FQDN:-${MTA_SUB}.${BASE_DOMAIN}}"
##UI_FQDN="${UI_FQDN:-${UI_SUB}.${BASE_DOMAIN}}"
##WEBMAIL_FQDN="${WEBMAIL_FQDN:-${WEBMAIL_SUB}.${BASE_DOMAIN}}"
##DKIM_ENABLE="${DKIM_ENABLE:-1}"
##DKIM_SELECTOR="${DKIM_SELECTOR:-mwl1}"
##DKIM_GENERATE="${DKIM_GENERATE:-1}"
##
### BASE_DOMAIN und Sub-Labels aus MTA/UI/WEBMAIL ableiten (robust)
##if [[ "$MTA_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## MTA_SUB="${BASH_REMATCH[1]}"
## BASE_DOMAIN="${BASH_REMATCH[2]}"
##fi
##if [[ "$UI_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## UI_SUB="${BASH_REMATCH[1]}"
##fi
##if [[ "$WEBMAIL_FQDN" =~ ^([^.]+)\.(.+)$ ]]; then
## WEBMAIL_SUB="${BASH_REMATCH[1]}"
##fi
##
##SYSMAIL_SUB="${SYSMAIL_SUB:-sysmail}"
##SYSMAIL_DOMAIN="${SYSMAIL_SUB}.${BASE_DOMAIN}"
### Kanonische Host-Variablen (NIE wieder zusammenbauen nimm die FQDNs)
##MAIL_HOSTNAME="${MTA_FQDN}"
##UI_HOST="${UI_FQDN}"
##WEBMAIL_HOST="${WEBMAIL_FQDN}"
##
### Zeitzone/Locale sinnvoll setzen (könntest du auch noch abfragen)
##APP_TZ="${APP_TZ:-$DEFAULT_TZ}"
##APP_LOCALE="${APP_LOCALE:-$DEFAULT_LOCALE}"
##
### ── Variablen exportieren ───────────────────────────────────────────────────
##export APP_NAME APP_USER APP_GROUP APP_USER_PREFIX APP_DIR
##export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB
##export SYSMAIL_SUB SYSMAIL_DOMAIN DKIM_ENABLE DKIM_SELECTOR DKIM_GENERATE
##export UI_HOST WEBMAIL_HOST MAIL_HOSTNAME
##export DB_NAME DB_USER
##export SERVER_PUBLIC_IPV4 SERVER_PUBLIC_IPV6 APP_TZ APP_LOCALE
##
##install -d -m 0755 /etc/mailwolt
##cat >/etc/mailwolt/installer.env <<EOF
##BASE_DOMAIN=${BASE_DOMAIN}
##MTA_SUB=${MTA_SUB}
##UI_SUB=${UI_SUB}
##WEBMAIL_SUB=${WEBMAIL_SUB}
##
##MAIL_HOSTNAME=${MAIL_HOSTNAME}
##UI_HOST=${UI_HOST}
##WEBMAIL_HOST=${WEBMAIL_HOST}
##
##SYSMAIL_SUB=${SYSMAIL_SUB}
##SYSMAIL_DOMAIN=${SYSMAIL_DOMAIN}
##
##DKIM_ENABLE=${DKIM_ENABLE}
##DKIM_SELECTOR=${DKIM_SELECTOR}
##DKIM_GENERATE=${DKIM_GENERATE}
##
##DB_HOST=127.0.0.1
##DB_NAME=${DB_NAME}
##DB_USER=${DB_USER}
##DB_PASS=${DB_PASS}
##REDIS_PASS=${REDIS_PASS}
##
##SERVER_PUBLIC_IPV4=${SERVER_PUBLIC_IPV4}
##SERVER_PUBLIC_IPV6=${SERVER_PUBLIC_IPV6}
##APP_ENV=${APP_ENV}
##
##CLAMAV_ENABLE=1
##OPENDMARC_ENABLE=1
##FAIL2BAN_ENABLE=1
##EOF
##
##chmod 600 /etc/mailwolt/installer.env
##
### ── Sequenz ────────────────────────────────────────────────────────────────
##for STEP in 10-provision 20-ssl 21-le-deploy-hook 22-dkim-helper 30-db 40-postfix 50-dovecot 60-rspamd-opendkim 61-opendmarc 62-clamav 63-fail2ban 70-nginx 75-le-issue 80-app 90-services 95-woltguard 98-motd 99-summary
##do
## log ">>> Running ${STEP}.sh"
## bash "./${STEP}.sh"
##done

View File

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Mailwolt Installer-Wrapper
# Deploy to: /usr/local/sbin/mailwolt-install
# Permissions: chmod 0755, chown root:root
set -euo pipefail
LOG="/var/log/mailwolt-install.log"
STATE_DIR="/var/lib/mailwolt/install"
INSTALLER_SCRIPT="/var/www/mailwolt/mailwolt-installer/install.sh"
APP_DIR="/var/www/mailwolt"
install -d -m 0755 "$STATE_DIR" /var/lib/mailwolt /var/lib/mailwolt/wizard
chown www-data:www-data /var/lib/mailwolt/wizard
: > "$LOG"
chmod 0644 "$LOG"
echo "running" > "$STATE_DIR/state"
: > "$STATE_DIR/rc"
{
echo "===== $(date -Is) :: Installation gestartet ====="
if [[ "$(id -u)" -ne 0 ]]; then
echo "[!] Muss als root laufen"
printf '1\n' > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
exit 1
fi
# Komponente aus $1, falls übergeben (z.B. "nginx", "postfix", "dovecot", "all")
COMPONENT="${1:-all}"
echo "[i] Komponente: $COMPONENT"
RC=0
if [[ -f "$INSTALLER_SCRIPT" ]]; then
APP_DIR="$APP_DIR" COMPONENT="$COMPONENT" bash "$INSTALLER_SCRIPT" || RC=$?
else
echo "[!] installer script nicht gefunden: $INSTALLER_SCRIPT"
RC=127
fi
echo "===== $(date -Is) :: Installation beendet (rc=$RC) ====="
printf '%s\n' "$RC" > "$STATE_DIR/rc"
echo "done" > "$STATE_DIR/state"
exit "$RC"
} 2>&1 | tee -a "$LOG"

View File

@ -1,189 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -f /etc/mailwolt/installer.env ]; then
set -a
. /etc/mailwolt/installer.env
set +a
fi
# ── Styling ────────────────────────────────────────────────────────────────
GREEN="$(printf '\033[1;32m')"; YELLOW="$(printf '\033[1;33m')"
RED="$(printf '\033[1;31m')"; CYAN="$(printf '\033[1;36m')"
GREY="$(printf '\033[0;90m')"; NC="$(printf '\033[0m')"
BAR="──────────────────────────────────────────────────────────────────────────────"
log(){ echo -e "${GREEN}[+]${NC} $*"; }
warn(){ echo -e "${YELLOW}[!]${NC} $*"; }
err(){ echo -e "${RED}[x]${NC} $*"; }
die(){ err "$*"; exit 1; }
require_root(){ [[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausführen."; }
# --- Defaults, nur wenn noch nicht gesetzt ---------------------------------
: "${APP_USER:=mailwolt}"
: "${APP_GROUP:=www-data}"
: "${APP_DIR:=/var/www/${APP_USER}}"
: "${APP_NAME:=MailWolt}"
: "${BASE_DOMAIN:=example.com}"
: "${UI_SUB:=ui}"
: "${WEBMAIL_SUB:=webmail}"
: "${MTA_SUB:=mx}"
# DB / Redis (werden später durch .env überschrieben)
: "${DB_NAME:=${APP_USER}}"
: "${DB_USER:=${APP_USER}}"
: "${DB_PASS:=}"
: "${REDIS_PASS:=}"
# Stabile Zert-Pfade (UI/WEBMAIL/MX → symlinked via 20-ssl.sh)
: "${MAIL_SSL_DIR:=/etc/ssl/mail}"
: "${UI_SSL_DIR:=/etc/ssl/ui}"
: "${WEBMAIL_SSL_DIR:=/etc/ssl/webmail}"
: "${UI_CERT:=${UI_SSL_DIR}/fullchain.pem}"
: "${UI_KEY:=${UI_SSL_DIR}/privkey.pem}"
# Optional: E-Mail für LE
: "${LE_EMAIL:=admin@${BASE_DOMAIN}}"
load_env_file(){
local f="$1"
[[ -f "$f" ]] || return 0
while IFS='=' read -r k v; do
[[ "$k" =~ ^[A-Z0-9_]+$ ]] || continue
export "$k=$v"
done < <(grep -E '^[A-Z0-9_]+=' "$f")
}
header(){ echo -e "${CYAN}${BAR}${NC}
${CYAN} :::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
${CYAN} +:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
${CYAN} +:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
${CYAN} +#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
${CYAN} +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
${CYAN} #+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
${CYAN} ### ### ### ### ########### ########## ### ### ######## ########## ###
${CYAN} ${CYAN}${BAR}${NC}\n"; }
#header(){ echo -e "${CYAN}${BAR}${NC}
#${CYAN} 888b d888 d8b 888 888 888 888 888
#${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888
#${CYAN} 88888b.d88888 888 888 d8b 888 888 888
#${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888
#${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888
#${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888
#${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b.
#${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888
#${CYAN}${BAR}${NC}\n"; }
detect_ip(){
local ip
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || die "Konnte Server-IP nicht ermitteln."
echo "$ip"
}
detect_ipv4() {
local ext=""
if command -v curl >/dev/null 2>&1; then
ext="$(curl -fsS --max-time 2 https://ifconfig.me 2>/dev/null || true)"
[[ "$ext" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || ext=""
fi
echo "$ext"
}
detect_ipv6(){
local ip6
ip6="$(ip -6 addr show scope global 2>/dev/null | awk '/inet6/{print $2}' | cut -d/ -f1 | head -n1)" || true
[[ -n "${ip6:-}" ]] || ip6="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /:/){print $i; exit}}')" || true
echo "${ip6:-}"
}
detect_timezone(){
local tz
if command -v timedatectl >/dev/null 2>&1; then
tz="$(timedatectl show -p Timezone --value 2>/dev/null | tr -d '[:space:]')" || true
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
fi
[[ -r /etc/timezone ]] && { tz="$(sed -n '1p' /etc/timezone | tr -d '[:space:]')" || true; [[ "$tz" == */* ]] && { echo "$tz"; return; }; }
if [[ -L /etc/localtime ]]; then
tz="$(readlink -f /etc/localtime 2>/dev/null || true)"; tz="${tz#/usr/share/zoneinfo/}"
[[ "$tz" == */* ]] && { echo "$tz"; return; }
fi
if command -v curl >/dev/null 2>&1; then
tz="$(curl -fsSL --max-time 3 https://ipapi.co/timezone 2>/dev/null || true)"; [[ "$tz" == */* ]] && { echo "$tz"; return; }
fi
echo "UTC"
}
guess_locale_from_tz(){ case "${1:-UTC}" in
Europe/Berlin|Europe/Vienna|Europe/Zurich|Europe/Luxembourg|Europe/Brussels|Europe/Amsterdam) echo "de";;
*) echo "en";; esac; }
resolve_ok(){ local host="$1"; getent ahosts "$host" | awk '{print $1}' | sort -u | grep -q -F "${SERVER_PUBLIC_IPV4:-}" ; }
join_host(){ local sub="$1" base="$2"; [[ -z "$sub" ]] && echo "$base" || echo "$sub.$base"; }
# dns_preflight HOST [HOST2 ...]
# Prüft: A-Record → SERVER_PUBLIC_IPV4, MX (nur wenn HOST == MAIL_HOSTNAME), PTR.
# Gibt strukturierte Zeilen aus: OK|WARN|FAIL <host> <check> <detail>
# Rückgabe 0 = alles OK; 1 = mind. ein FAIL.
dns_preflight(){
local overall=0
local server_ip="${SERVER_PUBLIC_IPV4:-}"
_dns_line(){ local level="$1" host="$2" check="$3" detail="$4"
case "$level" in
OK) echo -e "${GREEN}[DNS OK ]${NC} ${host} ${GREY}${check}${NC}${detail}" ;;
WARN) echo -e "${YELLOW}[DNS WARN]${NC} ${host} ${GREY}${check}${NC}${detail}" ;;
FAIL) echo -e "${RED}[DNS FAIL]${NC} ${host} ${GREY}${check}${NC}${detail}"; overall=1 ;;
esac
}
for host in "$@"; do
[[ -z "$host" || "$host" == "example.com" ]] && continue
# A-Record
local a_ip
a_ip="$(dig +short A "$host" @1.1.1.1 2>/dev/null | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -n1)"
if [[ -z "$a_ip" ]]; then
_dns_line FAIL "$host" "A-Record" "kein Eintrag gefunden"
elif [[ -n "$server_ip" && "$a_ip" != "$server_ip" ]]; then
_dns_line FAIL "$host" "A-Record" "${a_ip}${server_ip} (Server-IP)"
else
_dns_line OK "$host" "A-Record" "${a_ip}"
fi
# MX (nur für MAIL_HOSTNAME)
if [[ "$host" == "${MAIL_HOSTNAME:-}" ]]; then
local mx
mx="$(dig +short MX "$host" @1.1.1.1 2>/dev/null | awk '{print $2}' | head -n1)"
if [[ -z "$mx" ]]; then
_dns_line WARN "$host" "MX-Record" "kein Eintrag ausgehende Mail ggf. eingeschränkt"
else
_dns_line OK "$host" "MX-Record" "${mx}"
fi
fi
# PTR (nur wenn IP bekannt)
if [[ -n "$server_ip" && -n "$a_ip" && "$a_ip" == "$server_ip" ]]; then
local ptr
ptr="$(dig +short -x "$server_ip" @1.1.1.1 2>/dev/null | head -n1 | sed 's/\.$//')"
if [[ -z "$ptr" ]]; then
_dns_line WARN "$host" "PTR-Record" "kein Reverse-DNS kann Spam-Score erhöhen"
elif [[ "$ptr" != "$host" && "$ptr" != "${MAIL_HOSTNAME:-}" ]]; then
_dns_line WARN "$host" "PTR-Record" "${ptr} (zeigt nicht auf ${host})"
else
_dns_line OK "$host" "PTR-Record" "${ptr}"
fi
fi
done
return $overall
}
upsert_env(){ # upsert in $ENV_FILE
local k="$1" v="$2" ek ev
ek="$(printf '%s' "$k" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"
ev="$(printf '%s' "$v" | sed -e 's/[&/]/\\&/g')"
if grep -qE "^[#[:space:]]*${ek}=" "$ENV_FILE" 2>/dev/null; then
sed -Ei "s|^[#[:space:]]*${ek}=.*|${k}=${ev}|g" "$ENV_FILE"
else
printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE"
fi
}

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