#!/bin/sh
# SMTPly MSP: Installation von Node und Fleet mit Docker Compose.
#
# curl -fsSL https://msp.smtply.app/install.sh | sh
# curl -fsSL https://msp.smtply.app/install.sh -o install.sh && less install.sh && sh install.sh --help
#
# Empfohlen: erst herunterladen, lesen, dann ausführen. Beim Aufruf über eine Pipe liest das Skript Eingaben von
# /dev/tty. Ohne Terminal (CI, Ansible, cloud-init) arbeitet es nur mit Optionen und Umgebungsvariablen.
#
# Was das Skript tut: Plattform, Rechte, Docker und Compose prüfen (Docker auf Wunsch über die offiziellen
# Paketquellen installieren), Ports und Platz prüfen, Datenschlüssel erzeugen (Besitzer 1654, Modus 0400),
# docker-compose.yml, .env und node-config.json nach
schreiben, starten, auf /health warten und
# Bootstrap-Token und Fingerabdruck ausgeben. Es überschreibt nie eine vorhandene Installation.
#
# Was es nicht tut: Telemetrie, Phone-Home, Nachladen von Dateien außer Docker-Paketen und Images
# (und dem Repository bei --build). Alle Vorlagen sind unten eingebettet.
#
# Handbuch: docs/installation.md, Betrieb: docs/betrieb.md
set -eu
SCRIPT_VERSION="0.1.0"
REPO_URL="https://github.com/andreaseu/smtplymsp.git"
DEFAULT_PREFIX="ghcr.io/andreaseu/smtplymsp-"
APP_UID=1654
DOCKER_DOCS="https://docs.docker.com/engine/install/"
# ---------------------------------------------------------------------------------------------------------------
# Standardwerte (Umgebungsvariablen überschreiben, Optionen überschreiben beide)
# ---------------------------------------------------------------------------------------------------------------
COMPONENT="${SMTPLYMSP_COMPONENT:-}" # all, node, fleet
INSTALL_DIR="${SMTPLYMSP_DIR:-}"
TAG="${SMTPLYMSP_VERSION:-}"
IMAGE_PREFIX="${SMTPLYMSP_IMAGE_PREFIX:-}"
NODE_NAME="${SMTPLYMSP_NODE_NAME:-}"
BOOTSTRAP_TOKEN="${SMTPLYMSP_BOOTSTRAP_TOKEN:-}"
FLEET_URL="${SMTPLYMSP_FLEET_URL:-}"
BUILD="${SMTPLYMSP_BUILD:-0}"
BUILD_REF="${SMTPLYMSP_BUILD_REF:-}"
SOURCE_DIR="${SMTPLYMSP_SOURCE_DIR:-}"
PORT_SMTP="${SMTPLYMSP_PORT_SMTP:-25}"
PORT_SUBMISSION="${SMTPLYMSP_PORT_SUBMISSION:-587}"
PORT_API="${SMTPLYMSP_PORT_API:-8443}"
PORT_FLEET="${SMTPLYMSP_PORT_FLEET:-9443}"
UI_LANG="${SMTPLYMSP_LANG:-}"
ASSUME_YES=0
DRY_RUN=0
ACTION=install
PURGE=0
IS_MAC=0
ARCH=
HAVE_TTY=0
NEED_SUDO=0
DOCKER_SUDO=0
HAVE_DOCKER=0
DOCKER_READY=0
TMP_DIR=
# ---------------------------------------------------------------------------------------------------------------
# Sprache und Ausgabe
# ---------------------------------------------------------------------------------------------------------------
detect_lang() {
if [ -z "$UI_LANG" ]; then
case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
en*) UI_LANG=en ;;
*) UI_LANG=de ;;
esac
fi
case "$UI_LANG" in
de | en) ;;
*) UI_LANG=de ;;
esac
}
# t "deutsch" "english": gibt den Text der gewählten Sprache zurück
t() {
if [ "$UI_LANG" = en ]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_B=$(printf '\033[1m'); C_G=$(printf '\033[32m'); C_Y=$(printf '\033[33m'); C_R=$(printf '\033[31m'); C_0=$(printf '\033[0m')
else
C_B=; C_G=; C_Y=; C_R=; C_0=
fi
say() { printf '%s\n' "$*"; }
step() { printf '\n%s==> %s%s\n' "$C_B" "$*" "$C_0"; }
ok() { printf '%s ok%s %s\n' "$C_G" "$C_0" "$*"; }
warn() { printf '%s %s%s %s\n' "$C_Y" "$(t 'Warnung:' 'Warning:')" "$C_0" "$*" >&2; }
die() {
printf '\n%s%s%s %s\n' "$C_R" "$(t 'Abbruch:' 'Aborted:')" "$C_0" "$*" >&2
exit 1
}
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT
trap 'exit 130' INT TERM
# ---------------------------------------------------------------------------------------------------------------
# Hilfe
# ---------------------------------------------------------------------------------------------------------------
usage() {
if [ "$UI_LANG" = en ]; then
cat <<'EOF'
SMTPly MSP installer (Docker Compose)
Usage: install.sh [options]
curl -fsSL https://msp.smtply.app/install.sh | sh -s -- [options]
What to install (asks if omitted; required without a terminal):
--all node and fleet on this machine
--node node only (SMTP relay for one Microsoft 365 tenant)
--fleet fleet only (web UI and fleet API)
Options:
--dir DIR installation directory (default /opt/smtplymsp, macOS ~/smtplymsp)
--version TAG image tag (default latest)
--image-prefix P image prefix (default ghcr.io/andreaseu/smtplymsp-), e.g. smtplymsp- for local images
--build clone the repository and build the images locally (if no published images exist)
--build-ref REF branch or tag for --build (default: default branch)
--source DIR build from an existing checkout instead of cloning (implies --build)
--node-name NAME display name of the node (default: host name)
--bootstrap-token T bootstrap token from the fleet ("Provision new node"); safer via SMTPLYMSP_BOOTSTRAP_TOKEN
--fleet-url URL address under which admins reach the fleet (default https://:9443)
--smtp-port N host port for SMTP (default 25)
--submission-port N host port for submission/STARTTLS (default 587)
--api-port N host port for the node API (default 8443)
--fleet-port N host port for the fleet (default 9443)
--upgrade pull (or rebuild) images of an existing installation and restart; config and keys stay
--uninstall remove the containers; data volumes, keys and config stay
--purge with --uninstall: also delete volumes, keys and config (asks for typed confirmation)
-y, --yes answer yes to all questions (non-interactive)
--dry-run only show what would happen, change nothing
--lang de|en language of the output (default from LANG)
-h, --help this help
Environment: SMTPLYMSP_COMPONENT, SMTPLYMSP_DIR, SMTPLYMSP_VERSION, SMTPLYMSP_IMAGE_PREFIX, SMTPLYMSP_NODE_NAME,
SMTPLYMSP_FLEET_URL, SMTPLYMSP_BUILD=1, SMTPLYMSP_BUILD_REF, SMTPLYMSP_SOURCE_DIR, SMTPLYMSP_PORT_*, SMTPLYMSP_LANG.
No telemetry. Downloads: Docker packages (only if you agree), the images, and the repository with --build.
Manual: docs/installation.md
EOF
else
cat <<'EOF'
SMTPly MSP Installation (Docker Compose)
Aufruf: install.sh [Optionen]
curl -fsSL https://msp.smtply.app/install.sh | sh -s -- [Optionen]
Was installiert wird (ohne Angabe fragt das Skript; ohne Terminal Pflicht):
--all Node und Fleet auf diesem Rechner
--node nur Node (SMTP-Relay für einen Microsoft-365-Tenant)
--fleet nur Fleet (Web-UI und Fleet-API)
Optionen:
--dir DIR Installationsverzeichnis (Standard /opt/smtplymsp, macOS ~/smtplymsp)
--version TAG Image-Tag (Standard latest)
--image-prefix P Präfix der Images (Standard ghcr.io/andreaseu/smtplymsp-), z. B. smtplymsp- für lokale
--build Repository klonen und Images lokal bauen (wenn keine veröffentlichten Images vorliegen)
--build-ref REF Branch oder Tag für --build (Standard: Standard-Branch)
--source DIR aus einem vorhandenen Checkout bauen statt zu klonen (schließt --build ein)
--node-name NAME Anzeigename des Nodes (Standard: Rechnername)
--bootstrap-token T Bootstrap-Token aus der Fleet („Neuen Node bereitstellen“); sicherer über SMTPLYMSP_BOOTSTRAP_TOKEN
--fleet-url URL Adresse, unter der Administratoren die Fleet erreichen (Standard https://:9443)
--smtp-port N Host-Port für SMTP (Standard 25)
--submission-port N Host-Port für Submission/STARTTLS (Standard 587)
--api-port N Host-Port der Node-API (Standard 8443)
--fleet-port N Host-Port der Fleet (Standard 9443)
--upgrade vorhandene Installation: Images ziehen (oder neu bauen) und neu starten;
Konfiguration und Schlüssel bleiben
--uninstall Container entfernen; Daten-Volumes, Schlüssel und Konfiguration bleiben
--purge mit --uninstall: auch Volumes, Schlüssel und Konfiguration löschen (Bestätigung
durch Eintippen)
-y, --yes alle Fragen mit Ja beantworten (nicht interaktiv)
--dry-run nur anzeigen, was geschähe; nichts ändern
--lang de|en Sprache der Ausgabe (Standard aus LANG)
-h, --help diese Hilfe
Umgebung: SMTPLYMSP_COMPONENT, SMTPLYMSP_DIR, SMTPLYMSP_VERSION, SMTPLYMSP_IMAGE_PREFIX, SMTPLYMSP_NODE_NAME,
SMTPLYMSP_FLEET_URL, SMTPLYMSP_BUILD=1, SMTPLYMSP_BUILD_REF, SMTPLYMSP_SOURCE_DIR, SMTPLYMSP_PORT_*, SMTPLYMSP_LANG.
Keine Telemetrie. Downloads: Docker-Pakete (nur nach Zustimmung), die Images und bei --build das Repository.
Handbuch: docs/installation.md
EOF
fi
}
# ---------------------------------------------------------------------------------------------------------------
# Optionen
# ---------------------------------------------------------------------------------------------------------------
TAG_GIVEN=0
need_arg() {
if [ $# -lt 2 ] || [ -z "$2" ]; then
die "$(t "Option $1 braucht einen Wert." "Option $1 needs a value.")"
fi
}
parse_args() {
while [ $# -gt 0 ]; do
opt=$1
val=
case "$opt" in
--*=*)
val=${opt#*=}
opt=${opt%%=*}
shift
set -- "$opt" "$val" "$@"
;;
esac
case "$1" in
--all) COMPONENT=all ;;
--node) COMPONENT=node ;;
--fleet) COMPONENT=fleet ;;
--dir) need_arg "$@"; INSTALL_DIR=$2; shift ;;
--version | --tag) need_arg "$@"; TAG=$2; TAG_GIVEN=1; shift ;;
--image-prefix) need_arg "$@"; IMAGE_PREFIX=$2; shift ;;
--build) BUILD=1 ;;
--build-ref) need_arg "$@"; BUILD_REF=$2; shift ;;
--source) need_arg "$@"; SOURCE_DIR=$2; BUILD=1; shift ;;
--node-name) need_arg "$@"; NODE_NAME=$2; shift ;;
--bootstrap-token) need_arg "$@"; BOOTSTRAP_TOKEN=$2; shift ;;
--fleet-url) need_arg "$@"; FLEET_URL=$2; shift ;;
--smtp-port) need_arg "$@"; PORT_SMTP=$2; shift ;;
--submission-port) need_arg "$@"; PORT_SUBMISSION=$2; shift ;;
--api-port) need_arg "$@"; PORT_API=$2; shift ;;
--fleet-port) need_arg "$@"; PORT_FLEET=$2; shift ;;
--upgrade) ACTION=upgrade ;;
--uninstall) ACTION=uninstall ;;
--purge) PURGE=1 ;;
-y | --yes) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--lang) need_arg "$@"; UI_LANG=$2; shift ;;
-h | --help) ACTION=help ;;
*) die "$(t "Unbekannte Option: $1 (siehe --help)" "Unknown option: $1 (see --help)")" ;;
esac
shift
done
if [ -n "$TAG" ]; then TAG_GIVEN=1; fi
}
# ---------------------------------------------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------------------------------------------
has() { command -v "$1" >/dev/null 2>&1; }
matches() { printf '%s' "$1" | grep -Eq "$2"; }
as_root() {
if [ "$NEED_SUDO" = 1 ]; then sudo "$@"; else "$@"; fi
}
# Befehl ausführen oder bei --dry-run nur anzeigen
run() {
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] %s\n' "$*"
else
"$@"
fi
}
run_root() {
if [ "$DRY_RUN" = 1 ]; then
if [ "$NEED_SUDO" = 1 ]; then printf ' [dry-run] sudo %s\n' "$*"; else printf ' [dry-run] %s\n' "$*"; fi
else
as_root "$@"
fi
}
dk() {
if [ "$DOCKER_SUDO" = 1 ]; then sudo docker "$@"; else docker "$@"; fi
}
run_dk() {
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] docker %s\n' "$*"
else
dk "$@"
fi
}
compose() {
dk compose --project-directory "$INSTALL_DIR" -f "$INSTALL_DIR/docker-compose.yml" "$@"
}
run_compose() {
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] docker compose --project-directory %s %s\n' "$INSTALL_DIR" "$*"
else
compose "$@"
fi
}
# Datei aus stdin schreiben: write_file [] [geheim]
write_file() {
dest=$1
mode=$2
owner=${3:-}
secret=${4:-}
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] %s %s (%s%s)\n' "$(t 'schreibe' 'write')" "$dest" "$mode" "${owner:+, $owner}"
if [ -n "$secret" ]; then
cat >/dev/null
printf ' %s\n' "$(t '(Inhalt geheim, nicht angezeigt)' '(secret content, not shown)')"
else
sed 's/^/ | /'
fi
return 0
fi
as_root tee "$dest" >/dev/null
as_root chmod "$mode" "$dest"
if [ -n "$owner" ]; then
as_root chown "$owner" "$dest"
fi
}
# Eingaben: immer von /dev/tty, damit `curl ... | sh` funktioniert
detect_tty() {
if (exec /dev/null; then
HAVE_TTY=1
fi
}
# ask_yn "Frage" [j|n]: 0 = ja
ask_yn() {
question=$1
default=${2:-j}
if [ "$ASSUME_YES" = 1 ]; then
say "$question $(t '[ja, --yes]' '[yes, --yes]')"
return 0
fi
if [ "$HAVE_TTY" = 0 ]; then
if [ "$DRY_RUN" = 1 ]; then
say "$question $(t '[ohne Terminal: nein; mit --yes: ja]' '[no terminal: no; with --yes: yes]')"
return 1
fi
die "$(t "Keine Eingabe möglich (kein Terminal). Mit --yes bestätigen oder interaktiv aufrufen: $question" "No input possible (no terminal). Confirm with --yes or run interactively: $question")"
fi
if [ "$default" = j ]; then hint=$(t '[J/n]' '[Y/n]'); else hint=$(t '[j/N]' '[y/N]'); fi
printf '%s %s ' "$question" "$hint" >/dev/tty
read -r answer /dev/tty
read -r answer /dev/null || true)
[ -n "$name" ] || name=$(uname -n 2>/dev/null || true)
[ -n "$name" ] || name=localhost
printf '%s' "$name"
}
host_short() {
name=$(hostname -s 2>/dev/null || uname -n 2>/dev/null || true)
name=${name%%.*}
name=$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-63)
[ -n "$name" ] || name=node
printf '%s' "$name"
}
# Erste Adresse des Rechners für Links (kein Netzverkehr, nur lokale Abfrage)
host_addr() {
addr=
if [ "$IS_MAC" = 1 ]; then
addr=$(ipconfig getifaddr en0 2>/dev/null || true)
elif hostname -I >/dev/null 2>&1; then
addr=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
if [ -z "$addr" ] && [ "$IS_MAC" = 0 ] && has ip; then
addr=$(ip -4 -o addr show scope global 2>/dev/null | awk '$2 !~ /^(docker|br-|veth)/ {split($4, a, "/"); print a[1]; exit}')
fi
[ -n "$addr" ] || addr=localhost
case "$addr" in *:*) addr="[$addr]" ;; esac
printf '%s' "$addr"
}
host_tz() {
tz=${TZ:-}
if [ -z "$tz" ] && [ -r /etc/timezone ]; then tz=$(head -n1 /etc/timezone); fi
if [ -z "$tz" ] && [ -L /etc/localtime ]; then
tz=$(readlink /etc/localtime | sed -n 's|.*zoneinfo/||p')
fi
matches "${tz:-x}" '^[A-Za-z0-9_+/-]+$' || tz=UTC
printf '%s' "$tz"
}
gen_key() {
if has openssl; then
openssl rand -base64 32
else
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64
fi
}
# Wert aus .env lesen (nicht sourcen)
env_get() {
sed -n "s/^$1=//p" "$2" 2>/dev/null | tail -n1
}
# ---------------------------------------------------------------------------------------------------------------
# Prüfungen
# ---------------------------------------------------------------------------------------------------------------
check_platform() {
step "$(t 'Plattform' 'Platform')"
os=$(uname -s)
machine=$(uname -m)
case "$machine" in
x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
*) die "$(t "Architektur $machine wird nicht unterstützt (nur amd64 und arm64)." "Architecture $machine is not supported (amd64 and arm64 only).")" ;;
esac
case "$os" in
Linux)
distro=$(sed -n 's/^PRETTY_NAME=//p' /etc/os-release 2>/dev/null | tr -d '"')
ok "Linux $ARCH${distro:+ ($distro)}"
;;
Darwin)
IS_MAC=1
ok "macOS $ARCH $(t '(nur mit vorhandener Container-Laufzeit: Docker Desktop, OrbStack)' '(only with an existing container runtime: Docker Desktop, OrbStack)')"
;;
*) die "$(t "Betriebssystem $os wird von install.sh nicht unterstützt. Windows: deploy/windows/install-service.ps1 oder Docker Desktop, siehe docs/installation.md." "Operating system $os is not supported by install.sh. Windows: deploy/windows/install-service.ps1 or Docker Desktop, see docs/installation.md.")" ;;
esac
}
check_privileges() {
if [ "$IS_MAC" = 1 ]; then
return 0
fi
if [ "$(id -u)" -eq 0 ]; then
ok "$(t 'läuft als root' 'running as root')"
return 0
fi
if has sudo; then
NEED_SUDO=1
ok "$(t 'sudo wird für Verzeichnisse, Schlüssel und Docker verwendet' 'sudo is used for directories, keys and Docker')"
if [ "$DRY_RUN" = 0 ] && ! sudo -n true 2>/dev/null; then
if [ "$HAVE_TTY" = 0 ]; then
die "$(t 'sudo verlangt ein Kennwort, aber es gibt kein Terminal. Als root oder mit sudo -n-fähigem Benutzer aufrufen.' 'sudo needs a password but there is no terminal. Run as root or with passwordless sudo.')"
fi
# shellcheck disable=SC2024 # /dev/tty ist die Eingabe für die Kennwortabfrage, kein Schreibziel
sudo -v /dev/null 2>&1; then
DOCKER_SUDO=0
elif [ "$NEED_SUDO" = 1 ] && sudo -n docker info >/dev/null 2>&1; then
DOCKER_SUDO=1
elif [ "$NEED_SUDO" = 1 ] && [ "$DRY_RUN" = 0 ] && sudo docker info >/dev/null 2>&1; then
DOCKER_SUDO=1
else
if [ "$IS_MAC" = 1 ]; then
die "$(t 'Docker antwortet nicht. Docker Desktop oder OrbStack starten und erneut aufrufen.' 'Docker does not respond. Start Docker Desktop or OrbStack and run again.')"
fi
if has systemctl && [ "$DRY_RUN" = 0 ]; then
if ask_yn "$(t 'Der Docker-Dienst läuft nicht. Jetzt starten (systemctl enable --now docker)?' 'The Docker service is not running. Start it now (systemctl enable --now docker)?')"; then
as_root systemctl enable --now docker
if docker info >/dev/null 2>&1; then DOCKER_SUDO=0; else DOCKER_SUDO=$NEED_SUDO; fi
else
die "$(t 'Docker läuft nicht.' 'Docker is not running.')"
fi
elif [ "$DRY_RUN" = 1 ]; then
warn "$(t 'Docker antwortet nicht (im Probelauf nicht weiter geprüft).' 'Docker does not respond (not checked further in a dry run).')"
return 0
else
die "$(t 'Docker antwortet nicht.' 'Docker does not respond.')"
fi
fi
version=$(dk version --format '{{.Server.Version}}' 2>/dev/null || echo '?')
ok "Docker Engine $version"
if dk compose version >/dev/null 2>&1; then
ok "$(dk compose version --short 2>/dev/null | sed 's/^/Compose /')"
DOCKER_READY=1
else
die "$(t "Das Compose-Plugin fehlt (docker compose). Debian/Ubuntu mit Docker-Paketquelle: apt-get install docker-compose-plugin; Ubuntu-Pakete: docker-compose-v2; Fedora/RHEL: dnf install docker-compose-plugin. Siehe $DOCKER_DOCS" "The Compose plugin is missing (docker compose). Debian/Ubuntu with Docker repository: apt-get install docker-compose-plugin; Ubuntu packages: docker-compose-v2; Fedora/RHEL: dnf install docker-compose-plugin. See $DOCKER_DOCS")"
fi
}
install_docker() {
if [ "$IS_MAC" = 1 ]; then
die "$(t 'Docker fehlt. Auf dem Mac Docker Desktop oder OrbStack installieren und starten, dann erneut aufrufen.' 'Docker is missing. On a Mac install and start Docker Desktop or OrbStack, then run again.')"
fi
[ -r /etc/os-release ] || die "$(t "Distribution unbekannt (/etc/os-release fehlt). Docker selbst installieren: $DOCKER_DOCS" "Unknown distribution (/etc/os-release missing). Install Docker yourself: $DOCKER_DOCS")"
os_id=$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')
os_like=$(sed -n 's/^ID_LIKE=//p' /etc/os-release | tr -d '"')
codename=$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release | tr -d '"')
ubuntu_codename=$(sed -n 's/^UBUNTU_CODENAME=//p' /etc/os-release | tr -d '"')
family=
repo=
case "$os_id" in
debian) family=apt; repo=debian ;;
raspbian) family=apt; repo=debian ;; # Raspberry Pi OS 64 Bit nutzt die Debian-Quelle
ubuntu) family=apt; repo=ubuntu ;;
fedora) family=dnf; repo=fedora ;;
rhel) family=dnf; repo=rhel ;;
centos | rocky | almalinux) family=dnf; repo=centos ;;
*)
case " $os_like " in
*" ubuntu "*) family=apt; repo=ubuntu; codename=${ubuntu_codename:-$codename} ;;
*" debian "*) family=apt; repo=debian ;;
esac
;;
esac
if [ -z "$family" ]; then
die "$(t "Docker fehlt und die Distribution '$os_id' wird vom Skript nicht automatisch versorgt. Docker Engine mit Compose-Plugin nach $DOCKER_DOCS installieren und erneut aufrufen." "Docker is missing and distribution '$os_id' is not handled automatically. Install Docker Engine with the Compose plugin following $DOCKER_DOCS and run again.")"
fi
say "$(t "Docker fehlt. Das Skript kann Docker Engine und das Compose-Plugin aus der offiziellen Docker-Paketquelle (download.docker.com/linux/$repo) installieren, wie unter $DOCKER_DOCS beschrieben." "Docker is missing. The script can install Docker Engine and the Compose plugin from the official Docker repository (download.docker.com/linux/$repo) as described at $DOCKER_DOCS.")"
if ! ask_yn "$(t 'Docker jetzt installieren?' 'Install Docker now?')"; then
if [ "$DRY_RUN" = 1 ]; then
say "$(t ' (Probelauf: folgende Schritte würden ausgeführt)' ' (dry run: these steps would run)')"
else
die "$(t "Ohne Docker keine Installation. Anleitung: $DOCKER_DOCS" "No installation without Docker. Instructions: $DOCKER_DOCS")"
fi
fi
if [ "$family" = apt ]; then
[ -n "$codename" ] || die "$(t 'VERSION_CODENAME fehlt in /etc/os-release.' 'VERSION_CODENAME missing in /etc/os-release.')"
run_root apt-get update
run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl
run_root install -m 0755 -d /etc/apt/keyrings
run_root curl -fsSL "https://download.docker.com/linux/$repo/gpg" -o /etc/apt/keyrings/docker.asc
run_root chmod a+r /etc/apt/keyrings/docker.asc
dpkg_arch=$(dpkg --print-architecture 2>/dev/null || echo "$ARCH")
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$dpkg_arch" "$repo" "$codename" | write_file /etc/apt/sources.list.d/docker.list 0644
run_root apt-get update
run_root env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
else
repo_url="https://download.docker.com/linux/$repo/docker-ce.repo"
run_root dnf -y install dnf-plugins-core
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] dnf config-manager addrepo --from-repofile=%s\n' "$repo_url"
elif ! as_root dnf config-manager addrepo --from-repofile="$repo_url" 2>/dev/null; then
as_root dnf config-manager --add-repo "$repo_url"
fi
run_root dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
if has systemctl; then
run_root systemctl enable --now docker
fi
if [ "$DRY_RUN" = 0 ]; then
HAVE_DOCKER=1
check_docker
fi
}
# Besitzer eines belegten Ports, leer wenn frei
port_owner() {
p=$1
owner=
if [ "$DOCKER_READY" = 1 ]; then
owner=$(dk ps --filter "publish=$p" --format '{{.Names}}' 2>/dev/null | head -n1)
if [ -n "$owner" ]; then
printf '%s' "$(t "Container $owner" "container $owner")"
return 0
fi
fi
lines=
if has ss; then
if [ "$NEED_SUDO" = 1 ]; then
lines=$(sudo -n ss -Hltnp "sport = :$p" 2>/dev/null || ss -Hltn "sport = :$p" 2>/dev/null || true)
else
lines=$(ss -Hltnp "sport = :$p" 2>/dev/null || true)
fi
if [ -n "$lines" ]; then
owner=$(printf '%s\n' "$lines" | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | head -n1)
printf '%s' "${owner:-$(t 'unbekannter Prozess' 'unknown process')}"
return 0
fi
elif has netstat; then
lines=$(netstat -ltnp 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" && $6 == "LISTEN"' || true)
if [ -n "$lines" ]; then
owner=$(printf '%s\n' "$lines" | awk '{print $7}' | sed 's|^[0-9]*/||' | head -n1)
case "$owner" in '' | -) owner=$(t 'unbekannter Prozess' 'unknown process') ;; esac
printf '%s' "$owner"
return 0
fi
elif has lsof; then
lines=$(lsof -nP -iTCP:"$p" -sTCP:LISTEN 2>/dev/null | sed 1d || true)
if [ -n "$lines" ]; then
printf '%s' "$(printf '%s\n' "$lines" | awk '{print $1}' | head -n1)"
return 0
fi
fi
return 0
}
check_ports() {
step "$(t 'Ports' 'Ports')"
ports=
if wants_node; then ports="$PORT_SMTP $PORT_SUBMISSION $PORT_API"; fi
if wants_fleet; then ports="$ports $PORT_FLEET"; fi
if ! has ss && ! has netstat && ! has lsof && [ "$DOCKER_READY" = 0 ]; then
warn "$(t 'Weder ss, netstat noch lsof vorhanden: Ports nicht geprüft.' 'Neither ss, netstat nor lsof available: ports not checked.')"
return 0
fi
busy=0
for p in $ports; do
owner=$(port_owner "$p")
if [ -n "$owner" ]; then
warn "$(t "Port $p ist belegt von: $owner" "Port $p is in use by: $owner")"
busy=1
else
ok "$(t "Port $p frei" "port $p free")"
fi
done
if [ "$busy" = 1 ]; then
msg=$(t 'Belegte Ports. Dienst beenden (z. B. Postfix auf 25: systemctl disable --now postfix) oder andere Host-Ports wählen (--smtp-port, --submission-port, --api-port, --fleet-port).' 'Ports in use. Stop the service (e.g. Postfix on 25: systemctl disable --now postfix) or choose other host ports (--smtp-port, --submission-port, --api-port, --fleet-port).')
if [ "$DRY_RUN" = 1 ]; then warn "$msg"; else die "$msg"; fi
fi
}
free_kb() {
d=$1
while [ ! -d "$d" ]; do d=$(dirname "$d"); done
df -Pk "$d" 2>/dev/null | awk 'NR==2 {print $4}'
}
check_space() {
step "$(t 'Speicherplatz' 'Disk space')"
need_mb=1024
if [ "$BUILD" = 1 ]; then need_mb=6144; fi
kb=$(free_kb "$INSTALL_DIR")
if [ -n "$kb" ]; then
if [ "$kb" -lt 102400 ]; then
die "$(t "Unter 100 MB frei für $INSTALL_DIR." "Less than 100 MB free for $INSTALL_DIR.")"
fi
ok "$INSTALL_DIR: $((kb / 1024)) MB $(t 'frei' 'free')"
fi
root=
if [ "$DOCKER_READY" = 1 ] && [ "$IS_MAC" = 0 ]; then
root=$(dk info --format '{{.DockerRootDir}}' 2>/dev/null || true)
fi
[ -n "$root" ] || root=/var/lib/docker
if [ "$IS_MAC" = 1 ]; then return 0; fi
kb=$(free_kb "$root")
if [ -n "$kb" ]; then
if [ "$kb" -lt $((need_mb * 1024)) ]; then
warn "$(t "Für Images unter $root nur $((kb / 1024)) MB frei, empfohlen $need_mb MB." "Only $((kb / 1024)) MB free for images under $root, $need_mb MB recommended.")"
ask_yn "$(t 'Trotzdem fortfahren?' 'Continue anyway?')" n || die "$(t 'Zu wenig Platz.' 'Not enough space.')"
else
ok "$root: $((kb / 1024)) MB $(t 'frei' 'free')"
fi
fi
}
# Vorhandene Installation oder fremde Container gleichen Namens erkennen
check_existing() {
step "$(t 'Vorhandene Installation' 'Existing installation')"
if [ -e "$INSTALL_DIR/docker-compose.yml" ] || [ -e "$INSTALL_DIR/.env" ] || [ -e "$INSTALL_DIR/secrets" ]; then
die "$(t "In $INSTALL_DIR liegt bereits eine Installation. Nichts wurde verändert. Aktualisieren: --upgrade. Entfernen: --uninstall. Neu an anderem Ort: --dir." "There is already an installation in $INSTALL_DIR. Nothing was changed. Update: --upgrade. Remove: --uninstall. New elsewhere: --dir.")"
fi
if [ "$DOCKER_READY" = 1 ]; then
for c in smtplymsp-node smtplymsp-fleet; do
if dk container inspect "$c" >/dev/null 2>&1; then
where=$(dk container inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$c" 2>/dev/null || true)
die "$(t "Ein Container $c existiert bereits${where:+ (Compose-Verzeichnis $where)}. Nichts wurde verändert. Vorhandene Installation mit --upgrade --dir aktualisieren oder den Container selbst entfernen." "A container $c already exists${where:+ (Compose directory $where)}. Nothing was changed. Update the existing installation with --upgrade --dir or remove the container yourself.")"
fi
done
fi
ok "$(t 'keine gefunden' 'none found')"
}
# ---------------------------------------------------------------------------------------------------------------
# Images
# ---------------------------------------------------------------------------------------------------------------
image_node() { printf '%snode:%s' "$IMAGE_PREFIX" "$TAG"; }
image_fleet() { printf '%sfleet:%s' "$IMAGE_PREFIX" "$TAG"; }
build_images() {
step "$(t 'Images lokal bauen' 'Building images locally')"
src=$SOURCE_DIR
if [ -z "$src" ]; then
has git || die "$(t 'git fehlt (für --build nötig). Installieren, z. B. apt-get install git.' 'git is missing (needed for --build). Install it, e.g. apt-get install git.')"
if [ "$DRY_RUN" = 1 ]; then
src=/tmp/smtplymsp-build/src
else
TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t smtplymsp)
src=$TMP_DIR/src
fi
if [ -n "$BUILD_REF" ]; then
run git clone --depth 1 --branch "$BUILD_REF" "$REPO_URL" "$src"
else
run git clone --depth 1 "$REPO_URL" "$src"
fi
elif [ ! -f "$src/deploy/Dockerfile.node" ]; then
die "$(t "$src ist kein SMTPly-MSP-Checkout (deploy/Dockerfile.node fehlt)." "$src is not an SMTPly MSP checkout (deploy/Dockerfile.node missing).")"
fi
say "$(t 'Das dauert einige Minuten (auf einem Raspberry Pi 15 Minuten und mehr).' 'This takes a few minutes (15 minutes or more on a Raspberry Pi).')"
if wants_node; then run_dk build -f "$src/deploy/Dockerfile.node" -t "$(image_node)" "$src"; fi
if wants_fleet; then run_dk build -f "$src/deploy/Dockerfile.fleet" -t "$(image_fleet)" "$src"; fi
}
# Images bereitstellen: lokal vorhanden, sonst ziehen, sonst klare Meldung
ensure_images() {
if [ "$BUILD" = 1 ]; then
build_images
return 0
fi
step "Images"
images=
if wants_node; then images=$(image_node); fi
if wants_fleet; then images="$images $(image_fleet)"; fi
for img in $images; do
if [ "$DRY_RUN" = 0 ] && dk image inspect "$img" >/dev/null 2>&1; then
ok "$(t "$img lokal vorhanden" "$img present locally")"
continue
fi
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] docker pull %s %s\n' "$img" "$(t '(falls nicht lokal vorhanden)' '(unless present locally)')"
continue
fi
if ! dk pull "$img"; then
die "$(t "Image $img ist nicht abrufbar. Veröffentlichte Images gibt es erst ab dem ersten Release (Tag v*). Ohne sie mit --build lokal bauen (git und etwa 6 GB Platz nötig) oder mit --image-prefix auf eigene Images zeigen." "Image $img cannot be pulled. Published images exist from the first release (tag v*) on. Without them build locally with --build (needs git and about 6 GB) or point --image-prefix to your own images.")"
fi
done
}
# ---------------------------------------------------------------------------------------------------------------
# Vorlagen (eingebettet, nichts wird nachgeladen)
# ---------------------------------------------------------------------------------------------------------------
render_env() {
cat </dev/null
elif has wget; then
wget -q --no-check-certificate -T 5 -O /dev/null "$1" 2>/dev/null
else
return 2
fi
}
# wait_healthy
wait_healthy() {
c=$1
url=$2
i=0
while [ "$i" -lt 120 ]; do
state=$(dk container inspect --format '{{.State.Status}} {{.RestartCount}}' "$c" 2>/dev/null || echo 'missing 0')
case "$state" in
exited* | dead* | missing*)
dk logs --tail 30 "$c" 2>&1 | sed 's/^/ /' >&2 || true
die "$(t "Container $c läuft nicht ($state). Log oben; häufige Ursachen in docs/installation.md, Fehlersuche." "Container $c is not running ($state). Log above; common causes in docs/installation.md, troubleshooting.")"
;;
esac
restarts=${state#* }
if [ "${restarts:-0}" -gt 0 ]; then
dk logs --tail 30 "$c" 2>&1 | sed 's/^/ /' >&2 || true
die "$(t "Container $c startet neu (Neustarts: $restarts). Log oben; häufig Rechte des Schlüssels (Besitzer 1654, 0400) oder belegter Port." "Container $c keeps restarting (restarts: $restarts). Log above; often key permissions (owner 1654, 0400) or a port in use.")"
fi
if http_ok "$url"; then
ok "$c: $url"
return 0
fi
# Ohne curl/wget mit TLS: Startmeldung im Log genügt
if ! has curl && dk logs "$c" 2>&1 | grep -q 'Now listening on'; then
ok "$c $(t 'lauscht (laut Log)' 'is listening (per log)')"
return 0
fi
i=$((i + 1))
sleep 1
done
dk logs --tail 30 "$c" 2>&1 | sed 's/^/ /' >&2 || true
die "$(t "$c antwortet nach 120 Sekunden nicht auf $url." "$c does not answer on $url after 120 seconds.")"
}
log_field() {
# log_field : letzter Wert aus den JSON-Logs
dk logs "$1" 2>&1 | grep -F "$2" | sed -n "s/.*\"$3\":\"\([^\"]*\)\".*/\1/p" | tail -n1
}
fingerprint_via_openssl() {
has openssl || return 0
openssl s_client -connect "127.0.0.1:$1" /dev/null |
openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 | tr -d ':'
}
# ---------------------------------------------------------------------------------------------------------------
# Abläufe
# ---------------------------------------------------------------------------------------------------------------
choose_component() {
if [ -n "$COMPONENT" ]; then return 0; fi
if [ "$HAVE_TTY" = 1 ] && [ "$ASSUME_YES" = 0 ]; then
say ""
say "$(t 'Was soll auf diesem Rechner laufen?' 'What should run on this machine?')"
say " 1) $(t 'Node und Fleet (Einstieg, ein Rechner)' 'node and fleet (getting started, one machine)')"
say " 2) $(t 'nur Node (SMTP-Relay für einen Tenant)' 'node only (SMTP relay for one tenant)')"
say " 3) $(t 'nur Fleet (Web-UI für mehrere Nodes)' 'fleet only (web UI for several nodes)')"
printf '%s [1]: ' "$(t 'Auswahl' 'Choice')" >/dev/tty
read -r answer ' -H 'Content-Type: application/json' -d '{\"description\":\"Fleet\",\"scope\":\"admin\"}' https://127.0.0.1:$PORT_API/api/v1/tokens"
n=$((n + 1))
fi
if wants_node && wants_fleet; then
say " $n. $(t 'In der Fleet unter Nodes registrieren: Adresse https://node:8443 (gleicher Rechner), dauerhaftes Token und Fingerabdruck des Nodes. Der Assistent führt durch Microsoft 365, Absender und SMTP-Benutzer.' 'Register under Nodes in the fleet: address https://node:8443 (same machine), permanent token and node fingerprint. The assistant walks through Microsoft 365, sender and SMTP users.')"
n=$((n + 1))
elif wants_node; then
say " $n. $(t "In der Fleet (anderer Rechner) registrieren mit https://$addr:$PORT_API, dauerhaftem Token und Fingerabdruck, oder per CLI: smtplymsp --node https://$addr:$PORT_API --insecure-fingerprint status" "Register in the fleet (other machine) with https://$addr:$PORT_API, permanent token and fingerprint, or via CLI: smtplymsp --node https://$addr:$PORT_API --insecure-fingerprint status")"
n=$((n + 1))
fi
if wants_node; then
say " $n. $(t 'Der Node verlangt SMTP AUTH und hat noch keinen Benutzer: bis einer angelegt ist, nimmt er keine Mail an (sicher ab Werk). Freigabeliste: 10.0.0.0/8, 192.168.0.0/16, fc00::/7, anpassen in der Fleet oder per API.' 'The node requires SMTP AUTH and has no user yet: until one exists it accepts no mail (secure by default). Allow list: 10.0.0.0/8, 192.168.0.0/16, fc00::/7, adjust in the fleet or via API.')"
n=$((n + 1))
fi
say " $n. $(t 'Firewall: Docker-Portweiterleitungen umgehen ufw. Zugriff auf API und Fleet über die Kette DOCKER-USER beschränken (docs/installation.md).' 'Firewall: Docker port forwarding bypasses ufw. Restrict access to API and fleet via the DOCKER-USER chain (docs/installation.md).')"
say ""
say "$(t "Verzeichnis: $INSTALL_DIR (Schlüssel in secrets/ getrennt vom Volume sichern). Aktualisieren: install.sh --upgrade --dir $INSTALL_DIR" "Directory: $INSTALL_DIR (back up the keys in secrets/ separately from the volume). Update: install.sh --upgrade --dir $INSTALL_DIR")"
if [ "$BUILD" = 1 ]; then
say "$(t 'Images wurden lokal gebaut (--build). Für Updates erneut mit --upgrade --build aufrufen.' 'Images were built locally (--build). For updates run again with --upgrade --build.')"
fi
}
load_existing() {
envf="$INSTALL_DIR/.env"
if [ ! -f "$INSTALL_DIR/docker-compose.yml" ] || [ ! -f "$envf" ]; then
die "$(t "In $INSTALL_DIR ist keine Installation von install.sh (docker-compose.yml und .env fehlen). Anderes Verzeichnis mit --dir angeben." "No installation by install.sh in $INSTALL_DIR (docker-compose.yml and .env missing). Pass another directory with --dir.")"
fi
COMPONENT=$(env_get SMTPLYMSP_INSTALL_COMPONENT "$envf")
case "$COMPONENT" in all | node | fleet) ;; *) die "$(t "$envf ist unvollständig (SMTPLYMSP_INSTALL_COMPONENT)." "$envf is incomplete (SMTPLYMSP_INSTALL_COMPONENT).")" ;; esac
PORT_API=$(env_get SMTPLYMSP_PORT_API "$envf")
PORT_FLEET=$(env_get SMTPLYMSP_PORT_FLEET "$envf")
if [ "$BUILD" = 0 ]; then BUILD=$(env_get SMTPLYMSP_INSTALL_BUILD "$envf"); fi
if [ "$TAG_GIVEN" = 0 ]; then TAG=$(env_get SMTPLYMSP_TAG "$envf"); fi
if [ -z "${SMTPLYMSP_IMAGE_PREFIX:-}" ] && [ "$IMAGE_PREFIX" = "$DEFAULT_PREFIX" ]; then
IMAGE_PREFIX=$(env_get SMTPLYMSP_IMAGE_PREFIX "$envf")
fi
: "${PORT_API:=8443}" "${PORT_FLEET:=9443}" "${BUILD:=0}" "${TAG:=latest}" "${IMAGE_PREFIX:=$DEFAULT_PREFIX}"
}
set_env_value() {
# set_env_value in .env ersetzen
envf="$INSTALL_DIR/.env"
if [ "$DRY_RUN" = 1 ]; then
printf ' [dry-run] %s: %s=%s\n' "$envf" "$1" "$2"
return 0
fi
as_root sed -i.bak "s|^$1=.*|$1=$2|" "$envf"
as_root rm -f "$envf.bak"
}
do_upgrade() {
check_privileges
check_docker
[ "$DOCKER_READY" = 1 ] || [ "$DRY_RUN" = 1 ] || die "$(t 'Docker fehlt.' 'Docker is missing.')"
load_existing
validate_inputs
step "Upgrade ($COMPONENT, $TAG)"
say "$(t 'Konfiguration, node-config.json und Schlüssel bleiben unverändert.' 'Configuration, node-config.json and keys stay unchanged.')"
ask_yn "$(t 'Images aktualisieren und Container neu erstellen? Geräte bekommen dabei kurz keine Verbindung und liefern später erneut ein.' 'Update images and recreate containers? Devices briefly get no connection and retry later.')" || die "$(t 'Abgebrochen.' 'Cancelled.')"
set_env_value SMTPLYMSP_TAG "$TAG"
set_env_value SMTPLYMSP_IMAGE_PREFIX "$IMAGE_PREFIX"
set_env_value SMTPLYMSP_NODE_IMAGE "$(image_node)"
set_env_value SMTPLYMSP_FLEET_IMAGE "$(image_fleet)"
if [ "$BUILD" = 1 ]; then
build_images
else
case "$IMAGE_PREFIX" in
*/*) run_compose pull ;;
*) say "$(t "Lokale Images ($IMAGE_PREFIX), kein Pull." "Local images ($IMAGE_PREFIX), no pull.")" ;;
esac
fi
run_compose up -d
if [ "$DRY_RUN" = 1 ]; then return 0; fi
if wants_node; then wait_healthy smtplymsp-node "https://127.0.0.1:$PORT_API/api/v1/health"; fi
if wants_fleet; then wait_healthy smtplymsp-fleet "https://127.0.0.1:$PORT_FLEET/health"; fi
say ""
say "$(t 'Upgrade abgeschlossen.' 'Upgrade finished.')"
compose ps
}
do_uninstall() {
check_privileges
check_docker
[ "$DOCKER_READY" = 1 ] || [ "$DRY_RUN" = 1 ] || die "$(t 'Docker fehlt.' 'Docker is missing.')"
load_existing
step "$(t 'Deinstallation' 'Uninstall')"
ask_yn "$(t "Container in $INSTALL_DIR stoppen und entfernen? Volumes, Schlüssel und Konfiguration bleiben." "Stop and remove the containers of $INSTALL_DIR? Volumes, keys and configuration stay.")" || die "$(t 'Abgebrochen.' 'Cancelled.')"
run_compose down
ok "$(t 'Container entfernt. Daten liegen weiter in den Volumes smtplymsp_node-data und smtplymsp_fleet-data, Schlüssel und Konfiguration in' 'Containers removed. Data remains in the volumes smtplymsp_node-data and smtplymsp_fleet-data, keys and configuration in') $INSTALL_DIR"
if [ "$PURGE" = 0 ]; then
say "$(t "Neu starten: docker compose --project-directory $INSTALL_DIR up -d. Endgültig löschen: --uninstall --purge." "Start again: docker compose --project-directory $INSTALL_DIR up -d. Delete for good: --uninstall --purge.")"
return 0
fi
word=$(t LOESCHEN DELETE)
say ""
warn "$(t "Endgültiges Löschen: Tracker, Warteschlange, Tokens, Lizenzaktivierung, Datenschlüssel und Konfiguration in $INSTALL_DIR. Nicht umkehrbar." "Permanent deletion: tracker, queue, tokens, licence activation, data keys and configuration in $INSTALL_DIR. Cannot be undone.")"
if [ "$DRY_RUN" = 1 ]; then
say " $(t "(Probelauf: Bestätigung durch Eintippen von $word würde verlangt)" "(dry run: typed confirmation $word would be required)")"
else
[ "$HAVE_TTY" = 1 ] || die "$(t 'Löschen geht nur interaktiv (Bestätigung durch Eintippen). Container sind entfernt, Daten blieben erhalten.' 'Deleting is interactive only (typed confirmation). Containers are removed, data was kept.')"
printf '%s ' "$(t "Zum Bestätigen $word eintippen:" "Type $word to confirm:")" >/dev/tty
read -r answer