#!/bin/bash # ══════════════════════════════════════════════════════════════════════════════ # NetCell MailGuard — One-Liner Installer # # New cluster: # curl -fsSL https://get.netcell-mailguard.de | sudo bash -s -- --init # # Join an existing cluster: # curl -fsSL https://get.netcell-mailguard.de | sudo bash -s -- \ # --join https://node1.example --token # # Plain install (no cluster op, just the package + enable services): # curl -fsSL https://get.netcell-mailguard.de | sudo bash # # Supports: Debian 12/13, Ubuntu 24.04 (amd64 + arm64) # ══════════════════════════════════════════════════════════════════════════════ set -euo pipefail # Locale auf C zwingen damit jeder apt/dpkg/systemctl-Output auf # Englisch ist und unsere Pipe-Parser (awk, grep) deterministisch # matchen. Vorher fail'te der Installer auf de_DE-Boxen weil # apt-cache policy „Installationskandidat:" statt „Candidate:" druckte # (Befund ws19, 1.8.51). Globaler Fix für alle künftigen Locale-Quirks. export LC_ALL=C export LANG=C GRN='\033[0;32m'; RED='\033[0;31m'; YLW='\033[0;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m' log() { echo -e "${GRN}[nmg]${NC} $*"; } warn() { echo -e "${YLW}[nmg]${NC} $*"; } fail() { echo -e "${RED}[nmg]${NC} $*"; exit 1; } # ─── Argument parsing ───────────────────────────────────────────────────────── MODE="install" ORG="NetCell MailGuard" JOIN_URL="" JOIN_TOKEN="" FQDN_OVERRIDE="" while [ $# -gt 0 ]; do case "$1" in --init) MODE="init" shift ;; --join) MODE="join" JOIN_URL="${2:-}" shift 2 ;; --token) JOIN_TOKEN="${2:-}" shift 2 ;; --org) ORG="${2:-}" shift 2 ;; --fqdn) FQDN_OVERRIDE="${2:-}" shift 2 ;; -h|--help) cat <<'USAGE' Usage: install.sh [--init | --join --token ] [--org ] [--fqdn ] Modes: (none) Install + enable services. No cluster action. Re-runnable. --init Create a new cluster (generates CA + shared session-key). --join URL Join an existing cluster (needs --token). --token TOK One-time token, obtained from an existing node. Optional: --org NAME Cluster display name (default: "NetCell MailGuard"). --fqdn HOST Override detected hostname. USAGE exit 0 ;; *) fail "unknown argument: $1" ;; esac done if [ "$MODE" = "join" ]; then [ -z "$JOIN_URL" ] && fail "--join is required" [ -z "$JOIN_TOKEN" ] && fail "--token is required (get one from an existing node via 'nmg-ctl cluster join-token')" fi echo "" echo -e "${CYN}══════════════════════════════════════════════════════════${NC}" echo -e "${CYN} ${BLD}NetCell MailGuard${NC}${CYN} — Installer${NC}" echo -e "${CYN}══════════════════════════════════════════════════════════${NC}" echo "" # ─── Root check ─────────────────────────────────────────────────────────────── [ "$(id -u)" -ne 0 ] && fail "Please run as root: curl -fsSL https://get.netcell-mailguard.de | sudo bash" # ─── Pre-flight: required tools ─────────────────────────────────────────────── # Wenn eines davon fehlt, bricht der Installer später mit kryptischem # Fehler ab (apt-get ohne curl: kein GPG-Key-Fetch; ohne gpg: keine # Repo-Signatur-Prüfung; ohne apt-get: nicht-Debian-Distro). Vorab # klar abfangen mit One-Liner-Fix-Hinweis. for tool in curl apt-get gpg dpkg; do command -v "$tool" >/dev/null 2>&1 || \ fail "$tool not found — required prerequisite. Install with: apt-get install -y curl gpg ca-certificates (Debian/Ubuntu only)" done # ─── Pre-flight: disk space ─────────────────────────────────────────────────── # nmg + Dependencies (postfix, rspamd, clamav-DB, keydb, unbound) + # ClamAV-Signatures (~600 MB) brauchen mindestens 1 GiB auf /var. Bei # zu wenig Platz crashed die Install mitten im Postinst (typisch: # „No space left on device" wenn freshclam die DB lädt). if [ "$(df --output=avail /var | tail -1)" -lt 1048576 ] 2>/dev/null; then log "WARNING: less than 1 GiB free on /var — install may fail mid-way (clamav db is ~600 MB)" fi # ─── OS check ───────────────────────────────────────────────────────────────── if [ -f /etc/os-release ]; then . /etc/os-release OS_ID="$ID" OS_VERSION="$VERSION_ID" OS_CODENAME="${VERSION_CODENAME:-}" else fail "Unknown OS. Only Debian 12/13 and Ubuntu 24.04 are supported." fi case "$OS_ID" in debian) [[ "$OS_VERSION" =~ ^(12|13)$ ]] || fail "Debian $OS_VERSION not supported. Only 12 (Bookworm) or 13 (Trixie)." ;; ubuntu) [[ "$OS_VERSION" == "24.04" ]] || fail "Ubuntu $OS_VERSION not supported. Only 24.04 LTS." ;; *) fail "$OS_ID is not supported. Only Debian 12/13 and Ubuntu 24.04." ;; esac ARCH=$(dpkg --print-architecture 2>/dev/null || echo "unknown") case "$ARCH" in amd64|arm64) ;; *) fail "Architecture $ARCH not supported. Only amd64 or arm64." ;; esac log "System: $OS_ID $OS_VERSION ($OS_CODENAME) $ARCH" log "Mode: $MODE" # ─── Dependencies (bootstrap only) ──────────────────────────────────────────── log "Installing bootstrap prerequisites..." apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ curl gnupg ca-certificates apt-transport-https >/dev/null 2>&1 # ─── apt repository setup ───────────────────────────────────────────────────── log "Configuring apt repository (Gitea packages)..." # Strip pre-rebrand or pre-renamed repo files first. They point at the same # Gitea URL but with a different signed-by keyring, which apt rejects with # `Conflicting values set for option Signed-By`. Idempotent + safe — we only # remove files we know we used to ship. for stale_list in netcell.list mail-gateway.list mailgw.list; do [ -f "/etc/apt/sources.list.d/$stale_list" ] && rm -f "/etc/apt/sources.list.d/$stale_list" done for stale_key in netcell-gitea.asc mail-gateway.asc mailgw.asc; do [ -f "/etc/apt/keyrings/$stale_key" ] && rm -f "/etc/apt/keyrings/$stale_key" done mkdir -p /etc/apt/keyrings curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \ -o /etc/apt/keyrings/nmg.asc # Debian 13 uses "trixie" suite, Debian 12 uses "bookworm", Ubuntu 24.04 uses "noble". # Gitea publishes under "bookworm" — it works for every derivative because the # packages are arch-typed, not suite-typed. echo 'deb [signed-by=/etc/apt/keyrings/nmg.asc] https://git.netcell-it.de/api/packages/projekte/debian bookworm main' \ > /etc/apt/sources.list.d/nmg.list # rspamd-Upstream: Debian Trixie friert auf 3.12.x ein, upstream-stable # ist auf der 4.0.x-Linie (DKIM-RFC-Fix, multi-flag fuzzy, async hyperscan # cache, FP/FN-Verbesserungen). Wir setzen das Repo + Pin VOR `apt install # nmg` damit der Dep-Resolver rspamd direkt zur 4.0.x-Version pullt # statt erst 3.12 zu installieren und dann hochzuziehen. Codename matched # Debian-Release; rspamd.com liefert für trixie/bookworm/noble jeweils # eigene Builds. RSPAMD_SUITE="trixie" case "$(. /etc/os-release && echo "$VERSION_CODENAME")" in bookworm) RSPAMD_SUITE="bookworm" ;; trixie) RSPAMD_SUITE="trixie" ;; noble) RSPAMD_SUITE="noble" ;; esac curl -fsSL "https://rspamd.com/apt-stable/gpg.key" \ -o /etc/apt/keyrings/rspamd.asc echo "deb [signed-by=/etc/apt/keyrings/rspamd.asc] https://rspamd.com/apt-stable/ $RSPAMD_SUITE main" \ > /etc/apt/sources.list.d/rspamd.list mkdir -p /etc/apt/preferences.d cat > /etc/apt/preferences.d/rspamd-upstream <<'EOF' # Pin rspamd auf rspamd.com-Repo (Origin=Rspamd) damit der Major-Bump # 3.x → 4.x bei jedem `apt install` automatisch greift, statt von der # Debian-eingefrorenen 3.12.x überschattet zu werden. Package: rspamd rspamd-asn Pin: release o=Rspamd Pin-Priority: 1001 EOF apt-get update -qq # LC_ALL=C zwingt apt auf englischen Output — auf deutschen Boxen # druckt apt-cache policy „Installationskandidat:" statt „Candidate:", # der awk-Match scheitert sonst → AVAILABLE bleibt leer → Skript hält # den Kunden mit „(none — package missing)" auf, obwohl das Paket sehr # wohl im Repo liegt. Plus `|| true`-Sicherheitsnetz gegen pipefail- # kill bei wirklich fehlendem Eintrag. AVAILABLE=$(LC_ALL=C apt-cache policy nmg 2>/dev/null | awk '/Candidate:/ {print $2; exit}' || true) log "Available version: ${BLD}${AVAILABLE:-(none — package missing)}${NC}" [ -z "$AVAILABLE" ] || [ "$AVAILABLE" = "(none)" ] && fail "nmg package not found in the Gitea repository." # ─── Install the package (pulls postfix, rspamd, clamav, keydb, bwrap, …) ───── log "Installing nmg package..." # Belt-and-braces: pre-pull packages that earlier .deb releases didn't # carry as Depends. apt-get install nmg resolves Depends transitively # from the .deb's control file (see packaging/debian/nmg/DEBIAN/control), # but a bootstrap host that already had postfix without postfix-pcre # would still be missing the latter on an in-place upgrade — explicit # install here avoids the silent "header_checks pcre map lookup # problem -- message not accepted, try again later" failure that # blocks every inbound mail until pcre support is added. DEBIAN_FRONTEND=noninteractive apt-get install -y postfix-pcre clamav-freshclam >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive apt-get install -y nmg 2>&1 | while IFS= read -r line; do echo -e " ${line}" done # Enable & start services installed by the .deb (postinst has usually done # this, but we re-run for idempotency on re-installs). systemctl enable --now nmg-api >/dev/null 2>&1 || true systemctl enable --now nmg-scheduler >/dev/null 2>&1 || true systemctl enable --now unbound >/dev/null 2>&1 || true # ─── Functional check: services + node-local DNS resolver ──────────────────── # Print one line per service so a failed service is visible without # having to scroll the apt log. unbound gets an extra functional probe # because "active" alone doesn't prove the cache is answering. for svc in nmg-api nmg-scheduler nmg-sandbox postfix rspamd nginx postgresql unbound; do if systemctl list-unit-files --no-legend "${svc}.service" >/dev/null 2>&1; then if systemctl is-active --quiet "$svc" 2>/dev/null; then log "${svc} running" else warn "${svc} not active — see: journalctl -u ${svc} -n 30" fi fi done # unbound functional probe: ask the local resolver for a canonical # name; getent goes through nss-dns which honours /etc/resolv.conf, so # we use a direct query tool if installed, otherwise just check the # socket binding. Either tells us the resolver is actually answering. if systemctl is-active --quiet unbound 2>/dev/null; then if command -v unbound-host >/dev/null 2>&1; then if unbound-host -r -t a 1.1.1.1.nip.io @127.0.0.1 >/dev/null 2>&1; then log "unbound answering on 127.0.0.1:53" else warn "unbound is up but failed a test query — DNS may be broken" fi elif ss -tlnp 2>/dev/null | grep -q "127.0.0.1:53.*unbound"; then log "unbound listening on 127.0.0.1:53" fi fi # ─── Cluster ops ────────────────────────────────────────────────────────────── case "$MODE" in init) if [ -f /etc/nmg/secret/cluster-ca.crt ]; then warn "cluster CA already present — running cluster-init as noop" else log "Initialising a new cluster (CA + shared session-signing key)..." fi nmg-ctl cluster-init --org "$ORG" # Restart API so the freshly generated session-signing key is used. systemctl restart nmg-api # Mint a first join-token directly via the CLI (reads cluster.json # server-side without needing the API to be up yet). TOKEN=$(nmg-ctl cluster-join-token --note "first-node bootstrap" 2>/dev/null || true) if [ -n "$TOKEN" ]; then echo "" echo -e "${CYN}══════════════════════════════════════════════════════════${NC}" echo -e "${BLD}Cluster ready. Join a second node with:${NC}" echo "" echo -e " curl -fsSL https://get.netcell-mailguard.de | sudo bash -s -- \\" echo -e " --join https://$(hostname -f) --token ${BLD}${TOKEN}${NC}" echo "" echo -e "${YLW}Token is one-shot and expires in 24h.${NC}" echo -e "${CYN}══════════════════════════════════════════════════════════${NC}" fi ;; join) log "Joining existing cluster at $JOIN_URL..." JOIN_ARGS=(--from "$JOIN_URL" --token "$JOIN_TOKEN") if [ -n "$FQDN_OVERRIDE" ]; then JOIN_ARGS+=(--fqdn "$FQDN_OVERRIDE") fi nmg-ctl cluster-join "${JOIN_ARGS[@]}" # After join we restart the API so the session signer picks up the # cluster-wide shared key. systemctl restart nmg-api ;; install) # No-op — just package install. ;; esac # ─── Result summary ─────────────────────────────────────────────────────────── SERVER_IP=$(hostname -I | awk '{print $1}') VERSION_INSTALLED=$(dpkg-query -W -f='${Version}' nmg 2>/dev/null || echo "?") # Best-effort FQDN: prefer hostname -f (resolver-aware), fall back to # /etc/hostname, drop the value if it looks like a single-label name # (like "Test-WebPanel") that won't resolve in DNS anyway. SERVER_HOST=$(hostname -f 2>/dev/null || hostname) case "$SERVER_HOST" in *.*) ;; # has at least one dot — looks fqdn-ish *) SERVER_HOST=""; ;; esac # Print one URL per row so each is independently click/copy-able from # the terminal. Both rows go to the same listener; admins pick whichever # matches their DNS / browser certificate-trust setup. print_urls() { local path="$1" echo -e " ${CYN}https://${SERVER_IP}:3443${path}${NC}" [ -n "$SERVER_HOST" ] && echo -e " ${CYN}https://${SERVER_HOST}:3443${path}${NC}" } echo "" echo -e "${GRN}══════════════════════════════════════════════════════════${NC}" echo -e "${GRN} ${BLD}Installation complete${NC} (${VERSION_INSTALLED})" echo -e "${GRN}══════════════════════════════════════════════════════════${NC}" echo "" case "$MODE" in init) echo -e " ${BLD}Admin UI:${NC}" print_urls "/" echo "" echo -e " ${BLD}Setup wizard:${NC}" print_urls "/setup" ;; join) echo -e " Node joined the cluster. Admin UI on this node:" print_urls "/" echo -e " Existing admin credentials from the seed node work here too" echo -e " (cluster-shared session key)." ;; *) echo -e " ${BLD}Admin UI / Setup wizard:${NC}" print_urls "/setup" echo "" echo -e " Next step — run one of:" echo -e " sudo nmg-ctl cluster-init # new cluster" echo -e " sudo nmg-ctl cluster-join --from --token " ;; esac echo "" echo -e " ${CYN}Logs:${NC}" echo -e " journalctl -u nmg-api -f" echo -e " journalctl -u nmg-scheduler -f" echo ""