docker-mailserver LXC für Proxmox: Stack + Admin-UI + Webmail + Hardening
- dms-lxc.sh: Proxmox-Host-Installer (unprivilegierter LXC, Debian 13, Docker), curl-Self-Download, Multi-Domain-DKIM, SnappyMail-Provisionierung, PVE-Firewall - Stack: docker-mailserver, Node-Admin-API (Supabase-Auth), React-Admin-UI (OPENBUREAU-Look), SnappyMail (Shibui-Theme), Rspamd-Web-UI, docker-socket-proxy - Admin: Postfächer/Aliase/Catch-all/Quota, editierbare Domains+Settings, Server (Quota/Queue über abgesicherte Bridge), Status & DNS - Hardening: no-new-privileges, Whitelisted exec-Bridge, Rspamd-Passwort, .env chmod 600, PVE-CT-Firewall, generisch/teilbar (keine festen Domains) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
# Secrets & Laufzeitdaten
|
||||
stack/.env
|
||||
stack/docker-data/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
|
||||
# OS / Editor
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# lokale Test-Compose (privat) – Beispiel: docker-compose.local.example.yml
|
||||
docker-compose.local.yml
|
||||
@@ -0,0 +1,345 @@
|
||||
# docker-mailserver auf Proxmox (LXC) — mit React-Admin UI & SnappyMail
|
||||
|
||||
Ein vollständiges, selbst gehostetes Mail-Setup für ein kleines Büro (4–5 Personen):
|
||||
|
||||
| Komponente | Aufgabe |
|
||||
|---|---|
|
||||
| **docker-mailserver (DMS)** | Postfix + Dovecot + Rspamd (SMTP/IMAP, Spam, DKIM) |
|
||||
| **Admin-API** (Node.js) | verwaltet Postfächer/Aliase/Quotas über die DMS-Config-Dateien — Auth via Supabase |
|
||||
| **Admin-UI** (React-Admin) | Weboberfläche im Stil von OPENBUREAU |
|
||||
| **SnappyMail** | schlankes Webmail für die Mitarbeiter |
|
||||
| **Nginx Proxy Manager** *(bereits vorhanden)* | HTTPS/Let's-Encrypt für Admin-UI & Webmail |
|
||||
|
||||
Das Ganze läuft in **einem unprivilegierten LXC-Container** (Debian 13) auf Proxmox, in dem Docker mit `nesting=1` betrieben wird.
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
Port 25/465/587/ │ Port 80/443
|
||||
143/993 ──────► ▼ ──────► Nginx Proxy Manager (HTTPS)
|
||||
direkt zum DMS │
|
||||
(Mail-TLS im DMS) ├─► admin.example.com → Admin-UI (:8080)
|
||||
└─► mail.example.com → SnappyMail (:8888)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Voraussetzungen
|
||||
|
||||
- **Proxmox VE** (8.x), du arbeitest als `root` auf dem Host.
|
||||
- Eine **Domain** (`example.com`) mit Zugriff auf die DNS-Einstellungen.
|
||||
- **Port 25 ausgehend/eingehend** beim Hoster/ISP freigegeben (viele Privatanschlüsse blockieren 25!) und eine **statische öffentliche IP** mit der Möglichkeit, einen **PTR/rDNS** zu setzen.
|
||||
- Eine **Supabase-Instanz** (dieselbe wie bei OPENBUREAU) für das Admin-Login.
|
||||
- Der bereits vorhandene **Nginx Proxy Manager**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Installation
|
||||
|
||||
**Variante A — Einzeiler** (auf dem Proxmox-Host als root). Das Skript lädt den
|
||||
`stack/` bei Bedarf selbst herunter:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.kgva.ch/karim/DOCKERMAILSERVER-LXC/raw/branch/main/dms-lxc.sh)
|
||||
# privates Repo: GIT_TOKEN=<token> bash <(curl -fsSL .../dms-lxc.sh)
|
||||
```
|
||||
|
||||
**Variante B — Ordner kopieren** (inkl. `stack/`) und ausführen:
|
||||
|
||||
```bash
|
||||
cd /root/dms && bash dms-lxc.sh
|
||||
```
|
||||
|
||||
Das Skript fragt **alles interaktiv ab** (Domains, Webmail-/Admin-Domain, Brand,
|
||||
Supabase, NPM-IP …) — oder per ENV vorbelegen:
|
||||
|
||||
```bash
|
||||
CTID=110 \
|
||||
MAIL_FQDN=mail.example.com MAIL_DOMAIN=example.com MAIL_DOMAINS="example.com weitere.tld" \
|
||||
BRAND="Mein Büro" WEBMAIL_FQDN=mail.example.com ADMIN_FQDN=admin.example.com \
|
||||
NET_IP=192.168.1.50/24 NET_GW=192.168.1.1 \
|
||||
NPM_IP=192.168.1.10 \
|
||||
ADMIN_ALLOWED_EMAILS="admin@example.com" \
|
||||
SUPABASE_URL=https://xxxx.supabase.co SUPABASE_ANON_KEY=eyJ... \
|
||||
bash dms-lxc.sh
|
||||
```
|
||||
|
||||
> **Generisch & teilbar:** Es sind **keine festen Domains** im Stack — alles kommt aus dem
|
||||
> Dialog und ist später in der Admin-UI editierbar. Du kannst das Skript also weitergeben.
|
||||
|
||||
Es macht automatisch:
|
||||
1. unprivilegierten LXC (Debian 13, `nesting=1,keyctl=1`) anlegen + **PVE-CT-Firewall**,
|
||||
2. Docker installieren,
|
||||
3. **Self-signed-Cert + erstes Postfach vor-seeden**, dann den Stack bauen & starten,
|
||||
4. **DKIM je Domain** erzeugen, SnappyMail provisionieren (Domain→mailserver, Shibui-Theme),
|
||||
5. am Ende **DNS-Records (alle Domains, inkl. DKIM)** + **Rspamd-Passwort** + Hardening-Checkliste ausgeben.
|
||||
|
||||
> **Wichtige Variablen** (oben im Skript / per ENV): `CORES`, `RAM_MB` (4096), `DISK_GB` (20),
|
||||
> `STORAGE`, `BRIDGE`, `ADMIN_PORT`/`WEBMAIL_PORT`/`RSPAMD_PORT`, `NPM_IP`, `HARDEN_FIREWALL`, `ENABLE_CLAMAV`.
|
||||
|
||||
Verwaltung später:
|
||||
|
||||
```bash
|
||||
pct enter 110
|
||||
cd /opt/dms-stack
|
||||
docker compose ps
|
||||
docker compose logs -f mailserver
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DNS einrichten
|
||||
|
||||
**Ein Mailserver, mehrere Domains:** Es gibt **einen** Mailhost (`mail.kgva.ch`), der die Postfächer
|
||||
**aller** Domains bedient (kgva.ch, gabrielevarano.ch, karimgabrielevarano.xyz, openbureau.ch …).
|
||||
Alle Clients/Webmail verbinden sich immer zu `mail.kgva.ch` — egal welche Adress-Domain.
|
||||
|
||||
**Einmalig für den Mailhost** (`<IP>` = öffentliche IP):
|
||||
|
||||
| Typ | Name | Wert |
|
||||
|---|---|---|
|
||||
| A | `mail.kgva.ch.` | `<IP>` |
|
||||
| **PTR/rDNS** | `<IP>` | `mail.kgva.ch` — **beim Hoster/ISP setzen!** |
|
||||
|
||||
**Pro Mail-Domain** (Beispiel `kgva.ch` — analog für jede weitere Domain):
|
||||
|
||||
| Typ | Name | Wert |
|
||||
|---|---|---|
|
||||
| MX | `kgva.ch.` | `10 mail.kgva.ch.` |
|
||||
| TXT (SPF) | `kgva.ch.` | `v=spf1 mx ~all` |
|
||||
| TXT (DMARC) | `_dmarc.kgva.ch.` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@kgva.ch` |
|
||||
| TXT (DKIM) | je Domain eigener Key — siehe Skript-Ausgabe / Admin-UI → **Status & DNS** | (langer Public-Key) |
|
||||
|
||||
> Jede Domain hat einen **eigenen DKIM-Schlüssel**. Die fertigen Records (alle Domains) findest du
|
||||
> nach dem Setup in der Admin-UI unter **Status & DNS** zum Kopieren — oder per Skript-Ausgabe.
|
||||
> Weitere Domain später hinzufügen: `MAIL_DOMAINS` in `.env` ergänzen, Konto anlegen,
|
||||
> `docker exec mailserver setup config dkim domain neue-domain.tld`, DNS setzen.
|
||||
|
||||
Der **PTR-Record** ist entscheidend dafür, dass deine Mails nicht als Spam landen.
|
||||
|
||||
DKIM später erneut anzeigen:
|
||||
```bash
|
||||
pct enter 110
|
||||
cat /opt/dms-stack/docker-data/dms/config/rspamd/dkim/*.dns.txt
|
||||
```
|
||||
|
||||
Prüfen lassen kannst du alles bequem über **[mail-tester.com](https://www.mail-tester.com)** und **[dkimvalidator.com](https://dkimvalidator.com)**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Port-Weiterleitung / Firewall
|
||||
|
||||
Diese Ports müssen von außen zum Container (`<IP>`) gelangen:
|
||||
|
||||
| Port | Zweck |
|
||||
|---|---|
|
||||
| 25 | SMTP (eingehende Mails von anderen Servern) |
|
||||
| 587 / 465 | Mail-Versand der Mitarbeiter (Submission) |
|
||||
| 143 / 993 | IMAP (Mailabruf) |
|
||||
|
||||
Falls der Container hinter NAT liegt, am Router entsprechend weiterleiten.
|
||||
Die Web-Ports **80/443** gehen an den **Nginx Proxy Manager**, nicht direkt an den Container.
|
||||
|
||||
---
|
||||
|
||||
## 5. Supabase-Login einrichten (Admin-UI)
|
||||
|
||||
Die Admin-UI nutzt Supabase-Auth (wie OPENBUREAU):
|
||||
|
||||
1. In deinem Supabase-Projekt unter **Authentication → Users** einen Benutzer mit deiner E-Mail anlegen (oder Einladung).
|
||||
2. Diese E-Mail muss in `ADMIN_ALLOWED_EMAILS` stehen (in `/opt/dms-stack/.env`).
|
||||
3. `SUPABASE_URL` und `SUPABASE_ANON_KEY` in `.env` müssen gesetzt sein.
|
||||
|
||||
Änderungen an der `.env` aktiv machen:
|
||||
```bash
|
||||
cd /opt/dms-stack
|
||||
docker compose up -d # liest .env neu (UI/-API neu starten)
|
||||
docker compose restart admin-ui admin-api
|
||||
```
|
||||
|
||||
> Nur E-Mails aus `ADMIN_ALLOWED_EMAILS` erhalten Zugriff — alle anderen Supabase-User werden von der API mit 403 abgewiesen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Nginx Proxy Manager (HTTPS für Web)
|
||||
|
||||
Im NPM zwei **Proxy Hosts** anlegen:
|
||||
|
||||
| Domain | Forward Hostname/IP | Port | SSL |
|
||||
|---|---|---|---|
|
||||
| `admin.example.com` | `<Container-IP>` | `8080` | Let's Encrypt anfordern, „Force SSL" |
|
||||
| `mail.example.com` | `<Container-IP>` | `8888` | Let's Encrypt anfordern, „Force SSL" |
|
||||
|
||||
Für SnappyMail unter **Advanced** ggf. „Websockets Support" aktivieren.
|
||||
|
||||
---
|
||||
|
||||
## 7. TLS für den Mailserver (Port 465/587/993)
|
||||
|
||||
Mail-Protokolle laufen **nicht** über NPM, der Mailserver braucht also ein eigenes Zertifikat.
|
||||
DMS ist auf **`SSL_TYPE=manual`** mit festem Pfad konfiguriert:
|
||||
|
||||
```
|
||||
/opt/dms-stack/docker-data/certs/cert.pem (Zertifikat / fullchain)
|
||||
/opt/dms-stack/docker-data/certs/key.pem (privater Schlüssel)
|
||||
```
|
||||
|
||||
Das Setup-Skript legt dort beim ersten Start automatisch ein **self-signed** Zertifikat ab,
|
||||
damit STARTTLS sofort funktioniert (Clients warnen wegen Selbstsignierung — für interne
|
||||
Tests okay). **Für ein echtes Let's-Encrypt-Zertifikat einfach diese zwei Dateien ersetzen:**
|
||||
|
||||
1. In NPM unter **SSL Certificates** ein Zertifikat für `mail.kgva.ch` via **DNS-Challenge**
|
||||
anfordern (funktioniert auch ohne offenen Port 80).
|
||||
2. Dessen `fullchain.pem` → `docker-data/certs/cert.pem` und `privkey.pem` → `docker-data/certs/key.pem` kopieren.
|
||||
3. `cd /opt/dms-stack && docker compose restart mailserver` — **keine Config-Änderung nötig**, der Pfad bleibt gleich.
|
||||
|
||||
> Bei Erneuerung (alle ~90 Tage) die beiden Dateien neu kopieren und `docker compose restart mailserver`.
|
||||
> Das lässt sich per Cron/Hook automatisieren. Alternativ `acme.sh`/certbot direkt im LXC (DNS-Plugin)
|
||||
> und die Cert-Dateien an dieselben Pfade schreiben.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin-UI benutzen
|
||||
|
||||
`https://admin.example.com` öffnen → mit Supabase-E-Mail/Passwort einloggen.
|
||||
|
||||
- **Übersicht (Dashboard)**: Zähler (Postfächer/Aliase/Domains), Domains, Schnell-Links.
|
||||
- **Postfächer**: Konten anlegen/löschen, Passwort ändern, **Quota** setzen (z.B. `5G`, leer = unbegrenzt). Validierung von E-Mail/Quota/Passwortlänge.
|
||||
- **Aliase**: Weiterleitungen, z.B. `info@example.com` → `chef@example.com` (mehrere Ziele mit Komma).
|
||||
**Catch-all**: als Quelle `@example.com` eintragen → fängt alle unbekannten Adressen der Domain.
|
||||
- **Einstellungen**: **Domains hinzufügen/entfernen** (Hinzufügen erzeugt automatisch den DKIM-Key),
|
||||
Brand, Webmail-/Admin-Domain und Mailserver-FQDN bearbeiten. (Erst-Befüllung aus dem Deploy-Dialog.)
|
||||
- **Server**: **Quota-Auslastung** (belegt/frei je Postfach), **Mail-Queue**, **aktive Sessions**.
|
||||
Läuft über eine abgesicherte Bridge (docker-socket-proxy, nur `exec`, Whitelist).
|
||||
- **Status & DNS**: pro Domain MX/SPF/DMARC + DKIM zum Kopieren, „DKIM erzeugen/erneuern"-Button.
|
||||
|
||||
Konten/Aliase werden direkt in die DMS-Config-Dateien geschrieben; DMS übernimmt Änderungen automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 9. SnappyMail (Webmail) einrichten
|
||||
|
||||
1. `https://mail.example.com` öffnen.
|
||||
2. Admin-Panel beim ersten Start: `https://mail.example.com/?admin` — das Admin-Passwort steht in
|
||||
`/opt/dms-stack/docker-data/snappymail/_data_/_default_/admin_password.txt`.
|
||||
3. Unter **Domains** die Domain `example.com` hinzufügen:
|
||||
- IMAP: `mailserver` Port `993` (SSL/TLS) — innerhalb des Docker-Netzes per Containername erreichbar,
|
||||
extern `mail.example.com` Port `993`.
|
||||
- SMTP: Port `587` (STARTTLS).
|
||||
4. Mitarbeiter loggen sich dann mit ihrer vollen E-Mail-Adresse + Passwort ein.
|
||||
|
||||
### KGVA-Theme („Shibui")
|
||||
|
||||
SnappyMail wird im selben Look wie das KGVA-Nextcloud-Theme ausgeliefert. Das Theme liegt unter
|
||||
`stack/snappymail-theme/Shibui/styles.css` und wird per Volume nach `/snappymail/themes/` gemountet —
|
||||
es erscheint in SnappyMail als **`Shibui@custom`**.
|
||||
|
||||
Aktivieren (eine der beiden Varianten):
|
||||
- **Für alle (empfohlen):** Admin-Panel (`https://mail.example.com/?admin`) → **Themes** → Standard-Theme
|
||||
auf `Shibui` setzen. Alternativ in `data/_data_/_default_/configs/application.ini` unter `[webmail]`
|
||||
`theme = "Shibui@custom"` eintragen und `docker compose restart snappymail`.
|
||||
- **Pro Nutzer:** Webmail → **Einstellungen → Allgemein → Theme → Shibui**.
|
||||
|
||||
> Anpassen: einfach `styles.css` editieren und `docker compose restart snappymail`. Die Farb-/Font-Variablen
|
||||
> stehen gesammelt im `:root`-Block oben (Petrol-Akzent `#7BA89B`, washi-Neutraltöne, Inter/Instrument Serif/JetBrains Mono).
|
||||
> Hinweis: Die Fonts werden aktuell von Google Fonts geladen; auf Wunsch hoste ich sie lokal (DSGVO).
|
||||
|
||||
---
|
||||
|
||||
## 10. Mail-Client-Einstellungen (für Outlook/Thunderbird/Apple Mail)
|
||||
|
||||
| Einstellung | Wert |
|
||||
|---|---|
|
||||
| Posteingang (IMAP) | `mail.example.com`, Port **993**, SSL/TLS |
|
||||
| Postausgang (SMTP) | `mail.example.com`, Port **587** (STARTTLS) oder **465** (SSL) |
|
||||
| Benutzername | volle E-Mail-Adresse |
|
||||
| Authentifizierung | normales Passwort |
|
||||
|
||||
---
|
||||
|
||||
## 11. Backup
|
||||
|
||||
Alle Daten liegen unter `/opt/dms-stack/docker-data/`:
|
||||
|
||||
```bash
|
||||
pct enter 110
|
||||
cd /opt/dms-stack
|
||||
docker compose down
|
||||
tar czf /root/dms-backup-$(date +%F).tar.gz docker-data .env mailserver.env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Zusätzlich empfiehlt sich ein **Proxmox-Backup (vzdump)** des ganzen LXC sowie für Postfächer ggf. eine Offsite-Kopie von `docker-data/dms/mail-data/`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Troubleshooting
|
||||
|
||||
```bash
|
||||
pct enter 110 && cd /opt/dms-stack
|
||||
|
||||
docker compose ps # Status aller Container
|
||||
docker compose logs -f mailserver # Mail-Logs
|
||||
docker exec mailserver setup email list # Postfächer auflisten (CLI)
|
||||
docker exec mailserver postqueue -p # Mail-Queue ansehen
|
||||
docker exec mailserver setup debug fetchmail # Debug
|
||||
```
|
||||
|
||||
**Häufige Stolperfallen:**
|
||||
- **Mails kommen nicht raus / landen im Spam:** Port 25 vom ISP geblockt, fehlender PTR, oder DKIM/SPF/DMARC nicht gesetzt → `mail-tester.com` nutzen.
|
||||
- **Docker startet im LXC nicht:** `nesting=1,keyctl=1` prüfen (`pct config 110`); bei Bedarf `pct set 110 --features nesting=1,keyctl=1` und Container neu starten.
|
||||
- **Admin-UI „Token ungültig":** E-Mail steht nicht in `ADMIN_ALLOWED_EMAILS`, oder `SUPABASE_URL/ANON_KEY` falsch.
|
||||
- **Admin-UI lädt, aber Login schlägt fehl:** `config.js` prüfen → `curl http://<ip>:8080/config.js` sollte deine Supabase-URL zeigen; sonst `docker compose up -d admin-ui`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Spam-Filter (Rspamd)
|
||||
|
||||
docker-mailserver bringt **Rspamd** mit (aktiviert: Bayes, SPF/DKIM/DMARC-Checks, Greylisting, RBLs) —
|
||||
ein sehr guter Spam-Filter ohne Extra-Infrastruktur. Die **Web-UI** (Statistik/Training) läuft auf Port
|
||||
`11334` und ist **passwortgeschützt** (Passwort wird beim Deploy generiert und ausgegeben; steht in
|
||||
`docker-data/dms/config/rspamd/override.d/worker-controller.inc`).
|
||||
|
||||
- Im NPM als Proxy-Host `rspamd.<domain>` → `<CT-IP>:11334` (HTTPS).
|
||||
- Passwort ändern: Datei editieren (`password` / `enable_password`) + `docker compose restart mailserver`.
|
||||
- Optional härter hashen: `docker exec mailserver rspamadm pw` → Hash statt Klartext eintragen.
|
||||
|
||||
> Für größere Setups/dedizierte Quarantäne kann man optional ein **Proxmox Mail Gateway** davorschalten — für ein kleines Büro ist Rspamd i.d.R. ausreichend.
|
||||
|
||||
---
|
||||
|
||||
## 14. Hardening
|
||||
|
||||
**Schon eingebaut:**
|
||||
- **Unprivilegierter LXC** + Nesting; Container mit `no-new-privileges` (admin-api, admin-ui, snappymail, socket-proxy).
|
||||
- **Mailserver-Bridge** nur über **docker-socket-proxy** (ausschließlich `exec`) + Kommando-Whitelist in der API — **kein** roher Docker-Socket an die App.
|
||||
- **Rspamd-UI** passwortgeschützt; **Fail2ban** aktiv (`cap_add: NET_ADMIN`); DMS kein Open-Relay (`PERMIT_DOCKER=none`, `SPOOF_PROTECTION=1`).
|
||||
- Admin nur für E-Mails aus `ADMIN_ALLOWED_EMAILS` (Supabase). **`.env` ist `chmod 600`**.
|
||||
- **Proxmox-CT-Firewall**: Mail-Ports offen, Web/Admin/Webmail/Rspamd nur von `NPM_IP`. Wirkt, sobald die **Datacenter/Node-Firewall aktiv** ist (`Datacenter → Firewall → Options → Enable: Yes`).
|
||||
|
||||
**Vor dem Echtbetrieb noch erledigen:**
|
||||
- [ ] **Echtes TLS** für Mail: NPM-Zertifikat (DNS-Challenge) → `docker-data/certs/cert.pem|key.pem`, `restart mailserver` (ersetzt self-signed).
|
||||
- [ ] Admin-UI/Webmail/Rspamd **nur über NPM (HTTPS)** erreichbar machen — rohe Ports nicht ins Internet; `NPM_IP` setzen.
|
||||
- [ ] **Supabase-Login** mit 2FA absichern; starke Postfach-Passwörter.
|
||||
- [ ] **Backups** (vzdump + `docker-data/`), siehe Abschnitt 11.
|
||||
- [ ] **PTR/Reverse-DNS** auf `mail.<domain>` setzen; **Port 25** beim ISP prüfen (sonst Smarthost/`RELAY_HOST`).
|
||||
- [ ] **DMARC** nach der Testphase auf `p=reject` verschärfen.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
.
|
||||
├── dms-lxc.sh # Proxmox-Host-Skript (LXC anlegen + Stack deployen)
|
||||
├── README.md
|
||||
└── stack/ # wird in den Container nach /opt/dms-stack kopiert
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── mailserver.env
|
||||
├── api/ # Node.js Admin-API (Supabase-Auth)
|
||||
│ ├── server.js
|
||||
│ └── lib/{store,auth}.js
|
||||
└── admin/ # React-Admin UI (Vite → nginx)
|
||||
└── src/{App,Layout,Status,authProvider,dataProvider}.jsx ...
|
||||
```
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# docker-mailserver (DMS) als LXC auf Proxmox VE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dieses Skript wird AUF DEM PROXMOX-HOST als root ausgeführt.
|
||||
# Es legt einen UNPRIVILEGIERTEN LXC-Container (Debian 13) mit Nesting an,
|
||||
# installiert Docker und deployt den kompletten Stack via docker compose:
|
||||
# docker-mailserver + Admin-API + React-Admin-UI + SnappyMail.
|
||||
#
|
||||
# Der Ordner stack/ muss neben diesem Skript liegen.
|
||||
#
|
||||
# Aufruf: bash dms-lxc.sh
|
||||
# oder: CTID=110 MAIL_FQDN=mail.example.com bash dms-lxc.sh
|
||||
# =============================================================================
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard-Einstellungen (per ENV oder interaktiv überschreibbar)
|
||||
# ---------------------------------------------------------------------------
|
||||
CTID="${CTID:-}" # leer => nächste freie ID
|
||||
HOSTNAME="${HOSTNAME:-mailserver}" # LXC-Hostname (Anzeigename)
|
||||
MAIL_FQDN="${MAIL_FQDN:-}" # z.B. mail.example.com (PFLICHT)
|
||||
MAIL_DOMAIN="${MAIL_DOMAIN:-}" # primäre Domain, z.B. kgva.ch (leer => aus FQDN abgeleitet)
|
||||
MAIL_DOMAINS="${MAIL_DOMAINS:-}" # ALLE Mail-Domains (Leerzeichen-getrennt); leer => nur MAIL_DOMAIN
|
||||
FIRST_EMAIL="${FIRST_EMAIL:-}" # erstes Postfach, z.B. admin@example.com
|
||||
FIRST_PASSWORD="${FIRST_PASSWORD:-}" # Passwort für das erste Postfach
|
||||
|
||||
# Admin-UI / Webmail / Supabase
|
||||
ADMIN_PORT="${ADMIN_PORT:-8080}" # Port der Admin-UI (NPM zeigt hierauf)
|
||||
WEBMAIL_PORT="${WEBMAIL_PORT:-8888}" # Port von SnappyMail (NPM zeigt hierauf)
|
||||
RSPAMD_PORT="${RSPAMD_PORT:-11334}" # Rspamd Web-UI (NPM zeigt hierauf)
|
||||
ADMIN_ALLOWED_EMAILS="${ADMIN_ALLOWED_EMAILS:-}" # Komma-getrennte Admin-E-Mails (Supabase-Login)
|
||||
SUPABASE_URL="${SUPABASE_URL:-}" # https://xxxx.supabase.co
|
||||
SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY:-}" # Supabase anon/public key
|
||||
|
||||
# Branding / Web-Domains (Erst-Befüllung, später in der Admin-UI editierbar)
|
||||
BRAND="${BRAND:-}" # Anzeigename im Dashboard
|
||||
WEBMAIL_FQDN="${WEBMAIL_FQDN:-}" # Webmail-Domain (NPM)
|
||||
ADMIN_FQDN="${ADMIN_FQDN:-}" # Admin-UI-Domain (NPM)
|
||||
|
||||
# Hardening
|
||||
NPM_IP="${NPM_IP:-}" # IP des Nginx Proxy Manager; schränkt Web-Ports per PVE-Firewall darauf ein (leer = offen, mit Warnung)
|
||||
HARDEN_FIREWALL="${HARDEN_FIREWALL:-1}" # 1 = Proxmox-CT-Firewall-Regeln schreiben
|
||||
RSPAMD_PASSWORD="${RSPAMD_PASSWORD:-}" # leer => wird generiert
|
||||
|
||||
# Container-Ressourcen (5 Container + Rspamd -> 4 GB empfohlen)
|
||||
CORES="${CORES:-2}"
|
||||
RAM_MB="${RAM_MB:-4096}" # 4 GB; mit ClamAV besser 6144+
|
||||
DISK_GB="${DISK_GB:-20}" # nach erwartetem Mailvolumen wählen
|
||||
SWAP_MB="${SWAP_MB:-512}"
|
||||
|
||||
# Storage / Template / Netz
|
||||
OS_TEMPLATE="${OS_TEMPLATE:-debian-13-standard}" # Debian 13 (Trixie); Fallback auf 12 automatisch
|
||||
STORAGE="${STORAGE:-local-lvm}" # Storage für rootfs
|
||||
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}" # Storage für CT-Template
|
||||
BRIDGE="${BRIDGE:-vmbr0}"
|
||||
NET_IP="${NET_IP:-dhcp}" # "dhcp" oder z.B. "192.168.1.50/24"
|
||||
NET_GW="${NET_GW:-}" # Gateway, nur bei statischer IP
|
||||
TIMEZONE="${TIMEZONE:-Europe/Zurich}"
|
||||
|
||||
# Funktionen / Optionen
|
||||
ENABLE_CLAMAV="${ENABLE_CLAMAV:-0}" # 1 = Virenscan (braucht ~1GB RAM extra)
|
||||
ENABLE_FAIL2BAN="${ENABLE_FAIL2BAN:-1}"
|
||||
DMS_IMAGE="${DMS_IMAGE:-ghcr.io/docker-mailserver/docker-mailserver:latest}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hübsche Ausgabe
|
||||
# ---------------------------------------------------------------------------
|
||||
RD=$'\033[01;31m'; GN=$'\033[1;92m'; YW=$'\033[33m'; BL=$'\033[1;34m'; CL=$'\033[m'
|
||||
msg_info() { echo -e " ${YW}•${CL} $1"; }
|
||||
msg_ok() { echo -e " ${GN}✔${CL} $1"; }
|
||||
msg_err() { echo -e " ${RD}✖${CL} $1" >&2; }
|
||||
die() { msg_err "$1"; exit 1; }
|
||||
trap 'msg_err "Fehler in Zeile $LINENO. Abbruch."' ERR
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-Flight Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root auf dem Proxmox-Host ausführen."
|
||||
command -v pct >/dev/null 2>&1 || die "'pct' nicht gefunden – läuft das Skript wirklich auf einem Proxmox-Host?"
|
||||
command -v pveam >/dev/null 2>&1 || die "'pveam' nicht gefunden – kein Proxmox VE?"
|
||||
command -v openssl >/dev/null 2>&1 || die "'openssl' nicht gefunden (für Passwort-Hash) – 'apt install openssl'."
|
||||
command -v curl >/dev/null 2>&1 || die "'curl' nicht gefunden – 'apt install curl'."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stack-Ordner: neben dem Skript ODER per curl-Einzeiler selbst herunterladen
|
||||
# curl-Einzeiler: bash <(curl -fsSL <RAW-URL>/dms-lxc.sh)
|
||||
# Privates Repo: GIT_TOKEN=<token> bash <(curl ... )
|
||||
# ---------------------------------------------------------------------------
|
||||
REPO_ARCHIVE="${REPO_ARCHIVE:-https://git.kgva.ch/karim/DOCKERMAILSERVER-LXC/archive/main.tar.gz}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo /tmp)"
|
||||
STACK_DIR="${STACK_DIR:-$SCRIPT_DIR/stack}"
|
||||
if [[ ! -f "$STACK_DIR/docker-compose.yml" ]]; then
|
||||
msg_info "stack/ nicht gefunden – lade Repo von $REPO_ARCHIVE ..."
|
||||
DL="$(mktemp -d)"
|
||||
AUTH=(); [[ -n "${GIT_TOKEN:-}" ]] && AUTH=(-H "Authorization: token ${GIT_TOKEN}")
|
||||
curl -fsSL "${AUTH[@]}" "$REPO_ARCHIVE" -o "$DL/repo.tar.gz" || die "Download fehlgeschlagen (privates Repo? GIT_TOKEN setzen)."
|
||||
tar -xzf "$DL/repo.tar.gz" -C "$DL" || die "Entpacken fehlgeschlagen."
|
||||
FOUND="$(find "$DL" -maxdepth 4 -type f -name docker-compose.yml -path '*/stack/*' 2>/dev/null | head -1)"
|
||||
[[ -n "$FOUND" ]] || die "stack/ im heruntergeladenen Archiv nicht gefunden."
|
||||
STACK_DIR="$(dirname "$FOUND")"
|
||||
msg_ok "Repo geladen, stack/ unter $STACK_DIR"
|
||||
fi
|
||||
[[ -f "$STACK_DIR/docker-compose.yml" ]] || die "stack/docker-compose.yml nicht gefunden unter $STACK_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interaktive Abfragen (nur wenn nicht per ENV gesetzt)
|
||||
# ---------------------------------------------------------------------------
|
||||
ask() { local p="$1" d="${2:-}" v; read -rp "$(echo -e "${BL}?${CL} $p ${d:+[$d] }")" v; echo "${v:-$d}"; }
|
||||
|
||||
if [[ -z "$CTID" ]]; then CTID="$(pvesh get /cluster/nextid)"; fi
|
||||
CTID="$(ask "Container-ID (CTID)" "$CTID")"
|
||||
|
||||
[[ -z "$MAIL_FQDN" ]] && MAIL_FQDN="$(ask "Mailserver-FQDN (z.B. mail.example.com)")"
|
||||
[[ -n "$MAIL_FQDN" ]] || die "MAIL_FQDN ist Pflicht."
|
||||
# Domain aus FQDN ableiten (alles nach dem ersten Punkt) falls nicht gesetzt
|
||||
[[ -z "$MAIL_DOMAIN" ]] && MAIL_DOMAIN="${MAIL_FQDN#*.}"
|
||||
MAIL_DOMAIN="$(ask "Primäre Mail-Domain" "$MAIL_DOMAIN")"
|
||||
[[ -z "$MAIL_DOMAINS" ]] && MAIL_DOMAINS="$(ask "Alle Mail-Domains (Leerzeichen-getrennt)" "$MAIL_DOMAIN")"
|
||||
# Primäre Domain sicher enthalten, Duplikate raus
|
||||
MAIL_DOMAINS="$(echo "$MAIL_DOMAIN $MAIL_DOMAINS" | tr ' ' '\n' | awk 'NF && !seen[$0]++' | tr '\n' ' ' | sed 's/ *$//')"
|
||||
|
||||
# Branding + Web-Domains (Defaults aus der primären Domain abgeleitet)
|
||||
[[ -z "$BRAND" ]] && BRAND="$(ask "Anzeigename / Brand (Dashboard)" "$MAIL_DOMAIN")"
|
||||
[[ -z "$WEBMAIL_FQDN" ]] && WEBMAIL_FQDN="$(ask "Webmail-Domain (NPM-Proxy-Host)" "mail.${MAIL_DOMAIN}")"
|
||||
[[ -z "$ADMIN_FQDN" ]] && ADMIN_FQDN="$(ask "Admin-UI-Domain (NPM-Proxy-Host)" "admin.${MAIL_DOMAIN}")"
|
||||
|
||||
[[ -z "$FIRST_EMAIL" ]] && FIRST_EMAIL="$(ask "Erstes Postfach (E-Mail)" "admin@${MAIL_DOMAIN}")"
|
||||
if [[ -z "$FIRST_PASSWORD" ]]; then
|
||||
read -rsp "$(echo -e "${BL}?${CL} Passwort für ${FIRST_EMAIL}: ")" FIRST_PASSWORD; echo
|
||||
[[ -n "$FIRST_PASSWORD" ]] || die "Passwort darf nicht leer sein."
|
||||
fi
|
||||
|
||||
NET_IP="$(ask "IP (dhcp oder CIDR z.B. 192.168.1.50/24)" "$NET_IP")"
|
||||
if [[ "$NET_IP" != "dhcp" && -z "$NET_GW" ]]; then
|
||||
NET_GW="$(ask "Gateway (für statische IP)")"
|
||||
fi
|
||||
|
||||
# --- Admin-UI / Supabase ---
|
||||
[[ -z "$ADMIN_ALLOWED_EMAILS" ]] && ADMIN_ALLOWED_EMAILS="$(ask "Admin-E-Mail(s) für UI-Login (Supabase, Komma-getrennt)" "$FIRST_EMAIL")"
|
||||
[[ -z "$SUPABASE_URL" ]] && SUPABASE_URL="$(ask "Supabase URL (leer = später in .env eintragen)")"
|
||||
[[ -z "$SUPABASE_ANON_KEY" ]] && SUPABASE_ANON_KEY="$(ask "Supabase anon key (leer = später in .env eintragen)")"
|
||||
|
||||
# --- Hardening ---
|
||||
[[ -z "$NPM_IP" ]] && NPM_IP="$(ask "IP des Nginx Proxy Manager (Web-Ports nur von dort; leer = offen)")"
|
||||
# Rspamd-Controller-Passwort generieren, falls nicht gesetzt
|
||||
[[ -z "$RSPAMD_PASSWORD" ]] && RSPAMD_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
${GN}── Zusammenfassung ─────────────────────────────${CL}
|
||||
CTID .............. $CTID
|
||||
Hostname .......... $HOSTNAME
|
||||
Mail-FQDN ......... $MAIL_FQDN
|
||||
Mail-Domains ...... $MAIL_DOMAINS
|
||||
Erstes Postfach ... $FIRST_EMAIL
|
||||
Ressourcen ........ ${CORES} vCPU / ${RAM_MB}MB RAM / ${DISK_GB}GB Disk
|
||||
Storage ........... $STORAGE (Template: $TEMPLATE_STORAGE)
|
||||
Netz .............. $BRIDGE / $NET_IP ${NET_GW:+gw $NET_GW}
|
||||
Admin-UI .......... Port $ADMIN_PORT (Login: $ADMIN_ALLOWED_EMAILS)
|
||||
Webmail ........... Port $WEBMAIL_PORT (SnappyMail)
|
||||
Rspamd-UI ......... Port $RSPAMD_PORT (Passwort wird generiert)
|
||||
Supabase .......... ${SUPABASE_URL:-<später in .env eintragen>}
|
||||
Firewall .......... $([[ "$HARDEN_FIREWALL" == 1 ]] && echo "PVE-CT-Firewall an${NPM_IP:+, Web nur von $NPM_IP}" || echo aus)
|
||||
ClamAV ............ $([[ "$ENABLE_CLAMAV" == 1 ]] && echo an || echo aus)
|
||||
Fail2ban .......... $([[ "$ENABLE_FAIL2BAN" == 1 ]] && echo an || echo aus)
|
||||
${GN}────────────────────────────────────────────────${CL}
|
||||
EOF
|
||||
read -rp "$(echo -e "${BL}?${CL} Fortfahren? [J/n] ")" go; [[ "${go:-J}" =~ ^[JjYy]?$ ]] || die "Abgebrochen."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CT-Template sicherstellen
|
||||
# ---------------------------------------------------------------------------
|
||||
msg_info "Suche $OS_TEMPLATE Template ..."
|
||||
pveam update >/dev/null 2>&1 || true
|
||||
TEMPLATE="$(pveam available --section system | awk -v t="$OS_TEMPLATE" '$2 ~ t {print $2}' | sort -V | tail -1)"
|
||||
if [[ -z "$TEMPLATE" ]]; then
|
||||
msg_err "$OS_TEMPLATE nicht verfügbar – fällt auf debian-12-standard zurück."
|
||||
TEMPLATE="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
|
||||
fi
|
||||
[[ -n "$TEMPLATE" ]] || die "Kein passendes Debian-Template in 'pveam available' gefunden."
|
||||
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then
|
||||
msg_info "Lade Template $TEMPLATE herunter ..."
|
||||
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
|
||||
fi
|
||||
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}"
|
||||
msg_ok "Template bereit: $TEMPLATE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Netzwerk-String bauen
|
||||
# ---------------------------------------------------------------------------
|
||||
FW_FLAG=""
|
||||
[[ "$HARDEN_FIREWALL" == 1 ]] && FW_FLAG=",firewall=1"
|
||||
if [[ "$NET_IP" == "dhcp" ]]; then
|
||||
NET="name=eth0,bridge=${BRIDGE},ip=dhcp${FW_FLAG}"
|
||||
else
|
||||
NET="name=eth0,bridge=${BRIDGE},ip=${NET_IP}${NET_GW:+,gw=${NET_GW}}${FW_FLAG}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LXC erstellen (unprivilegiert) + Nesting/keyctl für Docker
|
||||
# ---------------------------------------------------------------------------
|
||||
msg_info "Erstelle LXC $CTID ..."
|
||||
pct create "$CTID" "$TEMPLATE_REF" \
|
||||
--hostname "$HOSTNAME" \
|
||||
--cores "$CORES" \
|
||||
--memory "$RAM_MB" \
|
||||
--swap "$SWAP_MB" \
|
||||
--rootfs "${STORAGE}:${DISK_GB}" \
|
||||
--net0 "$NET" \
|
||||
--features nesting=1,keyctl=1 \
|
||||
--unprivileged 1 \
|
||||
--onboot 1 \
|
||||
--ostype debian \
|
||||
--timezone "$TIMEZONE" \
|
||||
--description "docker-mailserver (DMS) — ${MAIL_FQDN}"
|
||||
msg_ok "LXC $CTID erstellt."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxmox-CT-Firewall: Mail-Ports offen, Web/Rspamd nur von NPM (falls gesetzt)
|
||||
# (greift nur, wenn die Datacenter/Node-Firewall aktiviert ist — sonst inert)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$HARDEN_FIREWALL" == 1 ]]; then
|
||||
msg_info "Schreibe Proxmox-CT-Firewall-Regeln ..."
|
||||
WEBSRC=""; [[ -n "$NPM_IP" ]] && WEBSRC=" -source ${NPM_IP}"
|
||||
mkdir -p /etc/pve/firewall
|
||||
cat > "/etc/pve/firewall/${CTID}.fw" <<EOF
|
||||
[OPTIONS]
|
||||
enable: 1
|
||||
policy_in: DROP
|
||||
policy_out: ACCEPT
|
||||
|
||||
[RULES]
|
||||
# SSH/Console
|
||||
IN ACCEPT -p tcp -dport 22
|
||||
# Mail (von überall erreichbar)
|
||||
IN ACCEPT -p tcp -dport 25
|
||||
IN ACCEPT -p tcp -dport 465
|
||||
IN ACCEPT -p tcp -dport 587
|
||||
IN ACCEPT -p tcp -dport 143
|
||||
IN ACCEPT -p tcp -dport 993
|
||||
# Web/Admin/Webmail/Rspamd — nur vom Nginx Proxy Manager (falls NPM_IP gesetzt)
|
||||
IN ACCEPT -p tcp -dport ${ADMIN_PORT}${WEBSRC}
|
||||
IN ACCEPT -p tcp -dport ${WEBMAIL_PORT}${WEBSRC}
|
||||
IN ACCEPT -p tcp -dport ${RSPAMD_PORT}${WEBSRC}
|
||||
EOF
|
||||
msg_ok "Firewall-Regeln geschrieben (${NPM_IP:+Web nur von $NPM_IP}${NPM_IP:-Web offen – NPM_IP setzen für Einschränkung})."
|
||||
fi
|
||||
|
||||
msg_info "Starte Container ..."
|
||||
pct start "$CTID"
|
||||
# Auf Netzwerk warten
|
||||
for i in {1..30}; do
|
||||
pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
msg_ok "Container läuft."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basis-Setup + Docker im Container installieren
|
||||
# ---------------------------------------------------------------------------
|
||||
msg_info "Installiere Updates & Docker im Container ..."
|
||||
pct exec "$CTID" -- bash -c '
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq ca-certificates curl gnupg openssl jq >/dev/null
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null
|
||||
systemctl enable --now docker >/dev/null 2>&1
|
||||
'
|
||||
msg_ok "Docker installiert."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stack-Ordner vorbereiten (.env + mailserver.env patchen) und übertragen
|
||||
# ---------------------------------------------------------------------------
|
||||
DEPLOY_DIR="/opt/dms-stack"
|
||||
DMS_TAG="${DMS_IMAGE##*:}"
|
||||
msg_info "Bereite Stack vor ..."
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
cp -r "$STACK_DIR" "$TMP/stack"
|
||||
|
||||
# mailserver.env an Optionen anpassen
|
||||
sed -i "s|^TZ=.*|TZ=${TIMEZONE}|" "$TMP/stack/mailserver.env"
|
||||
sed -i "s|^ENABLE_CLAMAV=.*|ENABLE_CLAMAV=${ENABLE_CLAMAV}|" "$TMP/stack/mailserver.env"
|
||||
sed -i "s|^ENABLE_FAIL2BAN=.*|ENABLE_FAIL2BAN=${ENABLE_FAIL2BAN}|" "$TMP/stack/mailserver.env"
|
||||
|
||||
# .env für docker compose schreiben
|
||||
cat > "$TMP/stack/.env" <<EOF
|
||||
MAIL_FQDN=${MAIL_FQDN}
|
||||
MAIL_DOMAIN=${MAIL_DOMAIN}
|
||||
MAIL_DOMAINS=${MAIL_DOMAINS}
|
||||
BRAND=${BRAND}
|
||||
WEBMAIL_FQDN=${WEBMAIL_FQDN}
|
||||
ADMIN_FQDN=${ADMIN_FQDN}
|
||||
DMS_TAG=${DMS_TAG}
|
||||
ADMIN_PORT=${ADMIN_PORT}
|
||||
WEBMAIL_PORT=${WEBMAIL_PORT}
|
||||
RSPAMD_PORT=${RSPAMD_PORT}
|
||||
ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS}
|
||||
SUPABASE_URL=${SUPABASE_URL}
|
||||
SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
EOF
|
||||
|
||||
# Rspamd-Controller mit Passwort schützen (Web-UI)
|
||||
mkdir -p "$TMP/stack/docker-data/dms/config/rspamd/override.d"
|
||||
cat > "$TMP/stack/docker-data/dms/config/rspamd/override.d/worker-controller.inc" <<EOF
|
||||
bind_socket = "*:11334";
|
||||
password = "${RSPAMD_PASSWORD}";
|
||||
enable_password = "${RSPAMD_PASSWORD}";
|
||||
EOF
|
||||
|
||||
# Verzeichnis per tar in den Container übertragen (pct push kann nur Dateien)
|
||||
tar -C "$TMP/stack" -czf "$TMP/stack.tar.gz" .
|
||||
pct exec "$CTID" -- mkdir -p "$DEPLOY_DIR"
|
||||
pct push "$CTID" "$TMP/stack.tar.gz" /tmp/stack.tar.gz
|
||||
pct exec "$CTID" -- tar -xzf /tmp/stack.tar.gz -C "$DEPLOY_DIR"
|
||||
pct exec "$CTID" -- rm -f /tmp/stack.tar.gz
|
||||
# Hardening: .env (Supabase-Keys) nur für root lesbar
|
||||
pct exec "$CTID" -- chmod 600 "$DEPLOY_DIR/.env"
|
||||
msg_ok "Stack übertragen nach $DEPLOY_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS-Cert + erstes Postfach VOR dem Start vor-seeden
|
||||
# (DMS bricht sonst ab: ohne Cert -> TLS-Fehler, ohne Konto -> Shutdown nach 120s,
|
||||
# und Port 25 öffnet erst NACH dem ersten Konto -> sonst Deadlock beim Warten.)
|
||||
# ---------------------------------------------------------------------------
|
||||
msg_info "Erzeuge Self-signed-Zertifikat (Fallback bis NPM/Let's Encrypt) ..."
|
||||
pct exec "$CTID" -- bash -c "
|
||||
set -e
|
||||
mkdir -p $DEPLOY_DIR/docker-data/certs $DEPLOY_DIR/docker-data/dms/config
|
||||
if [ ! -f $DEPLOY_DIR/docker-data/certs/cert.pem ]; then
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-keyout $DEPLOY_DIR/docker-data/certs/key.pem \
|
||||
-out $DEPLOY_DIR/docker-data/certs/cert.pem \
|
||||
-subj '/CN=$MAIL_FQDN' -addext 'subjectAltName=DNS:$MAIL_FQDN' >/dev/null 2>&1
|
||||
fi
|
||||
"
|
||||
|
||||
msg_info "Lege erstes Postfach an: $FIRST_EMAIL"
|
||||
# Passwort-Hash auf dem Host erzeugen (Passwort geht nie in den Container).
|
||||
PW_HASH="$(printf '%s' "$FIRST_PASSWORD" | openssl passwd -6 -stdin)"
|
||||
pct exec "$CTID" -- bash -c "grep -q '^${FIRST_EMAIL}|' $DEPLOY_DIR/docker-data/dms/config/postfix-accounts.cf 2>/dev/null || \
|
||||
printf '%s\n' '${FIRST_EMAIL}|{SHA512-CRYPT}${PW_HASH}' >> $DEPLOY_DIR/docker-data/dms/config/postfix-accounts.cf"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stack bauen & starten + DKIM
|
||||
# ---------------------------------------------------------------------------
|
||||
msg_info "Baue & starte Stack (Image-Pull/Build kann einige Minuten dauern) ..."
|
||||
pct exec "$CTID" -- bash -c "cd $DEPLOY_DIR && docker compose up -d --build"
|
||||
|
||||
# Warten bis Mailserver auf Port 25 lauscht (kommt jetzt, da Konto+Cert vorhanden)
|
||||
msg_info "Warte auf Mailserver-Start ..."
|
||||
for i in {1..60}; do
|
||||
pct exec "$CTID" -- bash -c 'docker exec mailserver ss -lnt 2>/dev/null | grep -q ":25 "' && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
msg_info "Erzeuge DKIM-Schlüssel (RSA 2048) pro Domain ..."
|
||||
for d in $MAIL_DOMAINS; do
|
||||
pct exec "$CTID" -- bash -c "docker exec mailserver setup config dkim keysize 2048 domain '$d'" \
|
||||
|| msg_err "DKIM für $d fehlgeschlagen – später manuell nachholen."
|
||||
done
|
||||
|
||||
# alle DKIM-DNS-Records einsammeln (rspamd legt je Domain eine .dns.txt an)
|
||||
DKIM_TXT="$(pct exec "$CTID" -- bash -c "for f in $DEPLOY_DIR/docker-data/dms/config/rspamd/dkim/*.dns.txt; do [ -f \"\$f\" ] && echo \"; --- \$(basename \"\$f\" .dns.txt) ---\" && cat \"\$f\" && echo; done 2>/dev/null" || true)"
|
||||
|
||||
# --- SnappyMail provisionieren: Wildcard-Domain -> mailserver-Container, Shibui als Default ---
|
||||
msg_info "Provisioniere SnappyMail (alle Domains -> mailserver, Shibui-Theme) ..."
|
||||
SM_DEF="$DEPLOY_DIR/docker-data/snappymail/_data_/_default_"
|
||||
for i in {1..30}; do
|
||||
pct exec "$CTID" -- bash -c "[ -f '$SM_DEF/domains/default.json' ]" && break
|
||||
sleep 2
|
||||
done
|
||||
pct exec "$CTID" -- bash -c '
|
||||
set -e
|
||||
D="$1"; STACK="$2"
|
||||
[ -f "$D/domains/default.json" ] || exit 0
|
||||
jq ".IMAP.host=\"mailserver\" | .IMAP.port=143 | .IMAP.type=2 | .IMAP.ssl.allow_self_signed=true
|
||||
| .SMTP.host=\"mailserver\" | .SMTP.port=587 | .SMTP.type=2 | .SMTP.useAuth=true | .SMTP.ssl.allow_self_signed=true
|
||||
| .Sieve.host=\"mailserver\"" "$D/domains/default.json" > "$D/domains/default.json.tmp"
|
||||
mv "$D/domains/default.json.tmp" "$D/domains/default.json"
|
||||
sed -i "s/^theme = .*/theme = \"Shibui@custom\"/" "$D/configs/application.ini" 2>/dev/null || true
|
||||
cd "$STACK" && docker compose restart snappymail >/dev/null 2>&1
|
||||
' _ "$SM_DEF" "$DEPLOY_DIR" || msg_err "SnappyMail-Provisionierung übersprungen (kann manuell erfolgen)."
|
||||
|
||||
# Container-IP ermitteln
|
||||
CT_IP="$(pct exec "$CTID" -- bash -c "hostname -I | awk '{print \$1}'" 2>/dev/null || true)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abschluss / DNS-Hinweise
|
||||
# ---------------------------------------------------------------------------
|
||||
cat <<EOF
|
||||
|
||||
${GN}╔══════════════════════════════════════════════════════════════╗
|
||||
║ docker-mailserver ist eingerichtet 🎉 ║
|
||||
╚══════════════════════════════════════════════════════════════╝${CL}
|
||||
|
||||
Container .......... CTID $CTID (IP: ${CT_IP:-unbekannt})
|
||||
Mailserver ......... $MAIL_FQDN
|
||||
Erstes Postfach .... $FIRST_EMAIL
|
||||
Admin-UI ........... http://${CT_IP:-<ip>}:${ADMIN_PORT} (Supabase-Login)
|
||||
Webmail ............ http://${CT_IP:-<ip>}:${WEBMAIL_PORT} (SnappyMail)
|
||||
Rspamd-UI .......... http://${CT_IP:-<ip>}:${RSPAMD_PORT} (Passwort unten)
|
||||
Verwaltung ......... pct enter $CTID → cd ${DEPLOY_DIR}
|
||||
|
||||
${BL}Tipp:${CL} Im Nginx Proxy Manager als Proxy-Hosts anlegen (HTTPS):
|
||||
admin.${MAIL_DOMAIN} -> ${CT_IP:-<ip>}:${ADMIN_PORT}
|
||||
mail.${MAIL_DOMAIN} -> ${CT_IP:-<ip>}:${WEBMAIL_PORT}
|
||||
rspamd.${MAIL_DOMAIN} -> ${CT_IP:-<ip>}:${RSPAMD_PORT}
|
||||
|
||||
${RD}Rspamd-Web-UI-Passwort:${CL} ${RSPAMD_PASSWORD}
|
||||
(steht auch in ${DEPLOY_DIR}/docker-data/dms/config/rspamd/override.d/worker-controller.inc)
|
||||
|
||||
${YW}── DNS: einmal für den Mailhost ──${CL}
|
||||
|
||||
A-Record: ${MAIL_FQDN}. IN A <öffentliche IP>
|
||||
PTR/rDNS: <öffentliche IP> -> ${MAIL_FQDN} (beim Hoster/ISP setzen!)
|
||||
|
||||
${YW}── DNS: pro Mail-Domain (MX/SPF/DMARC) ──${CL}
|
||||
EOF
|
||||
for d in $MAIL_DOMAINS; do
|
||||
cat <<EOF
|
||||
|
||||
[${d}]
|
||||
MX ${d}. IN MX 10 ${MAIL_FQDN}.
|
||||
SPF ${d}. IN TXT "v=spf1 mx ~all"
|
||||
DMARC _dmarc.${d}. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@${d}"
|
||||
EOF
|
||||
done
|
||||
cat <<EOF
|
||||
|
||||
${YW}── DKIM (TXT) je Domain ──${CL}
|
||||
|
||||
EOF
|
||||
if [[ -n "$DKIM_TXT" ]]; then
|
||||
echo "$DKIM_TXT"
|
||||
else
|
||||
echo " (DKIM-Records noch nicht erzeugt – siehe README, Abschnitt DKIM.)"
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
${BL}Wichtig:${CL} Port 25 muss von deinem ISP/Hoster freigegeben sein und
|
||||
per Portweiterleitung (25,465,587,143,993) zum Container ${CT_IP:-…} zeigen.
|
||||
|
||||
${YW}── Hardening-Checkliste ──${CL}
|
||||
[ ] Echtes TLS: NPM-Zertifikat (DNS-Challenge) nach docker-data/certs/cert.pem|key.pem, dann
|
||||
'docker compose restart mailserver' (ersetzt das Self-signed).
|
||||
[ ] PVE-Firewall: $([[ "$HARDEN_FIREWALL" == 1 ]] && echo "CT-Regeln geschrieben${NPM_IP:+ (Web nur von $NPM_IP)}" || echo "AUS").
|
||||
Wirkt nur, wenn die Datacenter/Node-Firewall aktiv ist (Datacenter → Firewall → Options → Enable).
|
||||
[ ] Web-Ports (Admin/Webmail/Rspamd) nur über NPM/HTTPS erreichbar machen${NPM_IP:+ — NPM_IP gesetzt ✔}.
|
||||
[ ] Backups: vzdump des CT + ${DEPLOY_DIR}/docker-data (Mail!).
|
||||
[ ] DMARC nach Testphase auf p=reject verschärfen.
|
||||
|
||||
Details, TLS, Backup & Troubleshooting: siehe README.md
|
||||
EOF
|
||||
msg_ok "Fertig."
|
||||
@@ -0,0 +1,89 @@
|
||||
# ============================================================================
|
||||
# LOKALES TEST-COMPOSE (OrbStack/Colima/Docker Desktop auf dem Mac)
|
||||
# NICHT für Produktion — getrennt vom Deploy-Artefakt unter stack/.
|
||||
#
|
||||
# docker compose -f docker-compose.local.yml up -d --build # Admin-Stack
|
||||
# docker compose -f docker-compose.local.yml --profile mail up -d # + Mailserver
|
||||
# docker compose -f docker-compose.local.yml down -v
|
||||
#
|
||||
# Besonderheiten:
|
||||
# - admin-api mit AUTH_DISABLED=true (kein Supabase nötig)
|
||||
# - Mail-Ports auf hohe Ports gemappt (kein Konflikt/keine Root-Rechte)
|
||||
# ============================================================================
|
||||
name: dms-local
|
||||
|
||||
services:
|
||||
# docker-socket-proxy: gibt der API NUR exec frei (kein create/delete/volumes ...)
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:latest
|
||||
restart: always
|
||||
environment:
|
||||
- CONTAINERS=1
|
||||
- EXEC=1
|
||||
- POST=1
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
admin-api:
|
||||
build: ./stack/api
|
||||
environment:
|
||||
- AUTH_DISABLED=true
|
||||
- CONFIG_DIR=/config
|
||||
- MAIL_DOMAIN=example.com
|
||||
- MAIL_DOMAINS=example.com gabrielevarano.ch karimgabrielevarano.xyz openbureau.ch
|
||||
- MAIL_FQDN=mail.example.com
|
||||
- BRAND=Example
|
||||
- WEBMAIL_FQDN=mail.example.com
|
||||
- ADMIN_FQDN=admin.example.com
|
||||
- DOCKER_PROXY=socket-proxy:2375
|
||||
- MAILSERVER_CONTAINER=dms-local-mailserver-1
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
volumes:
|
||||
- ./stack/docker-data/dms/config/:/config/
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
admin-ui:
|
||||
build: ./stack/admin
|
||||
environment:
|
||||
- AUTH_DISABLED=true # nur lokal: UI ohne Supabase-Login ansehen
|
||||
- SUPABASE_URL=
|
||||
- SUPABASE_ANON_KEY=
|
||||
depends_on:
|
||||
- admin-api
|
||||
ports:
|
||||
- "8090:80"
|
||||
|
||||
snappymail:
|
||||
image: djmaze/snappymail:latest
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- ./stack/docker-data/snappymail/:/var/lib/snappymail/ # echter Datenpfad der djmaze-Image
|
||||
- ./stack/snappymail-theme/:/snappymail/themes/:ro # KGVA "Shibui"-Theme
|
||||
|
||||
# Nur mit --profile mail starten (großer Image-Pull, bindet Mail-Ports)
|
||||
mailserver:
|
||||
image: ghcr.io/docker-mailserver/docker-mailserver:latest
|
||||
hostname: mail.example.com
|
||||
env_file: ./stack/mailserver.env
|
||||
environment:
|
||||
- OVERRIDE_HOSTNAME=mail.example.com
|
||||
- POSTMASTER_ADDRESS=postmaster@example.com
|
||||
profiles: ["mail"]
|
||||
ports:
|
||||
- "2525:25"
|
||||
- "1143:143"
|
||||
- "4465:465"
|
||||
- "5587:587"
|
||||
- "9993:993"
|
||||
- "11334:11334" # Rspamd Web-UI (lokaler Test)
|
||||
volumes:
|
||||
- ./stack/docker-data/dms/mail-data/:/var/mail/
|
||||
- ./stack/docker-data/dms/mail-state/:/var/mail-state/
|
||||
- ./stack/docker-data/dms/mail-logs/:/var/log/mail/
|
||||
- ./stack/docker-data/dms/config/:/tmp/docker-mailserver/
|
||||
- ./stack/docker-data/certs/:/etc/letsencrypt/:ro
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -0,0 +1,31 @@
|
||||
# ============================================================================
|
||||
# Stack-Konfiguration (docker compose liest diese Datei automatisch)
|
||||
# Kopiere zu .env und passe die Werte an. -> cp .env.example .env
|
||||
# (Beim LXC-Deploy werden diese Werte über den Dialog gesetzt.)
|
||||
# ============================================================================
|
||||
|
||||
# --- Mail ---
|
||||
MAIL_FQDN=mail.example.com # FQDN des Mailservers (= Container-hostname, ein Host für ALLE Domains)
|
||||
MAIL_DOMAIN=example.com # primäre Domain (postmaster@, Defaults)
|
||||
# ALLE Mail-Domains (Leerzeichen-getrennt). Ein Mailserver bedient mehrere Domains.
|
||||
# Nur die Erst-Befüllung — danach in der Admin-UI unter „Einstellungen" editierbar:
|
||||
MAIL_DOMAINS=example.com
|
||||
DMS_TAG=latest # Image-Tag von docker-mailserver
|
||||
|
||||
# --- Branding / Web-Domains (Erst-Befüllung, in der Admin-UI editierbar) ---
|
||||
BRAND=example.com # Anzeigename im Admin-Dashboard
|
||||
WEBMAIL_FQDN=mail.example.com # Webmail-Domain (NPM-Proxy-Host)
|
||||
ADMIN_FQDN=admin.example.com # Admin-UI-Domain (NPM-Proxy-Host)
|
||||
|
||||
# --- Veröffentlichte Web-Ports (Nginx Proxy Manager zeigt hierauf) ---
|
||||
ADMIN_PORT=8080 # Admin-UI (React-Admin)
|
||||
WEBMAIL_PORT=8888 # SnappyMail Webmail
|
||||
RSPAMD_PORT=11334 # Rspamd Web-UI
|
||||
|
||||
# --- Admin-API ---
|
||||
# Liste der E-Mails, die sich im Admin anmelden dürfen (Komma-getrennt)
|
||||
ADMIN_ALLOWED_EMAILS=admin@example.com
|
||||
|
||||
# --- Supabase (Auth für die Admin-UI) ---
|
||||
SUPABASE_URL=https://YOUR-PROJECT.supabase.co
|
||||
SUPABASE_ANON_KEY=YOUR-ANON-KEY
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,16 @@
|
||||
# ---- Build-Stage: React-Admin mit Vite bauen ----
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Serve-Stage: nginx ----
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# Laufzeit-Config-Generator (nginx-Image führt /docker-entrypoint.d/*.sh aus)
|
||||
COPY docker-entrypoint.d/40-config.sh /docker-entrypoint.d/40-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-config.sh
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Erzeugt zur Laufzeit /config.js aus den Umgebungsvariablen, damit die
|
||||
# React-App Supabase-URL/Key ohne Neu-Build erhält (window.__CONFIG__).
|
||||
# Das nginx-Image führt Skripte in /docker-entrypoint.d/ vor dem Start aus.
|
||||
set -e
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__CONFIG__ = {
|
||||
SUPABASE_URL: "${SUPABASE_URL}",
|
||||
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY}",
|
||||
AUTH_DISABLED: "${AUTH_DISABLED:-false}"
|
||||
};
|
||||
EOF
|
||||
echo "[entrypoint] config.js erzeugt (SUPABASE_URL=${SUPABASE_URL}, AUTH_DISABLED=${AUTH_DISABLED:-false})"
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mailserver Admin</title>
|
||||
<!-- Fonts wie OPENBUREAU (bunny.net, datenschutzfreundlich) -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net" />
|
||||
<link href="https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500" rel="stylesheet" />
|
||||
<!-- Laufzeit-Konfiguration (von nginx aus ENV erzeugt; im Dev leer) -->
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API an den admin-api Container weiterreichen ( /api/accounts -> /accounts )
|
||||
location /api/ {
|
||||
proxy_pass http://admin-api:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
|
||||
# config.js nicht cachen (enthält Laufzeit-Konfiguration)
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# SPA-Fallback für react-admin Routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "dms-admin-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "React-Admin Oberfläche für docker-mailserver (Stil: OPENBUREAU).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"ra-data-simple-rest": "^5.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-admin": "^5.4.0",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Admin, Resource, CustomRoutes } from 'react-admin';
|
||||
import { Route } from 'react-router-dom';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import AltRouteIcon from '@mui/icons-material/AltRoute';
|
||||
|
||||
import { dataProvider } from './dataProvider';
|
||||
import { authProvider } from './authProvider';
|
||||
import { AccountList, AccountCreate, AccountEdit } from './resources/accounts';
|
||||
import { AliasList, AliasCreate, AliasEdit } from './resources/aliases';
|
||||
import { StatusPage } from './Status';
|
||||
import { ServerPage } from './Server';
|
||||
import { SettingsPage } from './Settings';
|
||||
import { Layout } from './Layout';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { openbureauTheme } from './theme';
|
||||
|
||||
const App = () => (
|
||||
<Admin
|
||||
title="Mail Admin"
|
||||
dataProvider={dataProvider}
|
||||
authProvider={authProvider}
|
||||
layout={Layout}
|
||||
dashboard={Dashboard}
|
||||
theme={openbureauTheme}
|
||||
requireAuth
|
||||
>
|
||||
<Resource
|
||||
name="accounts"
|
||||
options={{ label: 'Postfächer' }}
|
||||
icon={EmailIcon}
|
||||
list={AccountList}
|
||||
create={AccountCreate}
|
||||
edit={AccountEdit}
|
||||
/>
|
||||
<Resource
|
||||
name="aliases"
|
||||
options={{ label: 'Aliase' }}
|
||||
icon={AltRouteIcon}
|
||||
list={AliasList}
|
||||
create={AliasCreate}
|
||||
edit={AliasEdit}
|
||||
/>
|
||||
<CustomRoutes>
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/server" element={<ServerPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</CustomRoutes>
|
||||
</Admin>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useGetOne, useGetList, Link } from 'react-admin';
|
||||
import { Card, CardContent, Typography, Box, Stack, Chip, Button } from '@mui/material';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import AltRouteIcon from '@mui/icons-material/AltRoute';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
|
||||
const Stat = ({ icon, value, label, color }) => (
|
||||
<Card sx={{ flex: 1, minWidth: 160 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box sx={{ color: color || 'primary.main', display: 'flex' }}>{icon}</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ lineHeight: 1 }}>{value ?? '–'}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { data: status } = useGetOne('status', { id: 'status' });
|
||||
const { total: accountsTotal } = useGetList('accounts', { pagination: { page: 1, perPage: 1 } });
|
||||
const { total: aliasesTotal } = useGetList('aliases', { pagination: { page: 1, perPage: 1 } });
|
||||
const domains = status?.domains || [];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 } }}>
|
||||
<Typography variant="h3" sx={{ mb: 0.5 }}>{status?.brand || 'Mailserver'}</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{status?.fqdn ? `Host ${status.fqdn}` : 'Übersicht'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Stat icon={<EmailIcon />} value={accountsTotal ?? status?.accounts} label="Postfächer" />
|
||||
<Stat icon={<AltRouteIcon />} value={aliasesTotal ?? status?.aliases} label="Aliase" color="secondary.main" />
|
||||
<Stat icon={<LanguageIcon />} value={domains.length} label="Domains" color="success.main" />
|
||||
</Stack>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Domains</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
{domains.length
|
||||
? domains.map((d) => <Chip key={d} label={d} variant="outlined" />)
|
||||
: <Typography color="text.secondary">Keine Domains konfiguriert.</Typography>}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', gap: 1 }}>
|
||||
<Button component={Link} to="/accounts" variant="contained" startIcon={<EmailIcon />}>
|
||||
Postfächer verwalten
|
||||
</Button>
|
||||
<Button component={Link} to="/aliases" variant="outlined" startIcon={<AltRouteIcon />}>
|
||||
Aliase
|
||||
</Button>
|
||||
<Button component={Link} to="/status" variant="outlined" startIcon={<DnsIcon />}>
|
||||
Status & DNS
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Layout as RaLayout, Menu } from 'react-admin';
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
// Eigenes Menü: Standard-Ressourcen + Status-, Server- und Einstellungen-Seite.
|
||||
const AppMenu = () => (
|
||||
<Menu>
|
||||
<Menu.ResourceItems />
|
||||
<Menu.Item to="/status" primaryText="Status & DNS" leftIcon={<DnsIcon />} />
|
||||
<Menu.Item to="/server" primaryText="Server" leftIcon={<StorageIcon />} />
|
||||
<Menu.Item to="/settings" primaryText="Einstellungen" leftIcon={<SettingsIcon />} />
|
||||
</Menu>
|
||||
);
|
||||
|
||||
export const Layout = (props) => <RaLayout {...props} menu={AppMenu} />;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Title } from 'react-admin';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Stack, Chip, LinearProgress,
|
||||
Table, TableHead, TableRow, TableCell, TableBody, Button, Alert,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
const fmtBytes = (b) => {
|
||||
if (b == null) return '–';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0; let n = b;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
export const ServerPage = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiFetch('/mailserver/overview')
|
||||
.then((r) => { setData(r.json); setError(null); })
|
||||
.catch((e) => setError(e?.body?.error || e?.message || 'Bridge nicht erreichbar'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 } }}>
|
||||
<Title title="Server" />
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h3">Server</Typography>
|
||||
<Button onClick={load} startIcon={<RefreshIcon />} variant="outlined">Aktualisieren</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Mailserver-Bridge nicht erreichbar: {error}
|
||||
</Alert>
|
||||
)}
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 2 }}>
|
||||
<Card sx={{ flex: 1, minWidth: 200 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mail-Queue</Typography>
|
||||
{data.queue?.empty
|
||||
? <Chip color="success" label="Leer" />
|
||||
: <Chip color="warning" label={`${data.queue?.count ?? '?'} in Warteschlange`} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={{ flex: 1, minWidth: 200 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Aktive Sessions</Typography>
|
||||
<Typography variant="h4">{data.who?.sessions?.length ?? 0}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">IMAP/POP-Verbindungen gerade</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Quota-Auslastung</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Postfach</TableCell>
|
||||
<TableCell align="right">Belegt</TableCell>
|
||||
<TableCell align="right">Limit</TableCell>
|
||||
<TableCell sx={{ width: 180 }}>Auslastung</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(data.quota || []).map((q) => (
|
||||
<TableRow key={q.user}>
|
||||
<TableCell>{q.user}</TableCell>
|
||||
<TableCell align="right">{fmtBytes(q.usedBytes)}</TableCell>
|
||||
<TableCell align="right">{q.limitBytes == null ? '∞' : fmtBytes(q.limitBytes)}</TableCell>
|
||||
<TableCell>
|
||||
{q.limitBytes == null
|
||||
? <Typography variant="body2" color="text.secondary">unbegrenzt</Typography>
|
||||
: <Stack direction="row" spacing={1} alignItems="center">
|
||||
<LinearProgress variant="determinate" value={Math.min(q.percent || 0, 100)}
|
||||
sx={{ flex: 1, height: 8, borderRadius: 4 }} />
|
||||
<Typography variant="caption">{q.percent ?? 0}%</Typography>
|
||||
</Stack>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Title, useNotify } from 'react-admin';
|
||||
import {
|
||||
Card, CardContent, Typography, Box, Stack, TextField, Button, Chip,
|
||||
Divider, InputAdornment,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const notify = useNotify();
|
||||
const [s, setS] = useState(null);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
apiFetch('/settings').then((r) => setS(r.json)).catch(() => notify('Einstellungen nicht ladbar', { type: 'warning' }));
|
||||
}, [notify]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const set = (k) => (e) => setS((v) => ({ ...v, [k]: e.target.value }));
|
||||
|
||||
const save = () => {
|
||||
apiFetch('/settings/settings', { method: 'PUT', body: JSON.stringify(s) })
|
||||
.then((r) => { setS(r.json); notify('Gespeichert', { type: 'success' }); })
|
||||
.catch((e) => notify(e?.body?.error || 'Speichern fehlgeschlagen', { type: 'warning' }));
|
||||
};
|
||||
|
||||
const addDomain = () => {
|
||||
const d = newDomain.trim().toLowerCase();
|
||||
if (!d) return;
|
||||
apiFetch('/settings/domains', { method: 'POST', body: JSON.stringify({ domain: d }) })
|
||||
.then((r) => { setS(r.json); setNewDomain(''); notify(`${d} hinzugefügt – DKIM wird erzeugt`, { type: 'success' }); })
|
||||
.catch((e) => notify(e?.body?.error || 'Domain konnte nicht hinzugefügt werden', { type: 'warning' }));
|
||||
};
|
||||
|
||||
const removeDomain = (d) => {
|
||||
apiFetch(`/settings/domains/${encodeURIComponent(d)}`, { method: 'DELETE' })
|
||||
.then((r) => { setS(r.json); notify(`${d} entfernt`, { type: 'info' }); })
|
||||
.catch(() => notify('Entfernen fehlgeschlagen', { type: 'warning' }));
|
||||
};
|
||||
|
||||
if (!s) return <Card><CardContent>Lädt …</CardContent></Card>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 1, sm: 2 }, maxWidth: 760 }}>
|
||||
<Title title="Einstellungen" />
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>Einstellungen</Typography>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mail-Domains</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Domains, für die Postfächer/Adressen angelegt werden können. Hinzufügen erzeugt direkt den DKIM-Schlüssel.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
{(s.domains || []).map((d) => (
|
||||
<Chip key={d} label={d} onDelete={() => removeDomain(d)}
|
||||
color={d === s.primaryDomain ? 'primary' : 'default'}
|
||||
variant={d === s.primaryDomain ? 'filled' : 'outlined'} />
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField size="small" placeholder="neue-domain.tld" value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">@</InputAdornment> }} />
|
||||
<Button onClick={addDomain} startIcon={<AddIcon />} variant="contained">Hinzufügen</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Allgemein</Typography>
|
||||
<Stack spacing={2} sx={{ maxWidth: 480 }}>
|
||||
<TextField label="Anzeigename / Brand" value={s.brand || ''} onChange={set('brand')} fullWidth />
|
||||
<TextField label="Mailserver-FQDN" value={s.fqdn || ''} onChange={set('fqdn')} fullWidth
|
||||
helperText="Host, zu dem sich Clients/Webmail verbinden (= MX-Ziel)" />
|
||||
<TextField label="Primäre Domain" value={s.primaryDomain || ''} onChange={set('primaryDomain')} fullWidth />
|
||||
<Divider />
|
||||
<TextField label="Webmail-Domain (NPM)" value={s.webmailFqdn || ''} onChange={set('webmailFqdn')} fullWidth />
|
||||
<TextField label="Admin-UI-Domain (NPM)" value={s.adminFqdn || ''} onChange={set('adminFqdn')} fullWidth />
|
||||
</Stack>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button onClick={save} startIcon={<SaveIcon />} variant="contained">Speichern</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { useGetOne, Title, useNotify } from 'react-admin';
|
||||
import { Card, CardContent, Typography, Box, Chip, Stack, Divider, Button } from '@mui/material';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
import { apiFetch } from './dataProvider';
|
||||
|
||||
const Mono = ({ children }) => (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
background: '#1e1e1e', color: '#e0e0e0', p: 1.5, borderRadius: 1, m: 0,
|
||||
overflowX: 'auto', fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const DkimButton = ({ domain, onDone }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const notify = useNotify();
|
||||
const gen = () => {
|
||||
setBusy(true);
|
||||
apiFetch('/mailserver/dkim', { method: 'POST', body: JSON.stringify({ domain }) })
|
||||
.then(() => { notify(`DKIM für ${domain} erzeugt`, { type: 'success' }); onDone?.(); })
|
||||
.catch((e) => notify(e?.body?.error || 'DKIM-Erzeugung fehlgeschlagen', { type: 'warning' }))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
return (
|
||||
<Button size="small" startIcon={<VpnKeyIcon />} onClick={gen} disabled={busy} sx={{ mt: 1 }}>
|
||||
{busy ? 'Erzeuge …' : 'DKIM erzeugen / erneuern'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusPage = () => {
|
||||
const { data, isLoading, error, refetch } = useGetOne('status', { id: 'status' });
|
||||
|
||||
if (isLoading) return <Card><CardContent>Lädt …</CardContent></Card>;
|
||||
if (error) return <Card><CardContent>Fehler beim Laden des Status.</CardContent></Card>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title title="Status & DNS" />
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Mailserver</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip label={`Host: ${data.fqdn}`} />
|
||||
<Chip color="primary" label={`${data.accounts} Postfächer`} />
|
||||
<Chip color="secondary" label={`${data.aliases} Aliase`} />
|
||||
{(data.domains || []).map((d) => <Chip key={d} variant="outlined" label={d} />)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h6" gutterBottom>DNS — Mailhost (einmalig)</Typography>
|
||||
<Mono>{[data.host?.a, data.host?.ptr].filter(Boolean).join('\n')}</Mono>
|
||||
|
||||
{(data.records || []).map((r) => (
|
||||
<Box key={r.domain} sx={{ mt: 3 }}>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>{r.domain}</Typography>
|
||||
<Mono>{[r.mx, r.spf, r.dmarc].join('\n')}</Mono>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>DKIM (TXT)</Typography>
|
||||
{r.dkim
|
||||
? <Mono>{r.dkim}</Mono>
|
||||
: <Typography color="text.secondary" variant="body2">Noch kein DKIM-Schlüssel.</Typography>}
|
||||
<DkimButton domain={r.domain} onDone={refetch} />
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
// Dev-Bypass: nur lokal (config.js / window.__CONFIG__.AUTH_DISABLED), nie im Deploy.
|
||||
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
|
||||
const AUTH_DISABLED = String(cfg.AUTH_DISABLED) === 'true';
|
||||
|
||||
// AuthProvider auf Basis von Supabase (wie OPENBUREAU).
|
||||
export const authProvider = {
|
||||
async login({ username, password }) {
|
||||
if (AUTH_DISABLED) return;
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: username,
|
||||
password,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
},
|
||||
|
||||
async logout() {
|
||||
if (!AUTH_DISABLED && supabase) await supabase.auth.signOut();
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
if (AUTH_DISABLED) return;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!data?.session) throw new Error('Nicht angemeldet');
|
||||
},
|
||||
|
||||
async checkError(error) {
|
||||
if (AUTH_DISABLED) return;
|
||||
const status = error?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
await supabase.auth.signOut();
|
||||
throw new Error('Sitzung abgelaufen');
|
||||
}
|
||||
},
|
||||
|
||||
async getIdentity() {
|
||||
if (AUTH_DISABLED) return { id: 'dev', fullName: 'Dev (Auth aus)' };
|
||||
const { data } = await supabase.auth.getUser();
|
||||
const user = data?.user;
|
||||
return { id: user?.id || 'me', fullName: user?.email || 'Admin' };
|
||||
},
|
||||
|
||||
async getPermissions() {
|
||||
return 'admin';
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import simpleRestProvider from 'ra-data-simple-rest';
|
||||
import { fetchUtils } from 'react-admin';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
// HTTP-Client, der das Supabase-Access-Token als Bearer mitschickt.
|
||||
const httpClient = async (url, options = {}) => {
|
||||
const headers = new Headers(options.headers || { Accept: 'application/json' });
|
||||
if (supabase) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return fetchUtils.fetchJson(url, { ...options, headers });
|
||||
};
|
||||
|
||||
// nginx proxyt /api -> admin-api (Prod); Vite proxyt /api -> :3000 (Dev).
|
||||
export const dataProvider = simpleRestProvider('/api', httpClient);
|
||||
|
||||
// Helfer für eigene Endpunkte (Mailserver-Bridge): apiFetch('/mailserver/overview')
|
||||
export const apiFetch = (path, options) => httpClient('/api' + path, options);
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
List, Datagrid, TextField, EmailField,
|
||||
Create, Edit, SimpleForm, TextInput, PasswordInput,
|
||||
required, email, EditButton, DeleteButton, SearchInput,
|
||||
} from 'react-admin';
|
||||
|
||||
const accountFilters = [<SearchInput key="q" source="q" alwaysOn />];
|
||||
|
||||
// Validatoren
|
||||
const quotaValidate = (v) =>
|
||||
!v || /^\d+\s*[KMGT]?$/i.test(v) ? undefined : 'Format z.B. 5G, 500M (leer = unbegrenzt)';
|
||||
const min8 = (v) => (v && v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
|
||||
const min8Optional = (v) => (!v || v.length >= 8 ? undefined : 'Mindestens 8 Zeichen');
|
||||
|
||||
export const AccountList = () => (
|
||||
<List filters={accountFilters} sort={{ field: 'email', order: 'ASC' }} perPage={25}>
|
||||
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
||||
<EmailField source="email" label="Postfach" />
|
||||
<TextField source="quota" label="Quota" emptyText="∞" />
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const AccountCreate = () => (
|
||||
<Create redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="email" label="E-Mail-Adresse" validate={[required(), email()]} fullWidth
|
||||
helperText="z.B. max@example.com" />
|
||||
<PasswordInput source="password" label="Passwort" validate={[required(), min8]}
|
||||
helperText="Mindestens 8 Zeichen" />
|
||||
<TextInput source="quota" label="Quota" validate={quotaValidate}
|
||||
helperText="z.B. 5G, 500M – leer = unbegrenzt" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const AccountEdit = () => (
|
||||
<Edit redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="email" label="E-Mail-Adresse" disabled fullWidth />
|
||||
<PasswordInput source="password" label="Neues Passwort (leer = unverändert)" validate={min8Optional} />
|
||||
<TextInput source="quota" label="Quota" validate={quotaValidate}
|
||||
helperText="z.B. 5G, 500M – leer = unbegrenzt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
List, Datagrid, TextField,
|
||||
Create, Edit, SimpleForm, TextInput,
|
||||
required, EditButton, DeleteButton, SearchInput,
|
||||
} from 'react-admin';
|
||||
|
||||
const aliasFilters = [<SearchInput key="q" source="q" alwaysOn />];
|
||||
|
||||
// Quelle: vollständige E-Mail ODER @domain.tld (Catch-all)
|
||||
const aliasSource = (v) =>
|
||||
/^([^@\s]+)?@[^@\s]+\.[^@\s]+$/.test(v || '') ? undefined
|
||||
: 'Vollständige E-Mail oder @domain.tld (Catch-all)';
|
||||
|
||||
export const AliasList = () => (
|
||||
<List filters={aliasFilters} sort={{ field: 'source', order: 'ASC' }} perPage={25}>
|
||||
<Datagrid rowClick="edit" bulkActionButtons={false}>
|
||||
<TextField source="source" label="Alias" />
|
||||
<TextField source="destination" label="Ziel(e)" />
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
|
||||
export const AliasCreate = () => (
|
||||
<Create redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="source" label="Alias-Adresse" validate={[required(), aliasSource]} fullWidth
|
||||
helperText="z.B. info@example.com — oder @example.com als Catch-all (fängt alle unbekannten Adressen der Domain)" />
|
||||
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
|
||||
helperText="Eine oder mehrere Zieladressen, mit Komma getrennt" />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
export const AliasEdit = () => (
|
||||
<Edit redirect="list">
|
||||
<SimpleForm>
|
||||
<TextInput source="source" label="Alias-Adresse" disabled fullWidth />
|
||||
<TextInput source="destination" label="Ziel(e)" validate={required()} fullWidth
|
||||
helperText="Mehrere mit Komma getrennt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Konfiguration kommt zur Laufzeit aus /config.js (window.__CONFIG__),
|
||||
// im Dev-Modus aus den VITE_* Umgebungsvariablen.
|
||||
const cfg = (typeof window !== 'undefined' && window.__CONFIG__) || {};
|
||||
const url = cfg.SUPABASE_URL || import.meta.env.VITE_SUPABASE_URL || '';
|
||||
const key = cfg.SUPABASE_ANON_KEY || import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
||||
|
||||
if (!url || !key) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[admin] Supabase URL/Key fehlen — Login wird nicht funktionieren.');
|
||||
}
|
||||
|
||||
// Kein Client, wenn keine Konfiguration vorhanden ist (z.B. lokaler Dev-Bypass).
|
||||
export const supabase = (url && key)
|
||||
? createClient(url, key, { auth: { persistSession: true, autoRefreshToken: true } })
|
||||
: null;
|
||||
@@ -0,0 +1,138 @@
|
||||
// ============================================================
|
||||
// Admin-Theme im OPENBUREAU-Look (warmes Cream-Design-System)
|
||||
// Akzent: petrol #7BA89B (auf Wunsch, statt OPENBUREAU-Terrakotta)
|
||||
// ============================================================
|
||||
const accent = '#7BA89B'; // petrol
|
||||
const accentDeep = '#557A6D';
|
||||
const accentRing = 'rgba(123,168,155,0.20)';
|
||||
|
||||
const fonts = {
|
||||
sans: '"Inter", system-ui, -apple-system, sans-serif',
|
||||
serif: '"Newsreader", Georgia, serif',
|
||||
display: '"Space Grotesk", "Inter", sans-serif',
|
||||
mono: '"IBM Plex Mono", ui-monospace, monospace',
|
||||
};
|
||||
|
||||
const BG = 'hsl(35, 14%, 96%)'; // warmes Off-White
|
||||
const PANEL = '#fffdf9';
|
||||
const PANEL2 = 'hsl(35, 14%, 93%)';
|
||||
const LINE = 'hsl(35, 14%, 86%)';
|
||||
const TEXT = 'hsl(25, 18%, 12%)';
|
||||
const MUTED = 'hsl(25, 8%, 42%)';
|
||||
const DARK = '#191919';
|
||||
const SHADOW = '0 10px 34px -22px rgba(40,20,10,.5)';
|
||||
|
||||
export const openbureauTheme = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: accent, dark: accentDeep, contrastText: '#ffffff' },
|
||||
secondary: { main: accentDeep, contrastText: '#ffffff' },
|
||||
background: { default: BG, paper: PANEL },
|
||||
text: { primary: TEXT, secondary: MUTED },
|
||||
divider: LINE,
|
||||
success: { main: '#5d7d4b' },
|
||||
warning: { main: '#b8902f' },
|
||||
error: { main: '#b54a2c' },
|
||||
},
|
||||
shape: { borderRadius: 11 },
|
||||
typography: {
|
||||
fontFamily: fonts.sans,
|
||||
fontWeightMedium: 500,
|
||||
h1: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h2: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h3: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h4: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h5: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
h6: { fontFamily: fonts.serif, fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
// dunkle Topbar wie OPENBUREAU
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: DARK,
|
||||
color: '#f0f0f0',
|
||||
boxShadow: 'none',
|
||||
backgroundImage: 'none',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
RaAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .RaAppBar-title': {
|
||||
fontFamily: fonts.display,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Pill-Buttons (22px), keine Versalien
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: { borderRadius: 22, textTransform: 'none', fontWeight: 500, paddingLeft: 16, paddingRight: 16 },
|
||||
containedPrimary: { color: '#fff', '&:hover': { backgroundColor: accentDeep } },
|
||||
outlined: { borderColor: LINE, backgroundColor: PANEL },
|
||||
},
|
||||
},
|
||||
MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } },
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: { border: `1px solid ${LINE}`, boxShadow: SHADOW, borderRadius: 11, backgroundColor: PANEL },
|
||||
},
|
||||
},
|
||||
// Inputs: Radius 9, Petrol-Fokusring
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 9,
|
||||
backgroundColor: PANEL,
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: LINE },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: accent, borderWidth: 1 },
|
||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${accentRing}` },
|
||||
},
|
||||
},
|
||||
},
|
||||
// Sidebar warm, aktives Item mit Petrol-Kante
|
||||
RaSidebar: {
|
||||
styleOverrides: {
|
||||
root: { backgroundColor: PANEL2, borderRight: `1px solid ${LINE}` },
|
||||
},
|
||||
},
|
||||
RaMenuItemLink: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 11,
|
||||
margin: '3px 6px',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: MUTED,
|
||||
'&:hover': { backgroundColor: 'rgba(0,0,0,0.05)' },
|
||||
// klarer Petrol-Akzent im aktiven Zustand (auch eingeklappt sichtbar)
|
||||
'&.RaMenuItemLink-active': {
|
||||
backgroundColor: accentRing,
|
||||
color: accentDeep,
|
||||
fontWeight: 600,
|
||||
},
|
||||
// Icon in der eingeklappten Sidebar mittig
|
||||
'& .RaMenuItemLink-icon': {
|
||||
color: 'inherit',
|
||||
minWidth: 32,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RaLayout: { styleOverrides: { root: { backgroundColor: BG } } },
|
||||
RaDatagrid: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .RaDatagrid-headerCell': { fontWeight: 600, color: MUTED, backgroundColor: 'transparent' },
|
||||
'& .RaDatagrid-rowCell': { borderBottomColor: LINE },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// In der Produktion serviert nginx die statischen Dateien und proxyt /api
|
||||
// an den admin-api Container. Im Dev-Modus proxyen wir /api lokal.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Abhängigkeiten zuerst (besseres Layer-Caching)
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
# als non-root laufen
|
||||
USER node
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,60 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// auth.js — Supabase-Token-Prüfung (wie in OPENBUREAU)
|
||||
//
|
||||
// Die React-Admin-UI loggt sich per Supabase ein und schickt das Access-Token
|
||||
// als Authorization: Bearer <token>. Hier validieren wir es gegen Supabase
|
||||
// und prüfen, ob die E-Mail in ADMIN_ALLOWED_EMAILS steht.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
|
||||
const ALLOWED = (process.env.ADMIN_ALLOWED_EMAILS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
// Nur für lokale Tests: hebt die Auth komplett auf. Standardmäßig AUS.
|
||||
// Wird ausschließlich im docker-compose.local.yml gesetzt, niemals im Deploy.
|
||||
const AUTH_DISABLED = process.env.AUTH_DISABLED === 'true';
|
||||
if (AUTH_DISABLED) {
|
||||
console.warn('[auth] ⚠ AUTH_DISABLED=true — KEINE Authentifizierung! Nur für lokale Tests verwenden.');
|
||||
}
|
||||
|
||||
// Supabase-Client nur erstellen, wenn Auth aktiv und konfiguriert ist
|
||||
// (createClient wirft bei leerer URL — würde sonst den Start verhindern).
|
||||
let supabase = null;
|
||||
if (!AUTH_DISABLED) {
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
console.warn('[auth] SUPABASE_URL / SUPABASE_ANON_KEY nicht gesetzt — Auth wird fehlschlagen.');
|
||||
} else {
|
||||
supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(req, res, next) {
|
||||
try {
|
||||
if (AUTH_DISABLED) {
|
||||
req.user = { email: 'dev@local.test' };
|
||||
return next();
|
||||
}
|
||||
if (!supabase) return res.status(500).json({ error: 'Auth nicht konfiguriert (SUPABASE_URL/ANON_KEY fehlen).' });
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'Kein Token.' });
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
if (error || !data?.user) return res.status(401).json({ error: 'Token ungültig.' });
|
||||
|
||||
const email = (data.user.email || '').toLowerCase();
|
||||
if (ALLOWED.length && !ALLOWED.includes(email)) {
|
||||
return res.status(403).json({ error: 'Kein Admin-Zugriff für diese E-Mail.' });
|
||||
}
|
||||
req.user = { email };
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Auth-Fehler: ' + e.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// mailserver.js — abgesicherte Bridge zum docker-mailserver Container
|
||||
//
|
||||
// Läuft NUR über einen docker-socket-proxy (nur exec freigegeben) und führt
|
||||
// ausschließlich WHITELISTED Kommandos im Mailserver-Container aus. Argumente
|
||||
// werden als argv-Array übergeben (keine Shell -> keine Injection).
|
||||
// ---------------------------------------------------------------------------
|
||||
import Docker from 'dockerode';
|
||||
import { httpErr } from './store.js';
|
||||
|
||||
const MAILSERVER = process.env.MAILSERVER_CONTAINER || 'mailserver';
|
||||
// DOCKER_PROXY = "host:port" (docker-socket-proxy), z.B. socket-proxy:2375
|
||||
const [proxyHost, proxyPort] = (process.env.DOCKER_PROXY || 'socket-proxy:2375').split(':');
|
||||
const docker = new Docker({ host: proxyHost, port: Number(proxyPort) || 2375 });
|
||||
|
||||
const isEmail = (s) => /^@?[^@\s]*@?[^@\s]+\.[^@\s]+$/.test(s); // erlaubt auch @domain
|
||||
const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s);
|
||||
|
||||
// --- whitelisted exec: nimmt ein argv-Array, gibt stdout zurück ------------
|
||||
async function exec(cmd) {
|
||||
const container = docker.getContainer(MAILSERVER);
|
||||
const ex = await container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true });
|
||||
const stream = await ex.start({ hijack: true, stdin: false });
|
||||
const out = [];
|
||||
const err = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
container.modem.demuxStream(
|
||||
stream,
|
||||
{ write: (d) => out.push(d) },
|
||||
{ write: (d) => err.push(d) },
|
||||
);
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const info = await ex.inspect();
|
||||
if (info.ExitCode && info.ExitCode !== 0) {
|
||||
throw httpErr(502, `Mailserver-Kommando fehlgeschlagen: ${Buffer.concat(err).toString() || 'Exit ' + info.ExitCode}`);
|
||||
}
|
||||
return Buffer.concat(out).toString('utf8');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// QUOTA-Auslastung (doveadm quota get -A)
|
||||
// ===========================================================================
|
||||
export async function quotaUsage() {
|
||||
const raw = await exec(['doveadm', 'quota', 'get', '-A']);
|
||||
const rows = {};
|
||||
for (const line of raw.split('\n')) {
|
||||
// Username ... STORAGE <value KB> <limit KB|-> <%>
|
||||
const m = line.match(/^(\S+)\s+User quota\s+STORAGE\s+(\d+)\s+(\d+|-)\s+(\d+|-)/);
|
||||
if (m) {
|
||||
const [, user, valueKB, limitKB, pct] = m;
|
||||
rows[user] = {
|
||||
id: user,
|
||||
user,
|
||||
usedBytes: Number(valueKB) * 1024,
|
||||
limitBytes: limitKB === '-' ? null : Number(limitKB) * 1024,
|
||||
percent: pct === '-' ? null : Number(pct),
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.values(rows);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mail-Queue (postqueue -p)
|
||||
// ===========================================================================
|
||||
export async function queue() {
|
||||
const raw = await exec(['postqueue', '-p']);
|
||||
const empty = /Mail queue is empty/.test(raw);
|
||||
// letzte Zeile: "-- N Kbytes in M Requests."
|
||||
const m = raw.match(/in (\d+) Request/i);
|
||||
return { empty, count: empty ? 0 : (m ? Number(m[1]) : null), raw: raw.trim() };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Aktive Logins (doveadm who)
|
||||
// ===========================================================================
|
||||
export async function who() {
|
||||
const raw = await exec(['doveadm', 'who']);
|
||||
const lines = raw.trim().split('\n').slice(1).filter(Boolean); // ohne Header
|
||||
return {
|
||||
sessions: lines.map((l) => {
|
||||
const [username, count, proto] = l.split(/\s+/);
|
||||
return { username, count: Number(count) || count, proto };
|
||||
}),
|
||||
raw: raw.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// DKIM-Schlüssel pro Domain erzeugen (setup config dkim ...)
|
||||
// ===========================================================================
|
||||
export async function generateDkim(domain) {
|
||||
if (!isDomain(domain)) throw httpErr(400, 'Ungültige Domain.');
|
||||
await exec(['setup', 'config', 'dkim', 'keysize', '2048', 'domain', domain]);
|
||||
return { ok: true, domain };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Übersicht
|
||||
// ===========================================================================
|
||||
export async function overview() {
|
||||
const [q, qu, w] = await Promise.all([queue(), quotaUsage(), who()]);
|
||||
return { queue: q, quota: qu, who: w };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// settings.js — editierbare Admin-Einstellungen (Domains, Webmail-Domain, Brand)
|
||||
//
|
||||
// Liegt als JSON in der DMS-Config (persistent). Beim ersten Start aus den
|
||||
// ENV-Variablen (Deploy-Dialog) geseedet, danach in der Admin-UI editierbar.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const CONFIG_DIR = process.env.CONFIG_DIR || '/config';
|
||||
const FILE = path.join(CONFIG_DIR, 'admin-settings.json');
|
||||
|
||||
const isDomain = (s) => /^[^@\s]+\.[^@\s]+$/.test(s);
|
||||
const envDomains = () =>
|
||||
(process.env.MAIL_DOMAINS || process.env.MAIL_DOMAIN || '').split(/[\s,]+/).filter(Boolean);
|
||||
|
||||
function seed() {
|
||||
const domains = envDomains();
|
||||
const primary = process.env.MAIL_DOMAIN || domains[0] || 'example.com';
|
||||
return {
|
||||
brand: process.env.BRAND || primary,
|
||||
fqdn: process.env.MAIL_FQDN || `mail.${primary}`,
|
||||
primaryDomain: primary,
|
||||
domains: domains.length ? domains : [primary],
|
||||
webmailFqdn: process.env.WEBMAIL_FQDN || `mail.${primary}`,
|
||||
adminFqdn: process.env.ADMIN_FQDN || `admin.${primary}`,
|
||||
};
|
||||
}
|
||||
|
||||
let chain = Promise.resolve();
|
||||
const withLock = (fn) => { const r = chain.then(fn, fn); chain = r.catch(() => {}); return r; };
|
||||
const save = (s) => fs.writeFile(FILE, JSON.stringify(s, null, 2) + '\n', 'utf8');
|
||||
|
||||
export async function readSettings() {
|
||||
try {
|
||||
return { id: 'settings', ...JSON.parse(await fs.readFile(FILE, 'utf8')) };
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') { const s = seed(); await save(s); return { id: 'settings', ...s }; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSettings(patch) {
|
||||
return withLock(async () => {
|
||||
const { id, ...cur } = await readSettings();
|
||||
const next = { ...cur };
|
||||
// nur erlaubte Felder
|
||||
for (const k of ['brand', 'fqdn', 'primaryDomain', 'webmailFqdn', 'adminFqdn']) {
|
||||
if (typeof patch[k] === 'string' && patch[k].trim()) next[k] = patch[k].trim();
|
||||
}
|
||||
if (Array.isArray(patch.domains)) {
|
||||
next.domains = [...new Set(patch.domains.map((d) => String(d).trim()).filter(isDomain))];
|
||||
}
|
||||
await save(next);
|
||||
return { id: 'settings', ...next };
|
||||
});
|
||||
}
|
||||
|
||||
export function addDomain(domain) {
|
||||
return withLock(async () => {
|
||||
domain = String(domain || '').trim().toLowerCase();
|
||||
if (!isDomain(domain)) { const e = new Error('Ungültige Domain.'); e.status = 400; throw e; }
|
||||
const { id, ...cur } = await readSettings();
|
||||
if (!cur.domains.includes(domain)) cur.domains.push(domain);
|
||||
await save(cur);
|
||||
return { id: 'settings', ...cur };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeDomain(domain) {
|
||||
return withLock(async () => {
|
||||
const { id, ...cur } = await readSettings();
|
||||
cur.domains = cur.domains.filter((d) => d !== domain);
|
||||
await save(cur);
|
||||
return { id: 'settings', ...cur };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// store.js — liest/schreibt die docker-mailserver Config-Dateien
|
||||
//
|
||||
// postfix-accounts.cf email|{SHA512-CRYPT}$6$... (ein Konto pro Zeile)
|
||||
// postfix-virtual.cf quelle@dom ziel1@dom,ziel2@dom (ein Alias pro Zeile)
|
||||
// dovecot-quotas.cf email:10G (eine Quota pro Zeile)
|
||||
//
|
||||
// DMS erkennt Dateiänderungen automatisch und lädt neu — kein Docker-Socket nötig.
|
||||
// ---------------------------------------------------------------------------
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { sha512crypt } from 'sha512crypt-node';
|
||||
import { readSettings } from './settings.js';
|
||||
|
||||
const CONFIG_DIR = process.env.CONFIG_DIR || '/config';
|
||||
const ACCOUNTS = path.join(CONFIG_DIR, 'postfix-accounts.cf');
|
||||
const VIRTUAL = path.join(CONFIG_DIR, 'postfix-virtual.cf');
|
||||
const QUOTAS = path.join(CONFIG_DIR, 'dovecot-quotas.cf');
|
||||
|
||||
// --- simpler Schreib-Mutex (Admin-Last ist niedrig) -----------------------
|
||||
let chain = Promise.resolve();
|
||||
function withLock(fn) {
|
||||
const run = chain.then(fn, fn);
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
// --- Datei-Helfer ----------------------------------------------------------
|
||||
async function readLines(file) {
|
||||
try {
|
||||
const txt = await fs.readFile(file, 'utf8');
|
||||
return txt.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLines(file, lines) {
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
await fs.writeFile(tmp, lines.length ? lines.join('\n') + '\n' : '', 'utf8');
|
||||
await fs.rename(tmp, file); // atomar
|
||||
}
|
||||
|
||||
// --- Passwort-Hash (SHA512-CRYPT), Format wie doveadm pw ------------------
|
||||
const SALTCHARS = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
function hashPassword(plain) {
|
||||
let salt = '';
|
||||
for (let i = 0; i < 16; i++) salt += SALTCHARS[Math.floor(Math.random() * SALTCHARS.length)];
|
||||
return '{SHA512-CRYPT}' + sha512crypt(plain, '$6$' + salt);
|
||||
}
|
||||
|
||||
const isEmail = (s) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
|
||||
const isCatchAll = (s) => /^@[^@\s]+\.[^@\s]+$/.test(s); // @domain.tld
|
||||
const isQuota = (q) => !q || /^\d+\s*[KMGT]?$/i.test(String(q).trim()); // 5G, 500M, leer
|
||||
|
||||
// ===========================================================================
|
||||
// ACCOUNTS (+ Quota wird mit eingelesen/geschrieben)
|
||||
// ===========================================================================
|
||||
async function readQuotaMap() {
|
||||
const map = {};
|
||||
for (const line of await readLines(QUOTAS)) {
|
||||
const idx = line.lastIndexOf(':');
|
||||
if (idx > 0) map[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function listAccounts() {
|
||||
const quotas = await readQuotaMap();
|
||||
return (await readLines(ACCOUNTS)).map((line) => {
|
||||
const idx = line.indexOf('|');
|
||||
const email = idx > 0 ? line.slice(0, idx) : line;
|
||||
return { id: email, email, quota: quotas[email] || '' };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAccount(email) {
|
||||
return (await listAccounts()).find((a) => a.id === email) || null;
|
||||
}
|
||||
|
||||
export function createAccount({ email, password, quota }) {
|
||||
return withLock(async () => {
|
||||
if (!isEmail(email)) throw httpErr(400, 'Ungültige E-Mail-Adresse.');
|
||||
if (!password) throw httpErr(400, 'Passwort erforderlich.');
|
||||
if (password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.');
|
||||
if (!isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M – leer = unbegrenzt).');
|
||||
const lines = await readLines(ACCOUNTS);
|
||||
if (lines.some((l) => l.split('|')[0] === email)) throw httpErr(409, 'Konto existiert bereits.');
|
||||
lines.push(`${email}|${hashPassword(password)}`);
|
||||
await writeLines(ACCOUNTS, lines);
|
||||
await setQuotaUnlocked(email, quota);
|
||||
return { id: email, email, quota: quota || '' };
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(email, { password, quota }) {
|
||||
return withLock(async () => {
|
||||
const lines = await readLines(ACCOUNTS);
|
||||
const i = lines.findIndex((l) => l.split('|')[0] === email);
|
||||
if (i === -1) throw httpErr(404, 'Konto nicht gefunden.');
|
||||
if (password && password.length < 8) throw httpErr(400, 'Passwort muss mindestens 8 Zeichen haben.');
|
||||
if (quota !== undefined && !isQuota(quota)) throw httpErr(400, 'Ungültiges Quota-Format (z.B. 5G, 500M).');
|
||||
if (password) lines[i] = `${email}|${hashPassword(password)}`;
|
||||
await writeLines(ACCOUNTS, lines);
|
||||
if (quota !== undefined) await setQuotaUnlocked(email, quota);
|
||||
return { id: email, email, quota: quota ?? (await readQuotaMap())[email] ?? '' };
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAccount(email) {
|
||||
return withLock(async () => {
|
||||
const lines = await readLines(ACCOUNTS);
|
||||
await writeLines(ACCOUNTS, lines.filter((l) => l.split('|')[0] !== email));
|
||||
await setQuotaUnlocked(email, ''); // Quota mit entfernen
|
||||
return { id: email };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Quota (innerhalb eines bestehenden Locks aufrufen) -------------------
|
||||
async function setQuotaUnlocked(email, quota) {
|
||||
const lines = await readLines(QUOTAS);
|
||||
const rest = lines.filter((l) => l.slice(0, l.lastIndexOf(':')) !== email);
|
||||
if (quota) rest.push(`${email}:${quota}`);
|
||||
await writeLines(QUOTAS, rest);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// ALIASES
|
||||
// ===========================================================================
|
||||
export async function listAliases() {
|
||||
return (await readLines(VIRTUAL)).map((line) => {
|
||||
const m = line.split(/\s+/);
|
||||
const source = m.shift();
|
||||
return { id: source, source, destination: m.join(' ').replace(/\s+/g, ',') };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAlias(source) {
|
||||
return (await listAliases()).find((a) => a.id === source) || null;
|
||||
}
|
||||
|
||||
export function createAlias({ source, destination }) {
|
||||
return withLock(async () => {
|
||||
if (!isEmail(source) && !isCatchAll(source)) {
|
||||
throw httpErr(400, 'Ungültige Alias-Adresse (vollständige E-Mail oder @domain.tld für Catch-all).');
|
||||
}
|
||||
if (!destination) throw httpErr(400, 'Ziel erforderlich.');
|
||||
const lines = await readLines(VIRTUAL);
|
||||
if (lines.some((l) => l.split(/\s+/)[0] === source)) throw httpErr(409, 'Alias existiert bereits.');
|
||||
lines.push(`${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`);
|
||||
await writeLines(VIRTUAL, lines);
|
||||
return { id: source, source, destination };
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAlias(source, { destination }) {
|
||||
return withLock(async () => {
|
||||
const lines = await readLines(VIRTUAL);
|
||||
const i = lines.findIndex((l) => l.split(/\s+/)[0] === source);
|
||||
if (i === -1) throw httpErr(404, 'Alias nicht gefunden.');
|
||||
lines[i] = `${source} ${destination.split(',').map((s) => s.trim()).filter(Boolean).join(',')}`;
|
||||
await writeLines(VIRTUAL, lines);
|
||||
return { id: source, source, destination };
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAlias(source) {
|
||||
return withLock(async () => {
|
||||
const lines = await readLines(VIRTUAL);
|
||||
await writeLines(VIRTUAL, lines.filter((l) => l.split(/\s+/)[0] !== source));
|
||||
return { id: source };
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// STATUS / DNS / DKIM
|
||||
// ===========================================================================
|
||||
export async function status() {
|
||||
const s = await readSettings();
|
||||
const primary = s.primaryDomain;
|
||||
const fqdn = s.fqdn;
|
||||
const domains = s.domains;
|
||||
const accounts = await listAccounts();
|
||||
const aliases = await listAliases();
|
||||
|
||||
// alle DKIM-DNS-Dateien einlesen (Dateiname enthält die Domain)
|
||||
const dkimFiles = [];
|
||||
try {
|
||||
const dkimDir = path.join(CONFIG_DIR, 'rspamd', 'dkim');
|
||||
for (const f of await fs.readdir(dkimDir)) {
|
||||
if (f.endsWith('.dns.txt')) {
|
||||
dkimFiles.push({ f, txt: (await fs.readFile(path.join(dkimDir, f), 'utf8')).trim() });
|
||||
}
|
||||
}
|
||||
} catch { /* noch kein DKIM erzeugt */ }
|
||||
|
||||
const records = domains.map((d) => ({
|
||||
domain: d,
|
||||
mx: `${d}. IN MX 10 ${fqdn}.`,
|
||||
spf: `${d}. IN TXT "v=spf1 mx ~all"`,
|
||||
dmarc: `_dmarc.${d}. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@${d}"`,
|
||||
dkim: (dkimFiles.find((x) => x.f.includes(`-${d}.`) || x.f.includes(d)) || {}).txt || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
id: 'status',
|
||||
fqdn,
|
||||
domain: primary,
|
||||
brand: s.brand,
|
||||
webmailFqdn: s.webmailFqdn,
|
||||
adminFqdn: s.adminFqdn,
|
||||
domains,
|
||||
accounts: accounts.length,
|
||||
aliases: aliases.length,
|
||||
host: {
|
||||
a: `${fqdn}. IN A <öffentliche IP>`,
|
||||
ptr: `<öffentliche IP> -> ${fqdn} (PTR/rDNS beim Hoster setzen)`,
|
||||
},
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
// --- kleiner HTTP-Fehler-Helfer -------------------------------------------
|
||||
export function httpErr(status, message) {
|
||||
const e = new Error(message);
|
||||
e.status = status;
|
||||
return e;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "dms-admin-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Admin API für docker-mailserver — verwaltet Konten/Aliase/Quotas über die DMS-Config-Dateien (Supabase-Auth).",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"dockerode": "^4.0.2",
|
||||
"express": "^4.19.2",
|
||||
"sha512crypt-node": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// server.js — Admin-API für docker-mailserver
|
||||
//
|
||||
// REST-Schnittstelle im Format von react-admin (ra-data-simple-rest):
|
||||
// GET /accounts Liste (mit Content-Range Header)
|
||||
// GET /accounts/:id einzelnes Konto
|
||||
// POST /accounts anlegen
|
||||
// PUT /accounts/:id ändern (Passwort/Quota)
|
||||
// DELETE /accounts/:id löschen
|
||||
// analog /aliases, sowie GET /status (DNS/DKIM-Infos).
|
||||
// ---------------------------------------------------------------------------
|
||||
import express from 'express';
|
||||
import { requireAdmin } from './lib/auth.js';
|
||||
import * as store from './lib/store.js';
|
||||
import * as ms from './lib/mailserver.js';
|
||||
import * as settings from './lib/settings.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// --- ungeschützter Health-Check -------------------------------------------
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// --- ab hier alles geschützt ----------------------------------------------
|
||||
app.use(requireAdmin);
|
||||
|
||||
// Hilfsfunktion: Liste sortieren/filtern/paginieren wie ra-data-simple-rest
|
||||
function sendList(res, resource, rows, query) {
|
||||
let data = rows;
|
||||
// filter
|
||||
if (query.filter) {
|
||||
try {
|
||||
const f = JSON.parse(query.filter);
|
||||
// getMany: { id: [...] }
|
||||
if (Array.isArray(f.id)) data = data.filter((r) => f.id.includes(r.id));
|
||||
// Volltext-Filter "q"
|
||||
if (f.q) {
|
||||
const q = String(f.q).toLowerCase();
|
||||
data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(q));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// sort
|
||||
if (query.sort) {
|
||||
try {
|
||||
const [field, order] = JSON.parse(query.sort);
|
||||
data = [...data].sort((a, b) => String(a[field]).localeCompare(String(b[field])));
|
||||
if (order === 'DESC') data.reverse();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const total = data.length;
|
||||
// range
|
||||
let [start, end] = [0, total - 1];
|
||||
if (query.range) {
|
||||
try { [start, end] = JSON.parse(query.range); } catch { /* ignore */ }
|
||||
}
|
||||
const page = data.slice(start, end + 1);
|
||||
res.set('Content-Range', `${resource} ${start}-${start + page.length - 1}/${total}`);
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Range');
|
||||
res.json(page);
|
||||
}
|
||||
|
||||
const id = (req) => decodeURIComponent(req.params.id);
|
||||
|
||||
// ============================ ACCOUNTS =====================================
|
||||
app.get('/accounts', async (req, res, next) => {
|
||||
try { sendList(res, 'accounts', await store.listAccounts(), req.query); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/accounts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const a = await store.getAccount(id(req));
|
||||
if (!a) return res.status(404).json({ error: 'Konto nicht gefunden.' });
|
||||
res.json(a);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.post('/accounts', async (req, res, next) => {
|
||||
try { res.status(201).json(await store.createAccount(req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/accounts/:id', async (req, res, next) => {
|
||||
try { res.json(await store.updateAccount(id(req), req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/accounts/:id', async (req, res, next) => {
|
||||
try { res.json(await store.deleteAccount(id(req))); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============================ ALIASES ======================================
|
||||
app.get('/aliases', async (req, res, next) => {
|
||||
try { sendList(res, 'aliases', await store.listAliases(), req.query); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/aliases/:id', async (req, res, next) => {
|
||||
try {
|
||||
const a = await store.getAlias(id(req));
|
||||
if (!a) return res.status(404).json({ error: 'Alias nicht gefunden.' });
|
||||
res.json(a);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.post('/aliases', async (req, res, next) => {
|
||||
try { res.status(201).json(await store.createAlias(req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/aliases/:id', async (req, res, next) => {
|
||||
try { res.json(await store.updateAlias(id(req), req.body)); } catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/aliases/:id', async (req, res, next) => {
|
||||
try { res.json(await store.deleteAlias(id(req))); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============================ STATUS =======================================
|
||||
app.get('/status', async (_req, res, next) => {
|
||||
try { res.json(await store.status()); } catch (e) { next(e); }
|
||||
});
|
||||
// react-admin erwartet bei getOne(status) ggf. /status/status
|
||||
app.get('/status/:id', async (_req, res, next) => {
|
||||
try { res.json(await store.status()); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============== MAILSERVER-BRIDGE (über docker-socket-proxy) ===============
|
||||
app.get('/mailserver/overview', async (_req, res, next) => {
|
||||
try { res.json(await ms.overview()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/quota', async (_req, res, next) => {
|
||||
try { res.json(await ms.quotaUsage()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/queue', async (_req, res, next) => {
|
||||
try { res.json(await ms.queue()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/mailserver/who', async (_req, res, next) => {
|
||||
try { res.json(await ms.who()); } catch (e) { next(e); }
|
||||
});
|
||||
app.post('/mailserver/dkim', async (req, res, next) => {
|
||||
try { res.json(await ms.generateDkim((req.body || {}).domain)); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ============== EINSTELLUNGEN (Domains / Webmail-Domain / Brand) ============
|
||||
app.post('/settings/domains', async (req, res, next) => {
|
||||
try {
|
||||
const domain = (req.body || {}).domain;
|
||||
const result = await settings.addDomain(domain);
|
||||
ms.generateDkim(domain).catch(() => {}); // DKIM best-effort über die Bridge
|
||||
res.json(result);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
app.delete('/settings/domains/:domain', async (req, res, next) => {
|
||||
try { res.json(await settings.removeDomain(decodeURIComponent(req.params.domain))); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/settings', async (_req, res, next) => {
|
||||
try { res.json(await settings.readSettings()); } catch (e) { next(e); }
|
||||
});
|
||||
app.get('/settings/:id', async (_req, res, next) => {
|
||||
try { res.json(await settings.readSettings()); } catch (e) { next(e); }
|
||||
});
|
||||
app.put('/settings/:id', async (req, res, next) => {
|
||||
try { res.json(await settings.writeSettings(req.body || {})); } catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// --- Fehlerbehandlung ------------------------------------------------------
|
||||
app.use((err, _req, res, _next) => {
|
||||
const code = err.status || 500;
|
||||
if (code >= 500) console.error(err);
|
||||
res.status(code).json({ error: err.message || 'Serverfehler' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`[dms-admin-api] läuft auf :${PORT}`));
|
||||
@@ -0,0 +1,114 @@
|
||||
# ============================================================================
|
||||
# docker-mailserver Stack
|
||||
# mailserver – Postfix/Dovecot/Rspamd (docker-mailserver)
|
||||
# admin-api – Node.js API (Supabase-Auth) verwaltet DMS-Config-Dateien
|
||||
# admin-ui – React-Admin Oberfläche (nginx, proxyt /api -> admin-api)
|
||||
# snappymail – schlankes Webmail für die Mitarbeiter
|
||||
#
|
||||
# TLS/HTTPS für admin-ui & snappymail macht der Nginx Proxy Manager
|
||||
# (separater Stack), der auf ADMIN_PORT / WEBMAIL_PORT dieses Hosts zeigt.
|
||||
# ============================================================================
|
||||
services:
|
||||
mailserver:
|
||||
image: ghcr.io/docker-mailserver/docker-mailserver:${DMS_TAG:-latest}
|
||||
container_name: mailserver
|
||||
hostname: ${MAIL_FQDN}
|
||||
env_file: mailserver.env
|
||||
environment:
|
||||
- OVERRIDE_HOSTNAME=${MAIL_FQDN}
|
||||
- POSTMASTER_ADDRESS=postmaster@${MAIL_DOMAIN}
|
||||
ports:
|
||||
- "25:25" # SMTP (eingehender MX-Verkehr)
|
||||
- "143:143" # IMAP (STARTTLS)
|
||||
- "465:465" # SMTP Submission (implicit TLS)
|
||||
- "587:587" # SMTP Submission (STARTTLS)
|
||||
- "993:993" # IMAP (implicit TLS)
|
||||
- "${RSPAMD_PORT:-11334}:11334" # Rspamd Web-UI — NUR über NPM/Firewall öffnen!
|
||||
volumes:
|
||||
- ./docker-data/dms/mail-data/:/var/mail/
|
||||
- ./docker-data/dms/mail-state/:/var/mail-state/
|
||||
- ./docker-data/dms/mail-logs/:/var/log/mail/
|
||||
- ./docker-data/dms/config/:/tmp/docker-mailserver/
|
||||
- ./docker-data/certs/:/etc/letsencrypt/:ro # Zertifikate (NPM DNS-Challenge)
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: always
|
||||
stop_grace_period: 1m
|
||||
cap_add:
|
||||
- NET_ADMIN # für Fail2ban
|
||||
healthcheck:
|
||||
test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1"
|
||||
timeout: 3s
|
||||
retries: 0
|
||||
|
||||
# docker-socket-proxy: gibt der Admin-API NUR exec frei (kein create/delete/...)
|
||||
socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:latest
|
||||
container_name: dms-socket-proxy
|
||||
restart: always
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
- CONTAINERS=1
|
||||
- EXEC=1
|
||||
- POST=1
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
admin-api:
|
||||
build: ./api
|
||||
container_name: dms-admin-api
|
||||
restart: always
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
- CONFIG_DIR=/config
|
||||
- MAIL_DOMAIN=${MAIL_DOMAIN}
|
||||
- MAIL_DOMAINS=${MAIL_DOMAINS}
|
||||
- MAIL_FQDN=${MAIL_FQDN}
|
||||
- BRAND=${BRAND}
|
||||
- WEBMAIL_FQDN=${WEBMAIL_FQDN}
|
||||
- ADMIN_FQDN=${ADMIN_FQDN}
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- ADMIN_ALLOWED_EMAILS=${ADMIN_ALLOWED_EMAILS}
|
||||
# Bridge zum Mailserver: nur exec über den socket-proxy, Whitelist in der API
|
||||
- DOCKER_PROXY=socket-proxy:2375
|
||||
- MAILSERVER_CONTAINER=mailserver
|
||||
depends_on:
|
||||
- socket-proxy
|
||||
volumes:
|
||||
# Schreibzugriff auf die DMS-Config-Dateien (kein direkter Docker-Socket!)
|
||||
- ./docker-data/dms/config/:/config/
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
admin-ui:
|
||||
build:
|
||||
context: ./admin
|
||||
args:
|
||||
# Werden zur Laufzeit via /config.js injiziert (siehe entrypoint)
|
||||
- VITE_SUPABASE_URL=${SUPABASE_URL}
|
||||
- VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
container_name: dms-admin-ui
|
||||
restart: always
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
- admin-api
|
||||
environment:
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
ports:
|
||||
- "${ADMIN_PORT:-8080}:80"
|
||||
|
||||
snappymail:
|
||||
image: djmaze/snappymail:latest
|
||||
container_name: snappymail
|
||||
restart: always
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${WEBMAIL_PORT:-8888}:8888"
|
||||
volumes:
|
||||
- ./docker-data/snappymail/:/var/lib/snappymail/ # echter Datenpfad der djmaze-Image
|
||||
- ./snappymail-theme/:/snappymail/themes/:ro # KGVA "Shibui"-Theme
|
||||
@@ -0,0 +1,32 @@
|
||||
# ============================================================================
|
||||
# docker-mailserver — statische Feature-Flags
|
||||
# (Hostname/Domain kommen über docker-compose aus der .env)
|
||||
# Vollständige Referenz: https://docker-mailserver.github.io/docker-mailserver/latest/config/environment/
|
||||
# ============================================================================
|
||||
|
||||
TZ=Europe/Zurich
|
||||
LOG_LEVEL=info
|
||||
|
||||
# --- TLS ---
|
||||
# Fester Cert-Pfad (gemountet aus ./docker-data/certs nach /etc/letsencrypt).
|
||||
# Das Setup-Skript legt dort beim ersten Start ein SELF-SIGNED-Zertifikat ab,
|
||||
# damit STARTTLS sofort funktioniert. Für ein echtes Zertifikat einfach diese
|
||||
# zwei Dateien durch das NPM-Let's-Encrypt-Cert ersetzen (cert.pem=fullchain,
|
||||
# key.pem=privkey) und `docker compose restart mailserver` — KEINE Änderung hier.
|
||||
# Siehe README, Abschnitt "TLS mit Nginx Proxy Manager".
|
||||
SSL_TYPE=manual
|
||||
SSL_CERT_PATH=/etc/letsencrypt/cert.pem
|
||||
SSL_KEY_PATH=/etc/letsencrypt/key.pem
|
||||
|
||||
# --- Spam / Antivirus ---
|
||||
ENABLE_RSPAMD=1
|
||||
RSPAMD_GREYLISTING=1
|
||||
ENABLE_OPENDKIM=0
|
||||
ENABLE_OPENDMARC=0
|
||||
ENABLE_CLAMAV=0
|
||||
ENABLE_FAIL2BAN=1
|
||||
|
||||
# --- Sicherheit / Submission ---
|
||||
ONE_DIR=1
|
||||
PERMIT_DOCKER=none
|
||||
SPOOF_PROTECTION=1
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user