Files
archivmail/install.sh
T
sysops af897948be fix(PROJ-68): CapabilityBoundingSet blockiert sudo trotz korrekter sudoers-Regel
QA-Re-Diagnose auf 132 fand einen zweiten, unabhängigen Blocker: die
archivmail.service-Unit setzt CapabilityBoundingSet=CAP_NET_BIND_SERVICE
CAP_NET_ADMIN. Das kappt das Capability-Set des GESAMTEN Prozessbaums
inklusive der von exec.Command("sudo", ...) geforkten Kindprozesse - sudo
braucht aber zusaetzlich CAP_SETUID/CAP_SETGID um auf uid/gid 0 zu
wechseln, sonst bricht es ab ("unable to change to root gid: Operation not
permitted"), selbst wenn sudoers korrekt konfiguriert ist.

Ein erster Verifikationsversuch per `runuser -u archivmail -- sudo ...`
hatte faelschlich Erfolg gemeldet, weil runuser den restriktiven
Bounding-Set der Unit nicht traegt - nur ein Test im echten
systemd-Unit-Kontext (systemd-run oder echter API-Call) ist aussagekraeftig.

Fix: CapabilityBoundingSet in update.sh und install.sh um CAP_SETUID
CAP_SETGID erweitert. AmbientCapabilities bleibt unveraendert (der Go-Daemon
selbst soll diese Rechte nie dauerhaft halten, nur der sudo-Kindprozess
braucht sie im Bounding-Set verfuegbar).
2026-07-05 20:10:30 +02:00

904 lines
41 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# archivmail Installer
# Unterstütztes System: Debian 13 (trixie)
#
# Aufruf:
# bash install.sh # Interaktiv (Modus-Auswahl)
# INSTALL_MODE=native bash install.sh # Nativ (systemd, direkter Build)
# INSTALL_MODE=docker bash install.sh # Docker (alle Abhängigkeiten im Container)
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[OK]${NC} $*"; }
info() { echo -e "${BLUE}[..]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
die() { echo -e "${RED}[ERR]${NC} $*" >&2; exit 1; }
# PROJ-68: Provisioniert sudo-NOPASSWD-Rechte für die Admin-Dienststeuerung
# (Start/Stop/Restart/Enable/Disable über den "Dienste"-Tab). Muss mit
# `allowedServices` in internal/api/admin_services_handlers.go synchron
# gehalten werden. Installiert `sudo` falls fehlend, validiert die generierte
# Datei per `visudo -c` VOR dem Einspielen — eine kaputte sudoers-Datei würde
# sonst den gesamten sudo-Mechanismus auf dem Server lahmlegen. Idempotent:
# überschreibt die Datei bei jedem Lauf mit demselben, deterministischen
# Inhalt (kein Diff-Merge nötig, da wir hier die einzige Quelle dieser Regeln
# sind — ein manuell abweichend konfiguriertes System würde überschrieben,
# das ist so beabsichtigt, damit Code und Server nie auseinanderlaufen).
provision_archivmail_sudoers() {
local am_user="${1:-$AM_USER}"
command -v sudo >/dev/null 2>&1 || apt-get install -y -qq sudo
local services=("archivmail" "archivmail-web" "manticore" "postgresql@17-main" "postfix" "nginx")
local tmpfile
tmpfile="$(mktemp)"
{
echo "# Verwaltet von install.sh/update.sh (PROJ-68) — nicht manuell editieren,"
echo "# Änderungen gehen beim nächsten Deploy verloren. Whitelist muss mit"
echo "# allowedServices in internal/api/admin_services_handlers.go übereinstimmen."
for svc in "${services[@]}"; do
echo "${am_user} ALL=(root) NOPASSWD: /usr/bin/systemctl start ${svc}.service"
echo "${am_user} ALL=(root) NOPASSWD: /usr/bin/systemctl stop ${svc}.service"
echo "${am_user} ALL=(root) NOPASSWD: /usr/bin/systemctl restart ${svc}.service"
echo "${am_user} ALL=(root) NOPASSWD: /usr/bin/systemctl enable ${svc}.service"
echo "${am_user} ALL=(root) NOPASSWD: /usr/bin/systemctl disable ${svc}.service"
done
} > "$tmpfile"
if visudo -c -f "$tmpfile" >/dev/null 2>&1; then
install -m 0440 -o root -g root "$tmpfile" /etc/sudoers.d/archivmail
log "sudo-Rechte für Dienststeuerung eingerichtet (/etc/sudoers.d/archivmail)"
else
warn "Generierte sudoers-Regel ist ungültig — /etc/sudoers.d/archivmail NICHT verändert"
fi
rm -f "$tmpfile"
}
# PROJ-66: Ein Backup (App-Backup oder PBS/Snapshot) ist ohne dieses Keyfile
# wertlos — der Store bleibt Chiffretext. Der Inhalt wird EINMALIG bei der
# Generierung ausgegeben, damit er sofort in einen Passwort-Safe (z.B.
# Bitwarden Secure Note, siehe features/PROJ-66-backup-strategie.md) verschoben
# werden kann, statt sich später darauf zu verlassen, dass irgendein Backup
# das Keyfile schon mitgesichert hat.
print_keyfile_escrow_warning() {
local keyfile_path="$1"
echo
warn "WICHTIG — Keyfile jetzt sichern (Restore ist ohne dieses Keyfile unmöglich):"
echo -e "${YELLOW}$(cat "$keyfile_path")${NC}"
warn "Diesen Wert in einen separaten Passwort-Safe kopieren (z.B. Bitwarden Secure"
warn "Note, eigene Collection mit eingeschränktem Zugriff) — unabhängig von jedem"
warn "Server-Backup. Ohne Keyfile sind gesicherte Mail-Dateien nicht entschlüsselbar."
echo
}
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen: sudo bash install.sh"
# ── OS-Prüfung: nur Debian 13 (trixie) ──────────────────────────────────────
_os_id=$(. /etc/os-release 2>/dev/null && echo "$ID" || echo "")
_os_ver=$(. /etc/os-release 2>/dev/null && echo "$VERSION_ID" || echo "")
[[ "$_os_id" == "debian" && "$_os_ver" == "13" ]] \
|| die "Nur Debian 13 (trixie) wird unterstützt (erkannt: ${_os_id} ${_os_ver})"
log "Debian 13 erkannt"
# ── Gemeinsame Variablen ──────────────────────────────────────────────────────
DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)}"
API_SECRET="${API_SECRET:-$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 64)}"
WEBHOOK_SECRET="${WEBHOOK_SECRET:-$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 40)}"
INSTALL_DIR="/opt/archivmail"
STORE_DIR="/var/archivmail"
LOG_DIR="/var/log/archivmail"
CONFIG_DIR="/etc/archivmail"
SSL_DIR="/etc/ssl/archivmail"
AM_USER="archivmail"
FQDN="$(hostname -f 2>/dev/null || hostname)"
REPO_URL="${REPO_URL:-https://gitea.perlbach24.de/scripte/archivmail.git}"
# ── Banner ────────────────────────────────────────────────────────────────────
echo ""
echo " ╔══════════════════════════════════════════╗"
echo " ║ archivmail Installer v2.0 ║"
echo " ╚══════════════════════════════════════════╝"
echo ""
info "Hostname: $FQDN"
echo ""
# ── Modus-Auswahl ─────────────────────────────────────────────────────────────
INSTALL_MODE="${INSTALL_MODE:-}"
if [[ -z "$INSTALL_MODE" ]]; then
echo " Installations-Modus wählen:"
echo ""
echo " 1) Nativ — Go/Node.js direkt auf dem Server kompiliert und gebaut"
echo " (systemd-Dienste, PostgreSQL lokal, kein Docker nötig)"
echo ""
echo " 2) Docker — Alle Abhängigkeiten (Go, Node.js, PostgreSQL, Manticore)"
echo " laufen in Containern. Kein Build-Toolchain auf dem Host."
echo " Deployment via GitHub Webhook (git pull → docker compose up)"
echo ""
read -rp " Auswahl [1/2]: " _choice
case "$_choice" in
1) INSTALL_MODE="native" ;;
2) INSTALL_MODE="docker" ;;
*) die "Ungültige Auswahl: $_choice" ;;
esac
fi
[[ "$INSTALL_MODE" == "native" || "$INSTALL_MODE" == "docker" ]] \
|| die "INSTALL_MODE muss 'native' oder 'docker' sein (aktuell: $INSTALL_MODE)"
echo ""
info "Modus: $INSTALL_MODE"
echo ""
# ══════════════════════════════════════════════════════════════════════════════
# DOCKER-INSTALLATION
# ══════════════════════════════════════════════════════════════════════════════
install_docker() {
# ── Docker installieren ───────────────────────────────────────────────────
info "Installiere Docker..."
if command -v docker &>/dev/null; then
log "Docker bereits installiert: $(docker --version)"
else
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$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
systemctl enable --now docker
log "Docker $(docker --version) installiert"
fi
# ── Hilfspakete ───────────────────────────────────────────────────────────
info "Installiere Hilfspakete..."
apt-get install -y -qq git curl nginx openssl logrotate
log "Pakete installiert"
# ── webhook-Binary (adnanh/webhook) ──────────────────────────────────────
info "Installiere webhook-Empfänger..."
if ! command -v webhook &>/dev/null; then
WEBHOOK_BIN_VERSION="2.8.2"
_arch="$(dpkg --print-architecture)"
[[ "$_arch" == "amd64" ]] && _arch="linux_amd64"
[[ "$_arch" == "arm64" ]] && _arch="linux_arm64"
curl -fsSL \
"https://github.com/adnanh/webhook/releases/download/${WEBHOOK_BIN_VERSION}/webhook-${_arch}.tar.gz" \
| tar -xz -C /usr/local/bin --strip-components=1 "webhook-${_arch}/webhook"
chmod +x /usr/local/bin/webhook
log "webhook ${WEBHOOK_BIN_VERSION} installiert"
else
log "webhook bereits installiert"
fi
# ── Verzeichnisse ─────────────────────────────────────────────────────────
info "Erstelle Verzeichnisse..."
mkdir -p "$CONFIG_DIR" "$LOG_DIR"
log "Verzeichnisse erstellt"
# ── Repository klonen ─────────────────────────────────────────────────────
info "Klone Repository..."
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository existiert bereits — git pull"
git -C "$INSTALL_DIR" pull origin main
else
git clone "$REPO_URL" "$INSTALL_DIR"
log "Repository geklont: $INSTALL_DIR"
fi
# ── Keyfile ───────────────────────────────────────────────────────────────
info "Generiere Verschlüsselungs-Keyfile..."
if [[ ! -f "$CONFIG_DIR/keyfile" ]]; then
openssl rand -base64 32 > "$CONFIG_DIR/keyfile"
chmod 400 "$CONFIG_DIR/keyfile"
log "Keyfile generiert: $CONFIG_DIR/keyfile"
print_keyfile_escrow_warning "$CONFIG_DIR/keyfile"
else
log "Keyfile existiert bereits wird nicht überschrieben"
fi
# ── TLS-Zertifikat ────────────────────────────────────────────────────────
info "Erstelle selbstsigniertes TLS-Zertifikat..."
mkdir -p "$SSL_DIR"
if [[ ! -f "$SSL_DIR/archivmail.crt" ]]; then
SERVER_IP="$(hostname -I | awk '{print $1}')"
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \
-keyout "$SSL_DIR/archivmail.key" \
-out "$SSL_DIR/archivmail.crt" \
-subj "/CN=${FQDN}/O=archivmail/C=DE" \
-addext "subjectAltName=DNS:${FQDN},DNS:$(hostname -s),IP:${SERVER_IP}" \
2>/dev/null
chmod 640 "$SSL_DIR/archivmail.key"
chmod 644 "$SSL_DIR/archivmail.crt"
log "TLS-Zertifikat erstellt"
else
log "TLS-Zertifikat existiert bereits wird nicht überschrieben"
fi
# ── config.yml aus Vorlage ────────────────────────────────────────────────
info "Erstelle Konfigurationsdatei..."
if [[ ! -f "$CONFIG_DIR/config.yml" ]]; then
sed \
-e "s/CHANGE_ME_DB_PASSWORD/$DB_PASSWORD/g" \
-e "s/CHANGE_ME_64_CHAR_SECRET/$API_SECRET/g" \
-e "s/mail\.example\.com/$FQDN/g" \
"$INSTALL_DIR/config/config.docker.yml.example" \
> "$CONFIG_DIR/config.yml"
chmod 640 "$CONFIG_DIR/config.yml"
log "config.yml erstellt: $CONFIG_DIR/config.yml"
else
log "config.yml existiert bereits wird nicht überschrieben"
fi
# ── .env für docker-compose ───────────────────────────────────────────────
info "Erstelle .env..."
if [[ ! -f "$INSTALL_DIR/.env" ]]; then
printf 'DB_PASSWORD=%s\n' "$DB_PASSWORD" > "$INSTALL_DIR/.env"
chmod 600 "$INSTALL_DIR/.env"
log ".env erstellt"
else
log ".env existiert bereits wird nicht überschrieben"
fi
# ── deploy.sh ausführbar ──────────────────────────────────────────────────
chmod +x "$INSTALL_DIR/scripts/deploy.sh"
# ── logrotate ─────────────────────────────────────────────────────────────
cat > /etc/logrotate.d/archivmail <<EOF
${LOG_DIR}/deploy.log {
weekly
rotate 12
compress
delaycompress
missingok
notifempty
}
EOF
log "logrotate konfiguriert"
# ── webhook systemd-Dienst ────────────────────────────────────────────────
info "Richte webhook-Dienst ein..."
cat > /etc/systemd/system/archivmail-webhook.service <<EOF
[Unit]
Description=archivmail GitHub Webhook Receiver
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
Environment="WEBHOOK_SECRET=$WEBHOOK_SECRET"
Environment="INSTALL_DIR=$INSTALL_DIR"
ExecStart=/usr/local/bin/webhook \\
-hooks $INSTALL_DIR/scripts/webhook-hooks.json \\
-port 9000 \\
-ip 127.0.0.1 \\
-verbose
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now archivmail-webhook
log "archivmail-webhook Dienst gestartet"
# ── nginx ─────────────────────────────────────────────────────────────────
info "Konfiguriere nginx..."
cat > /etc/nginx/sites-available/archivmail <<EOF
# HTTP → HTTPS Redirect
server {
listen 80;
server_name ${FQDN} _;
return 301 https://\$host\$request_uri;
}
# HTTPS
server {
listen 443 ssl;
http2 on;
server_name ${FQDN} _;
ssl_certificate ${SSL_DIR}/archivmail.crt;
ssl_certificate_key ${SSL_DIR}/archivmail.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# GitHub Webhook
location /hooks/ {
proxy_pass http://127.0.0.1:9000/hooks/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_read_timeout 30s;
}
# Go API
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
client_max_body_size 512M;
}
# Next.js Frontend
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
access_log /var/log/nginx/archivmail.access.log;
error_log /var/log/nginx/archivmail.error.log;
}
EOF
ln -sf /etc/nginx/sites-available/archivmail /etc/nginx/sites-enabled/archivmail
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl enable --now nginx && systemctl reload nginx
log "nginx konfiguriert"
# ── Docker Compose Stack starten ──────────────────────────────────────────
info "Baue und starte Docker Compose Stack..."
cd "$INSTALL_DIR"
docker compose up -d --build
log "Stack gestartet"
# ── Zusammenfassung speichern ─────────────────────────────────────────────
local summary_file="$CONFIG_DIR/install-summary.txt"
cat > "$summary_file" <<EOF
archivmail Docker-Installation — $(date '+%d.%m.%Y %H:%M:%S')
Server: $FQDN
=== ZUGANGSDATEN ===
Datenbank (PostgreSQL im Container):
Passwort: $DB_PASSWORD
API-Secret (JWT + AES):
Secret: $API_SECRET
GitHub Webhook:
Secret: $WEBHOOK_SECRET
Web-Logins werden beim ersten Container-Start angelegt.
Passwörter: docker logs archivmail | grep -E 'admin|auditor'
=== DIENSTE ===
Web: https://$FQDN
SMTP: $FQDN:2525
IMAP: $FQDN:1143 (nginx → TLS)
=== GITHUB WEBHOOK EINRICHTEN ===
URL: https://$FQDN/hooks/deploy
Content-Type: application/json
Secret: $WEBHOOK_SECRET
Events: Just the push event (refs/heads/main)
=== DATEIPFADE ===
Konfiguration: $CONFIG_DIR/config.yml
Keyfile: $CONFIG_DIR/keyfile (NIEMALS löschen!)
TLS-Cert: $SSL_DIR/archivmail.crt
Repository: $INSTALL_DIR
Deploy-Log: $LOG_DIR/deploy.log
=== NÜTZLICHE BEFEHLE ===
docker compose -f $INSTALL_DIR/docker-compose.yml logs -f
docker compose -f $INSTALL_DIR/docker-compose.yml ps
bash $INSTALL_DIR/scripts/deploy.sh # Manueller Deploy
EOF
chmod 600 "$summary_file"
log "Zusammenfassung: $summary_file"
# ── Abschluss ─────────────────────────────────────────────────────────────
echo ""
echo " ╔══════════════════════════════════════════════════════════════╗"
echo " ║ Docker-Installation abgeschlossen! ║"
echo " ╚══════════════════════════════════════════════════════════════╝"
echo ""
log "archivmail läuft unter: https://$FQDN"
echo ""
warn "GitHub Webhook jetzt einrichten:"
echo " URL: https://$FQDN/hooks/deploy"
echo " Content-Type: application/json"
echo " Secret: $WEBHOOK_SECRET"
echo " Events: Push (nur main-Branch)"
echo ""
warn "Initial-Passwörter: docker logs archivmail | grep -E 'admin|auditor'"
warn "Zusammenfassung: $CONFIG_DIR/install-summary.txt"
echo ""
}
# ══════════════════════════════════════════════════════════════════════════════
# NATIVE INSTALLATION (systemd, direkter Build)
# ══════════════════════════════════════════════════════════════════════════════
install_native() {
# ── 1. Basispakete (kein golang-go — zu alt in Debian-Repos) ────────────
info "Installiere Systempakete..."
apt-get update -qq
apt-get install -y -qq \
nodejs npm postgresql nginx \
curl wget git rsync logrotate openssl ca-certificates
log "Pakete installiert"
# ── 1b. Go 1.24 von upstream (apt-Paket ist zu alt) ──────────────────────
GO_VERSION="1.24.4"
info "Installiere Go ${GO_VERSION} von upstream..."
if /usr/local/go/bin/go version 2>/dev/null | grep -q "go${GO_VERSION}"; then
log "Go ${GO_VERSION} bereits installiert"
else
_go_arch="amd64"
[[ "$(dpkg --print-architecture)" == "arm64" ]] && _go_arch="arm64"
curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${_go_arch}.tar.gz" \
| tar -C /usr/local -xz
log "Go $(/usr/local/go/bin/go version | awk '{print $3}') installiert"
fi
export PATH="$PATH:/usr/local/go/bin"
ln -sf /usr/local/go/bin/go /usr/local/bin/go
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
# ── 1c. Manticore Search ──────────────────────────────────────────────────
# Kein natives trixie-Repo (siehe update.sh für Details) — bookworm-Paket
# ist lauffähig. Seit ~v25 ein einziges "manticore"-Paket statt der
# früheren manticoresearch_*-Aufteilung; GitHub-Releases liefern keine
# .deb-Assets mehr, daher kein Fallback mehr dorthin. Version wird dynamisch
# aus der Packages-Datei des Repos gelesen (letzter "Package: manticore"-
# Block); _MANTICORE_VERSION_FALLBACK greift nur, wenn die Packages-Datei
# nicht erreichbar/parsbar ist.
_MANTICORE_VERSION_FALLBACK="27.1.5-26061911-5a1cf9399"
_mc_installed_version="$(dpkg-query -W -f='${Version}' manticore 2>/dev/null || true)"
_mc_arch="$(dpkg --print-architecture)"
_mc_latest_from_repo() {
local base="https://repo.manticoresearch.com/repository/manticoresearch_bookworm"
local packages block version filename
packages="$(curl -fsSL --max-time 15 "${base}/dists/bookworm/main/binary-${_mc_arch}/Packages" 2>/dev/null)" || return 1
[[ -n "$packages" ]] || return 1
block="$(printf '%s\n' "$packages" | awk 'BEGIN{RS="\n\n"} {n=split($0,l,"\n"); if (l[1]=="Package: manticore") b=$0} END{print b}')"
[[ -n "$block" ]] || return 1
version="$(printf '%s\n' "$block" | awk -F': ' '/^Version:/{print $2; exit}')"
filename="$(printf '%s\n' "$block" | awk -F': ' '/^Filename:/{print $2; exit}')"
[[ -n "$version" && -n "$filename" ]] || return 1
printf '%s|%s\n' "$version" "$filename"
}
_mc_target_version="$_MANTICORE_VERSION_FALLBACK"
_mc_target_url="https://repo.manticoresearch.com/repository/manticoresearch_bookworm/dists/bookworm/main/binary-${_mc_arch}/manticore_${_MANTICORE_VERSION_FALLBACK}_${_mc_arch}.deb"
if _mc_latest="$(_mc_latest_from_repo)"; then
_mc_target_version="${_mc_latest%%|*}"
_mc_target_url="https://repo.manticoresearch.com/repository/manticoresearch_bookworm/${_mc_latest#*|}"
info "Manticore Search: aktuelle Repo-Version ${_mc_target_version} ermittelt"
else
warn "Manticore-Repo-Metadaten nicht abrufbar — falle auf bekannten Stand ${_MANTICORE_VERSION_FALLBACK} zurück"
fi
_mc_fetch_and_install() {
wget -q -O /tmp/manticore.deb "$_mc_target_url" || true
if [[ ! -f /tmp/manticore.deb ]]; then
return 1
fi
dpkg -i /tmp/manticore.deb 2>/dev/null || apt-get install -f -y -qq 2>/dev/null || true
rm -f /tmp/manticore.deb
return 0
}
if [[ -z "$_mc_installed_version" ]]; then
info "Installiere Manticore Search ${_mc_target_version}..."
_mc_fetch_and_install && log "Manticore Search installiert" \
|| die "Manticore Search konnte nicht installiert werden — siehe: https://manticoresearch.com/install/"
elif dpkg --compare-versions "$_mc_installed_version" lt "$_mc_target_version"; then
# PROJ-67: Upgrade statt Skip, damit auch Bestandsinstallationen beim
# nächsten install.sh-/update.sh-Lauf mitgezogen werden. "lt"-Vergleich
# schützt gegen versehentliches Downgrade.
info "Manticore Search ${_mc_installed_version} gefunden, aktualisiere auf ${_mc_target_version}..."
systemctl stop manticore 2>/dev/null || true
_mc_fetch_and_install \
&& log "Manticore Search aktualisiert: ${_mc_installed_version}${_mc_target_version}" \
|| warn "Upgrade fehlgeschlagen — vorherige Version bleibt installiert"
else
log "Manticore Search ${_mc_installed_version} bereits aktuell (>= ${_mc_target_version})"
fi
systemctl enable --now manticore 2>/dev/null || warn "Manticore-Dienst konnte nicht gestartet werden"
systemctl is-active --quiet manticore && log "Manticore Search läuft"
# ── 2. Systembenutzer ─────────────────────────────────────────────────────
info "Lege Systembenutzer '$AM_USER' an..."
id "$AM_USER" &>/dev/null \
&& log "Benutzer '$AM_USER' existiert bereits" \
|| { useradd --system --shell /bin/false --home "$STORE_DIR" --create-home "$AM_USER"; log "Benutzer angelegt"; }
# ── 2b. sudo-Rechte für die Admin-Dienststeuerung (PROJ-68) ────────────────
# internal/api/admin_services_handlers.go ruft für Start/Stop/Restart/
# Enable/Disable IMMER `sudo systemctl ...` auf — ohne diese Provisionierung
# schlägt jede Dienst-Aktion aus dem Admin-"Dienste"-Tab mit einem
# schwer diagnostizierbaren HTTP 500 fehl (sudo: command not found).
# Whitelist MUSS mit `allowedServices` in admin_services_handlers.go
# synchron bleiben.
provision_archivmail_sudoers
# ── 3. Verzeichnisstruktur ────────────────────────────────────────────────
info "Erstelle Verzeichnisstruktur..."
mkdir -p "$STORE_DIR/store" "$STORE_DIR/astore"
mkdir -p "$CONFIG_DIR" "$LOG_DIR" "$INSTALL_DIR" "$INSTALL_DIR/web" "$SSL_DIR"
chown -R "$AM_USER:$AM_USER" "$STORE_DIR" "$LOG_DIR"
chmod 755 "$STORE_DIR"
chmod 700 "$STORE_DIR/store" "$STORE_DIR/astore"
log "Verzeichnisse erstellt"
# ── 4. Keyfile ────────────────────────────────────────────────────────────
info "Generiere Verschlüsselungs-Keyfile..."
if [ ! -f "$CONFIG_DIR/keyfile" ]; then
openssl rand -base64 32 > "$CONFIG_DIR/keyfile"
chmod 400 "$CONFIG_DIR/keyfile"
chown "$AM_USER:$AM_USER" "$CONFIG_DIR/keyfile"
log "Keyfile generiert: $CONFIG_DIR/keyfile"
print_keyfile_escrow_warning "$CONFIG_DIR/keyfile"
else
log "Keyfile existiert bereits wird nicht überschrieben"
fi
# ── 5. TLS-Zertifikat ─────────────────────────────────────────────────────
info "Erstelle selbstsigniertes TLS-Zertifikat..."
if [ ! -f "$SSL_DIR/archivmail.crt" ]; then
SERVER_IP="$(hostname -I | awk '{print $1}')"
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \
-keyout "$SSL_DIR/archivmail.key" \
-out "$SSL_DIR/archivmail.crt" \
-subj "/CN=${FQDN}/O=archivmail/C=DE" \
-addext "subjectAltName=DNS:${FQDN},DNS:$(hostname -s),IP:${SERVER_IP}" \
2>/dev/null
chmod 640 "$SSL_DIR/archivmail.key"
chmod 644 "$SSL_DIR/archivmail.crt"
chown "root:$AM_USER" "$SSL_DIR/archivmail.key"
log "TLS-Zertifikat erstellt: $SSL_DIR/archivmail.crt (FQDN: $FQDN, IP: $SERVER_IP)"
else
log "TLS-Zertifikat existiert bereits wird nicht überschrieben"
fi
# ── 6. PostgreSQL ─────────────────────────────────────────────────────────
info "Richte PostgreSQL ein..."
systemctl enable postgresql --quiet
systemctl start postgresql
# Falls config.yml schon existiert, DB-Passwort daraus lesen (verhindert Passwort-Mismatch bei Re-Install)
if [ -f "$CONFIG_DIR/config.yml" ]; then
EXISTING_PW=$(grep -A5 '^database:' "$CONFIG_DIR/config.yml" | awk '/password:/{print $2}' | head -1)
[[ -n "$EXISTING_PW" ]] && DB_PASSWORD="$EXISTING_PW" && info "DB-Passwort aus vorhandener config.yml übernommen"
fi
su -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='archivmail'\" | grep -q 1 \
&& psql -c \"ALTER USER archivmail WITH PASSWORD '$DB_PASSWORD'\" \
|| psql -c \"CREATE USER archivmail WITH PASSWORD '$DB_PASSWORD'\"" postgres
su -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='archivmail'\" | grep -q 1 || \
psql -c \"CREATE DATABASE archivmail OWNER archivmail\"" postgres
su -c "psql archivmail -c \"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO archivmail;\"" postgres
su -c "psql archivmail -c \"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO archivmail;\"" postgres
su -c "psql archivmail -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO archivmail;\"" postgres
su -c "psql archivmail -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO archivmail;\"" postgres
log "PostgreSQL eingerichtet"
# ── 7. Konfiguration ──────────────────────────────────────────────────────
info "Erstelle Konfigurationsdatei..."
if [ ! -f "$CONFIG_DIR/config.yml" ]; then
cat > "$CONFIG_DIR/config.yml" << CONFIG
# archivmail Konfiguration generiert am $(date -u +%Y-%m-%dT%H:%M:%SZ)
server:
api_port: 8080
smtp_port: 2525
database:
host: 127.0.0.1
port: 5432
name: archivmail
user: archivmail
password: ${DB_PASSWORD}
sslmode: disable
storage:
store_path: ${STORE_DIR}/store
astore_path: ${STORE_DIR}/astore
keyfile: ${CONFIG_DIR}/keyfile
api:
bind: ":8080"
secret: ${API_SECRET}
trusted_proxies:
- 127.0.0.1
secure_cookies: true
index:
backend: manticore
manticore_dsn: "manticore@tcp(127.0.0.1:9306)/"
batch_size: 100
batch_mode: true
ocr:
batch_mode: true
audit:
log_path: ${LOG_DIR}/audit.log
retention_days: 0
smtp:
enabled: true
bind: ":2525"
domain: "${FQDN}"
tls_cert: ${SSL_DIR}/archivmail.crt
tls_key: ${SSL_DIR}/archivmail.key
allowed_ips:
- 127.0.0.1
imap_server:
enabled: true
bind: ":993"
tls_cert: ${SSL_DIR}/archivmail.crt
tls_key: ${SSL_DIR}/archivmail.key
CONFIG
chmod 640 "$CONFIG_DIR/config.yml"
chown "root:$AM_USER" "$CONFIG_DIR/config.yml"
log "Konfiguration erstellt: $CONFIG_DIR/config.yml"
else
log "config.yml existiert bereits wird nicht überschrieben"
fi
# ── 8. Nginx ──────────────────────────────────────────────────────────────
info "Konfiguriere nginx (HTTP → HTTPS + TLS)..."
cat > /etc/nginx/sites-available/archivmail << NGINX
# HTTP → HTTPS Redirect
server {
listen 80;
server_name ${FQDN} _;
return 301 https://\$host\$request_uri;
}
# HTTPS
server {
listen 443 ssl;
http2 on;
server_name ${FQDN} _;
ssl_certificate ${SSL_DIR}/archivmail.crt;
ssl_certificate_key ${SSL_DIR}/archivmail.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
client_max_body_size 512M;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
}
access_log /var/log/nginx/archivmail.access.log;
error_log /var/log/nginx/archivmail.error.log;
}
NGINX
ln -sf /etc/nginx/sites-available/archivmail /etc/nginx/sites-enabled/archivmail
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl enable nginx --quiet
systemctl restart nginx
log "nginx konfiguriert"
# ── 9. logrotate ──────────────────────────────────────────────────────────
cat > /etc/logrotate.d/archivmail << LOGROTATE
${LOG_DIR}/audit.log {
daily
rotate 365
compress
delaycompress
missingok
notifempty
create 640 ${AM_USER} ${AM_USER}
}
LOGROTATE
log "logrotate konfiguriert"
# ── 10. systemd Units ─────────────────────────────────────────────────────
info "Erstelle systemd Units..."
cat > /etc/systemd/system/archivmail.service << UNIT
[Unit]
Description=archivmail Mail Archive Daemon
After=network.target postgresql.service manticore.service
Requires=postgresql.service manticore.service
[Service]
Type=simple
User=${AM_USER}
Group=${AM_USER}
# CAP_NET_ADMIN: required for the admin "enable firewall" action (nft -f /etc/nftables.conf)
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
# PROJ-68: CAP_SETUID/CAP_SETGID are NOT ambient (the Go daemon itself must
# never hold them) but must be in the bounding set so the setuid-root sudo
# child process (spawned by the admin "Dienste"-Tab for systemctl actions)
# can actually change to uid/gid 0. Without this, sudo aborts with
# "unable to change to root gid: Operation not permitted" even though the
# sudoers NOPASSWD rule is correct — verified 2026-07-05, see
# features/PROJ-68-sudo-provisionierung-dienststeuerung.md.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CAP_SETUID CAP_SETGID
ExecStart=${INSTALL_DIR}/archivmail --config ${CONFIG_DIR}/config.yml
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=archivmail
NoNewPrivileges=false
ProtectSystem=strict
ReadWritePaths=${STORE_DIR} ${LOG_DIR}
ReadOnlyPaths=${CONFIG_DIR} ${SSL_DIR}
[Install]
WantedBy=multi-user.target
UNIT
cat > /etc/systemd/system/archivmail-web.service << UNIT
[Unit]
Description=archivmail Web Frontend
After=network.target archivmail.service
[Service]
Type=simple
User=${AM_USER}
Group=${AM_USER}
WorkingDirectory=${INSTALL_DIR}/web
ExecStart=/usr/bin/node server.js
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=archivmail-web
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable archivmail archivmail-web --quiet
log "systemd Units erstellt und aktiviert"
# ── 11. Erstes Deployment via update.sh ───────────────────────────────────
info "Installiere update.sh und führe erstes Deployment durch..."
_update_url="${REPO_URL/\.git/}/raw/branch/main/update.sh"
curl -fsSL "$_update_url" -o "$INSTALL_DIR/update.sh" 2>/dev/null \
|| { warn "update.sh konnte nicht geladen werden überspringe Deployment"; }
if [ -f "$INSTALL_DIR/update.sh" ]; then
chmod +x "$INSTALL_DIR/update.sh"
log "update.sh installiert"
info "Führe erstes Deployment durch..."
bash "$INSTALL_DIR/update.sh"
for _i in $(seq 1 15); do
_pw=$(journalctl -u archivmail --no-pager -n 100 | grep 'admin' | grep -oP ':\s+\K[0-9a-f]{16,}' | tail -1)
[[ -n "$_pw" ]] && break
sleep 1
done
PW_SUPERADMIN=$(journalctl -u archivmail --no-pager -n 100 | grep 'superadmin' | grep -oP ':\s+\K\S+' | tail -1)
PW_ADMIN=$(journalctl -u archivmail --no-pager -n 100 | grep ' admin ' | grep -v superadmin | grep -oP ':\s+\K\S+' | tail -1)
PW_AUDITOR=$(journalctl -u archivmail --no-pager -n 100 | grep 'auditor' | grep -oP ':\s+\K\S+' | tail -1)
[[ -n "$PW_ADMIN" ]] || PW_ADMIN="(nicht gefunden — journalctl -u archivmail | grep admin)"
[[ -n "$PW_AUDITOR" ]] || PW_AUDITOR="(nicht gefunden — journalctl -u archivmail | grep auditor)"
[[ -n "$PW_SUPERADMIN" ]] || PW_SUPERADMIN="(nicht gefunden — journalctl -u archivmail | grep superadmin)"
else
warn "update.sh fehlt — manuell: bash $INSTALL_DIR/update.sh"
PW_SUPERADMIN="(noch nicht generiert)"; PW_ADMIN="(noch nicht generiert)"; PW_AUDITOR="(noch nicht generiert)"
fi
# ── Zusammenfassung ───────────────────────────────────────────────────────
SUMMARY_FILE="$CONFIG_DIR/install-summary.txt"
cat > "$SUMMARY_FILE" << SUMMARY
archivmail Native-Installation — $(date '+%d.%m.%Y %H:%M:%S')
Server: $FQDN
=== ZUGANGSDATEN ===
Datenbank: archivmail / $DB_PASSWORD
API-Secret: $API_SECRET
Web-Logins (UNBEDINGT ÄNDERN!):
superadmin@archivmail / $PW_SUPERADMIN
admin@archivmail / $PW_ADMIN
auditor@archivmail / $PW_AUDITOR
=== DIENSTE ===
Web (HTTPS): https://$FQDN
IMAP (TLS): $FQDN:993
SMTP: $FQDN:2525
=== DATEIPFADE ===
Konfiguration: $CONFIG_DIR/config.yml
Keyfile: $CONFIG_DIR/keyfile
TLS-Zertifikat: $SSL_DIR/archivmail.crt
Mail-Speicher: $STORE_DIR/
Logs: $LOG_DIR/
Updater: $INSTALL_DIR/update.sh
SUMMARY
chmod 600 "$SUMMARY_FILE"
log "Zusammenfassung: $SUMMARY_FILE"
# ── Abschluss ─────────────────────────────────────────────────────────────
echo ""
echo " ╔══════════════════════════════════════════════════════════╗"
echo " ║ Installation abgeschlossen! ║"
echo " ╚══════════════════════════════════════════════════════════╝"
echo ""
echo " Web (HTTPS): https://$FQDN"
echo " IMAP (TLS): $FQDN:993"
echo " SMTP: $FQDN:2525"
echo ""
echo " ┌─────────────────────────────────────────────────────────┐"
echo " │ ANGELEGTE BENUTZER & PASSWÖRTER │"
echo " ├─────────────────────────────────────────────────────────┤"
printf " │ DB archivmail: %-38s │\n" "$DB_PASSWORD"
echo " ├─────────────────────────────────────────────────────────┤"
echo " │ Web-Logins (UNBEDINGT ÄNDERN!): │"
printf " │ superadmin / %-38s│\n" "$PW_SUPERADMIN"
printf " │ admin / %-38s│\n" "$PW_ADMIN"
printf " │ auditor / %-38s│\n" "$PW_AUDITOR"
echo " └─────────────────────────────────────────────────────────┘"
echo ""
warn "Zusammenfassung mit Passwörtern: $SUMMARY_FILE"
warn "Standardpasswörter unbedingt nach dem ersten Login ändern!"
echo ""
}
# ══════════════════════════════════════════════════════════════════════════════
# MODUS AUSFÜHREN
# ══════════════════════════════════════════════════════════════════════════════
case "$INSTALL_MODE" in
docker) install_docker ;;
native) install_native ;;
esac