#!/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