#!/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 /dms-lxc.sh) # Privates Repo: GIT_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 <} 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" </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" < "$TMP/stack/docker-data/dms/config/rspamd/override.d/worker-controller.inc" < 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 <}:${ADMIN_PORT} (Supabase-Login) Webmail ............ http://${CT_IP:-}:${WEBMAIL_PORT} (SnappyMail) Rspamd-UI .......... http://${CT_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:-}:${ADMIN_PORT} mail.${MAIL_DOMAIN} -> ${CT_IP:-}:${WEBMAIL_PORT} rspamd.${MAIL_DOMAIN} -> ${CT_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 <