2b84a8a493
The user works in German but wants the artefacts in English throughout.
Translated were roughly 1,500 lines: every comment in the configuration
files, all comments in the Go sources, both shell scripts, and the three
manuals.
Renamed along with it, so nothing is left half-translated:
- install/vorlagen-pruefen -> install/verify-templates
- install/mailserver.conf.beispiel -> install/mailserver.conf.example
- docs/betrieb.md -> docs/operations.md
- shell functions and variables (schritt/abbruch/einsetzen/ZIEL/BEHALTEN
-> stage/die/deploy/DEST/KEEP), the ten installer stages, the Dovecot
quota root "Postfach" -> "Mailbox" and the sieve_script names
lernspam/lernham -> learnspam/learnham
- the comment headers that mailctl writes into the generated map files
Two things this dug up while translating:
- Perl treats $) and $/ as variables. A careless s{}{} put a NUL byte into
the regular expression documented in lang.go and mangled a line in
list.go. Both repaired; the sources were checked for NUL bytes and the
generated maps compared against the previous ones - the expressions
themselves are unchanged.
- The map files are only rewritten on a change, so their German headers
survived the first pass. Regenerated and verified line by line.
The origin server was brought along in the same step: configuration
deployed, Sieve scripts recompiled, mailctl rebuilt, services reloaded,
maps regenerated. Checked afterwards: postfix check, doveconf -n,
nft -c, unbound-checkconf, rspamadm configtest all pass; block list and
language filter still fire; a test message went through the full chain
into the mailbox. install/verify-templates reports 37 files identical,
no differences at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
638 lines
23 KiB
Bash
Executable File
638 lines
23 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Installs the complete mail server on a fresh Debian 13.
|
|
#
|
|
# Postfix + Dovecot + Rspamd + Redis + unbound + nftables + fail2ban
|
|
# administered through the command line tool mailctl.
|
|
#
|
|
# Usage:
|
|
# sudo install/mailserver-install # asks for what is missing
|
|
# sudo install/mailserver-install --config x.conf
|
|
# sudo install/mailserver-install --only packages # a single stage
|
|
#
|
|
# The script is repeatable: every stage first checks whether its work is
|
|
# already done. An abort halfway through can therefore simply be continued by
|
|
# calling it again.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
STAMP=$(date +%Y-%m-%d_%H%M)
|
|
BACKUP="/var/backups/mailserver-install_$STAMP"
|
|
|
|
# ---------------------------------------------------------------- Output
|
|
|
|
if [ -t 1 ]; then
|
|
C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m'
|
|
C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_OFF=$'\033[0m'
|
|
else
|
|
C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_OFF=""
|
|
fi
|
|
|
|
stage() { printf '\n%s══ %s%s\n' "$C_BOLD" "$*" "$C_OFF"; }
|
|
ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_OFF" "$*"; }
|
|
info() { printf ' %s·%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
|
|
warn() { printf ' %s!%s %s\n' "$C_YELLOW" "$C_OFF" "$*" >&2; }
|
|
die() { printf '\n%s✗ %s%s\n\n' "$C_RED" "$*" "$C_OFF" >&2; exit 1; }
|
|
|
|
# ask puts a question. Without a terminal the default applies.
|
|
ask() {
|
|
local text="$1" default="${2:-}" answer
|
|
if [ "$ASSUME_YES" = "1" ] || [ ! -t 0 ]; then
|
|
printf '%s\n' "$default"
|
|
return
|
|
fi
|
|
if [ -n "$default" ]; then
|
|
read -r -p " $text [$default]: " answer
|
|
printf '%s\n' "${answer:-$default}"
|
|
else
|
|
while [ -z "${answer:-}" ]; do
|
|
read -r -p " $text: " answer
|
|
done
|
|
printf '%s\n' "$answer"
|
|
fi
|
|
}
|
|
|
|
confirm() {
|
|
[ "$ASSUME_YES" = "1" ] && return 0
|
|
[ ! -t 0 ] && return 0
|
|
local answer
|
|
read -r -p " $1 [y/N] " answer
|
|
case "${answer,,}" in y|yes) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------- Arguments
|
|
|
|
CONFIG="$REPO/install/mailserver.conf"
|
|
ASSUME_YES=0
|
|
ONLY=""
|
|
SELF_SIGNED=0
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) CONFIG="$2"; shift 2 ;;
|
|
--only) ONLY="$2"; shift 2 ;;
|
|
--yes|-y) ASSUME_YES=1; shift ;;
|
|
--self-signed) SELF_SIGNED=1; shift ;;
|
|
--help|-h)
|
|
sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
printf '\nStages for --only:\n %s\n\n' "checks packages users config resolver firewall certificate mailctl services firstdomain"
|
|
exit 0 ;;
|
|
*) die "Unknown option: $1 (try --help)" ;;
|
|
esac
|
|
done
|
|
|
|
# ---------------------------------------------------------------- Settings
|
|
|
|
load_settings() {
|
|
if [ -f "$CONFIG" ]; then
|
|
# shellcheck source=/dev/null
|
|
. "$CONFIG"
|
|
info "Read settings from $CONFIG"
|
|
else
|
|
info "No $CONFIG - asking instead."
|
|
fi
|
|
|
|
MAILHOST=${MAILHOST:-}
|
|
MAILDOMAIN=${MAILDOMAIN:-}
|
|
SERVER_IP=${SERVER_IP:-}
|
|
SSH_PORT=${SSH_PORT:-}
|
|
ACME_EMAIL=${ACME_EMAIL:-}
|
|
POSTMASTER_QUOTA=${POSTMASTER_QUOTA:-1G}
|
|
ALLOWED_LANGUAGES=${ALLOWED_LANGUAGES-de en}
|
|
|
|
[ -n "$MAILHOST" ] || MAILHOST=$(ask "Fully qualified name of this server (e.g. mail.example.com)" "$(hostname -f 2>/dev/null || true)")
|
|
# The mail domain is almost always the name without its first label.
|
|
[ -n "$MAILDOMAIN" ] || MAILDOMAIN=$(ask "First mail domain (the part after the @)" "${MAILHOST#*.}")
|
|
[ -n "$SERVER_IP" ] || SERVER_IP=$(ask "Public IPv4 address of this server" "$(detect_ip)")
|
|
[ -n "$SSH_PORT" ] || SSH_PORT=$(ask "Port the SSH daemon listens on" "$(detect_ssh_port)")
|
|
[ -n "$ACME_EMAIL" ] || ACME_EMAIL="postmaster@$MAILDOMAIN"
|
|
|
|
case "$MAILHOST" in
|
|
*.*) : ;;
|
|
*) die "MAILHOST must be a fully qualified name, got: $MAILHOST" ;;
|
|
esac
|
|
case "$MAILDOMAIN" in
|
|
*.*) : ;;
|
|
*) die "MAILDOMAIN must contain a dot, got: $MAILDOMAIN" ;;
|
|
esac
|
|
}
|
|
|
|
# detect_ip asks the kernel for the address it speaks to the world with.
|
|
# No packet is sent.
|
|
detect_ip() {
|
|
ip -4 route get 198.51.100.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1
|
|
}
|
|
|
|
# detect_ssh_port reads the port actually in use from the running
|
|
# configuration - more reliable than the file, which may pull in includes.
|
|
detect_ssh_port() {
|
|
local port
|
|
port=$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true)
|
|
printf '%s' "${port:-22}"
|
|
}
|
|
|
|
show_settings() {
|
|
printf '\n%sSettings%s\n' "$C_BOLD" "$C_OFF"
|
|
printf ' Server name %s\n' "$MAILHOST"
|
|
printf ' Mail domain %s\n' "$MAILDOMAIN"
|
|
printf ' IPv4 address %s\n' "$SERVER_IP"
|
|
printf ' SSH port %s\n' "$SSH_PORT"
|
|
printf ' ACME contact %s\n' "$ACME_EMAIL"
|
|
printf ' Languages %s\n' "${ALLOWED_LANGUAGES:-(filter off)}"
|
|
printf '\n'
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 1 Checks
|
|
|
|
stage_checks() {
|
|
stage "1/10 Checking the ground"
|
|
|
|
[ "$(id -u)" = "0" ] || die "This must run as root."
|
|
|
|
local version
|
|
version=$(cut -d. -f1 /etc/debian_version 2>/dev/null || echo "?")
|
|
if [ "$version" = "13" ]; then
|
|
ok "Debian $(cat /etc/debian_version)"
|
|
else
|
|
warn "Built and tested on Debian 13, found: $(cat /etc/debian_version 2>/dev/null || echo unknown)"
|
|
warn "Dovecot 2.4 changed its configuration language - 2.3 systems will NOT work."
|
|
confirm "Continue anyway?" || die "Stopped."
|
|
fi
|
|
|
|
# The single most important test: does the name point here in DNS?
|
|
# Without it Let's Encrypt fails later, with an error message that hides
|
|
# the actual cause rather well.
|
|
local resolved
|
|
resolved=$(getent ahostsv4 "$MAILHOST" 2>/dev/null | awk '{print $1; exit}' || true)
|
|
if [ "$resolved" = "$SERVER_IP" ]; then
|
|
ok "$MAILHOST resolves to $SERVER_IP"
|
|
elif [ -z "$resolved" ]; then
|
|
warn "$MAILHOST does not resolve at all."
|
|
warn "Publish the A record first, or Let's Encrypt will fail."
|
|
confirm "Continue anyway?" || die "Stopped."
|
|
else
|
|
warn "$MAILHOST resolves to $resolved, not to $SERVER_IP."
|
|
confirm "Continue anyway?" || die "Stopped."
|
|
fi
|
|
|
|
# Outbound port 25 is blocked at many hosting providers. Otherwise this
|
|
# only surfaces when the first message fails to arrive.
|
|
if command -v nc >/dev/null 2>&1; then
|
|
if nc -z -w5 gmail-smtp-in.l.google.com 25 2>/dev/null; then
|
|
ok "Outbound port 25 is open"
|
|
else
|
|
warn "Outbound port 25 appears blocked - ask the hosting provider to unblock it."
|
|
warn "Everything else works; this server just could not deliver mail."
|
|
fi
|
|
fi
|
|
|
|
# Port 80 has to be free for the ACME challenge.
|
|
if ss -lntH 2>/dev/null | awk '{print $4}' | grep -qE ':80$'; then
|
|
warn "Something already listens on port 80 - Let's Encrypt (standalone) needs it free."
|
|
fi
|
|
|
|
local ram
|
|
ram=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)
|
|
if [ "$ram" -lt 1800 ]; then
|
|
warn "Only ${ram} MB RAM. Rspamd and Redis want 2 GB or more."
|
|
else
|
|
ok "${ram} MB RAM"
|
|
fi
|
|
|
|
ok "Ground checks done"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 2 Packages
|
|
|
|
PACKAGES="postfix postfix-sqlite postfix-pcre
|
|
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-managesieved dovecot-sieve dovecot-sqlite
|
|
rspamd redis-server unbound
|
|
nftables fail2ban
|
|
certbot
|
|
sqlite3 golang-go make
|
|
ca-certificates curl bind9-dnsutils netcat-traditional swaks"
|
|
|
|
stage_packages() {
|
|
stage "2/10 Installing packages"
|
|
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
|
|
# Postfix would otherwise open a dialogue asking for server type and mail
|
|
# name.
|
|
debconf-set-selections <<EOF
|
|
postfix postfix/main_mailer_type select Internet Site
|
|
postfix postfix/mailname string $MAILHOST
|
|
EOF
|
|
|
|
apt-get update -qq
|
|
# shellcheck disable=SC2086
|
|
apt-get install -y -qq $PACKAGES
|
|
|
|
ok "Packages installed"
|
|
info "Go $(go version 2>/dev/null | awk '{print $3}'), Dovecot $(dovecot --version 2>/dev/null | awk '{print $1}')"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 3 Users
|
|
|
|
stage_users() {
|
|
stage "3/10 Users, groups and directories"
|
|
|
|
if ! getent group vmail >/dev/null; then
|
|
groupadd -g 5000 vmail
|
|
fi
|
|
if ! getent passwd vmail >/dev/null; then
|
|
useradd -g vmail -u 5000 -d /var/vmail -s /usr/sbin/nologin \
|
|
-c "Virtual mailboxes" -m vmail
|
|
fi
|
|
ok "User vmail (uid/gid 5000)"
|
|
|
|
# A dedicated group for read access to the database. Without it, mail.db
|
|
# would have to be world-readable - and it holds the password hashes.
|
|
if ! getent group mailauth >/dev/null; then
|
|
groupadd --system mailauth
|
|
fi
|
|
for service in postfix dovecot dovenull _rspamd; do
|
|
if getent passwd "$service" >/dev/null; then
|
|
usermod -aG mailauth "$service"
|
|
fi
|
|
done
|
|
ok "Group mailauth: $(getent group mailauth | cut -d: -f4)"
|
|
|
|
install -d -o vmail -g vmail -m 0750 /var/vmail
|
|
install -d -o root -g root -m 0755 /etc/mailserver
|
|
install -d -o _rspamd -g _rspamd -m 0750 /var/lib/rspamd/dkim
|
|
ok "Directories created"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 4 Configuration
|
|
|
|
# deploy copies a template, filling in the placeholders on the way. An
|
|
# existing file is backed up first - a fresh installation must not wipe out an
|
|
# existing setup without trace.
|
|
deploy() {
|
|
local source="$1" target="$2" owner="${3:-root:root}" mode="${4:-0644}"
|
|
|
|
if [ -f "$target" ] && ! cmp -s <(render "$source") "$target"; then
|
|
install -d -m 0700 "$BACKUP"
|
|
cp -a "$target" "$BACKUP/$(echo "${target#/}" | tr / _)"
|
|
fi
|
|
install -d "$(dirname "$target")"
|
|
render "$source" > "$target"
|
|
chown "$owner" "$target"
|
|
chmod "$mode" "$target"
|
|
}
|
|
|
|
render() {
|
|
sed -e "s|@@MAILHOST@@|$MAILHOST|g" \
|
|
-e "s|@@MAILDOMAIN@@|$MAILDOMAIN|g" \
|
|
-e "s|@@SERVER_IP@@|$SERVER_IP|g" \
|
|
-e "s|@@SSH_PORT@@|$SSH_PORT|g" \
|
|
"$1"
|
|
}
|
|
|
|
stage_config() {
|
|
stage "4/10 Configuration files"
|
|
|
|
printf '%s\n' "$MAILHOST" > /etc/mailname
|
|
|
|
# --- Database --------------------------------------------------
|
|
deploy "$REPO/config/mailserver/schema.sql" /etc/mailserver/schema.sql
|
|
# A pointer to the documentation. It is maintained exclusively in the
|
|
# repository - a second copy on the server would only drift apart.
|
|
deploy "$REPO/config/mailserver/README.md" /etc/mailserver/README.md
|
|
if [ ! -f /etc/mailserver/mail.db ]; then
|
|
sqlite3 /etc/mailserver/mail.db < /etc/mailserver/schema.sql
|
|
ok "Database created at /etc/mailserver/mail.db"
|
|
else
|
|
# Repeat run: add missing tables, delete nothing.
|
|
sqlite3 /etc/mailserver/mail.db < /etc/mailserver/schema.sql
|
|
ok "Database already present - schema brought up to date"
|
|
fi
|
|
chown root:mailauth /etc/mailserver/mail.db
|
|
chmod 0640 /etc/mailserver/mail.db
|
|
|
|
# Site data for mailctl and mailbackup. Deliberately in shell format so
|
|
# that both can read the same file.
|
|
cat > /etc/mailserver/server.conf <<EOF
|
|
# Site data of this mail server. Written by the installer on $STAMP.
|
|
# Read by mailctl (site.go) and by the scripts under install/.
|
|
MAILHOST="$MAILHOST"
|
|
MAILDOMAIN="$MAILDOMAIN"
|
|
SERVER_IP="$SERVER_IP"
|
|
SSH_PORT="$SSH_PORT"
|
|
EOF
|
|
chmod 0644 /etc/mailserver/server.conf
|
|
ok "Site data written to /etc/mailserver/server.conf"
|
|
|
|
# --- Postfix ---------------------------------------------------
|
|
deploy "$REPO/config/postfix/main.cf" /etc/postfix/main.cf
|
|
deploy "$REPO/config/postfix/master.cf" /etc/postfix/master.cf
|
|
deploy "$REPO/config/postfix/submission_header_checks" /etc/postfix/submission_header_checks
|
|
deploy "$REPO/config/postfix/postscreen_access.cidr" /etc/postfix/postscreen_access.cidr
|
|
install -d -m 0755 /etc/postfix/sqlite
|
|
for f in "$REPO"/config/postfix/sqlite/*.cf; do
|
|
# These hold the path to the database, but no passwords.
|
|
deploy "$f" "/etc/postfix/sqlite/$(basename "$f")"
|
|
done
|
|
newaliases 2>/dev/null || true
|
|
ok "Postfix"
|
|
|
|
# --- Dovecot ---------------------------------------------------
|
|
deploy "$REPO/config/dovecot/dovecot.conf" /etc/dovecot/dovecot.conf
|
|
install -d -o vmail -g vmail -m 0755 /etc/dovecot/sieve/bin
|
|
for f in "$REPO"/config/dovecot/sieve/*.sieve; do
|
|
deploy "$f" "/etc/dovecot/sieve/$(basename "$f")" vmail:vmail 0644
|
|
done
|
|
for f in "$REPO"/config/dovecot/sieve/bin/*.sh; do
|
|
deploy "$f" "/etc/dovecot/sieve/bin/$(basename "$f")" vmail:vmail 0755
|
|
done
|
|
|
|
# Dovecot's systemd unit runs with ProtectSystem=full, so /etc is
|
|
# read-only for Dovecot. It therefore cannot compile the scripts itself -
|
|
# that has to happen here, or "Read-only file system" errors turn up in
|
|
# the log later on.
|
|
for f in /etc/dovecot/sieve/*.sieve; do
|
|
sievec "$f"
|
|
chown vmail:vmail "${f%.sieve}.svbin"
|
|
done
|
|
ok "Dovecot, Sieve scripts compiled"
|
|
|
|
# --- Rspamd ----------------------------------------------------
|
|
install -d -m 0755 /etc/rspamd/local.d
|
|
for f in "$REPO"/config/rspamd/local.d/*; do
|
|
deploy "$f" "/etc/rspamd/local.d/$(basename "$f")"
|
|
done
|
|
ok "Rspamd"
|
|
info "options.inc and worker-controller.inc keep the .inc suffix on purpose -"
|
|
info "rspamd includes those two sections as .inc and silently ignores a .conf."
|
|
|
|
# --- fail2ban --------------------------------------------------
|
|
deploy "$REPO/config/fail2ban/jail.d/mailserver.local" /etc/fail2ban/jail.d/mailserver.local
|
|
deploy "$REPO/config/fail2ban/filter.d/dovecot.local" /etc/fail2ban/filter.d/dovecot.local
|
|
ok "fail2ban"
|
|
|
|
# --- Backup ----------------------------------------------------
|
|
install -m 0755 "$REPO/bin/mailbackup" /usr/local/sbin/mailbackup
|
|
deploy "$REPO/config/systemd/mailbackup.service" /etc/systemd/system/mailbackup.service
|
|
deploy "$REPO/config/systemd/mailbackup.timer" /etc/systemd/system/mailbackup.timer
|
|
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
|
|
deploy "$REPO/config/10-reload-mail.sh" /etc/letsencrypt/renewal-hooks/deploy/10-reload-mail.sh root:root 0755
|
|
ok "Backup timer and certificate hook"
|
|
|
|
[ -d "$BACKUP" ] && info "Replaced files backed up to $BACKUP"
|
|
return 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 5 Resolver
|
|
|
|
stage_resolver() {
|
|
stage "5/10 Local validating resolver"
|
|
|
|
deploy "$REPO/config/unbound/mailserver.conf" /etc/unbound/unbound.conf.d/mailserver.conf
|
|
systemctl enable --now unbound >/dev/null 2>&1 || true
|
|
systemctl restart unbound
|
|
|
|
# /etc/resolv.conf has to point at 127.0.0.1, otherwise the DNSBL queries
|
|
# keep going through the provider's resolver - and the operators of the
|
|
# blocklists block exactly those queries.
|
|
#
|
|
# dhcpcd rewrites the file on every lease; resolv.conf.head survives that.
|
|
deploy "$REPO/config/resolv.conf.head" /etc/resolv.conf.head
|
|
if ! grep -qE '^\s*nameserver\s+127\.0\.0\.1\s*$' /etc/resolv.conf; then
|
|
cp -a /etc/resolv.conf "/etc/resolv.conf.before-mailserver_$STAMP"
|
|
cat /etc/resolv.conf.head "/etc/resolv.conf.before-mailserver_$STAMP" > /etc/resolv.conf
|
|
info "Prepended 127.0.0.1 to /etc/resolv.conf (old copy kept alongside)"
|
|
fi
|
|
|
|
if dig +short +time=3 +tries=1 @127.0.0.1 debian.org A >/dev/null 2>&1; then
|
|
ok "unbound answers on 127.0.0.1"
|
|
else
|
|
warn "unbound is not answering - DNSBL checks and DANE will not work."
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 6 Firewall
|
|
|
|
stage_firewall() {
|
|
stage "6/10 Firewall"
|
|
|
|
# The most dangerous step of the whole installation: a wrong SSH port
|
|
# number locks the running session out, and immediately. Hence the check
|
|
# against the ports actually being listened on.
|
|
local listening
|
|
listening=$(ss -lntH 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' ')
|
|
if ! printf '%s' " $listening" | grep -q " $SSH_PORT "; then
|
|
warn "Nothing is listening on port $SSH_PORT."
|
|
warn "Ports in use right now: $listening"
|
|
warn "Applying the firewall now would lock you out of this session."
|
|
confirm "Really continue?" || die "Stopped - fix SSH_PORT and run again."
|
|
else
|
|
ok "sshd is listening on port $SSH_PORT"
|
|
fi
|
|
|
|
deploy "$REPO/config/nftables.conf" /etc/nftables.conf root:root 0755
|
|
|
|
# Check first, then apply. nft -c loads the ruleset as a trial.
|
|
nft -c -f /etc/nftables.conf || die "The firewall ruleset has a syntax error - nothing applied."
|
|
systemctl enable nftables >/dev/null 2>&1 || true
|
|
systemctl restart nftables
|
|
ok "Firewall active: SSH $SSH_PORT, 25, 80, 143, 465, 587, 993, 4190"
|
|
|
|
systemctl enable fail2ban >/dev/null 2>&1 || true
|
|
systemctl restart fail2ban || warn "fail2ban did not start - check: journalctl -u fail2ban"
|
|
ok "fail2ban active"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 7 Certificate
|
|
|
|
stage_certificate() {
|
|
stage "7/10 TLS certificate"
|
|
|
|
if [ -f "/etc/letsencrypt/live/$MAILHOST/fullchain.pem" ]; then
|
|
ok "Certificate for $MAILHOST already present"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$SELF_SIGNED" = "1" ]; then
|
|
# For lab setups only. Mail clients and foreign mail servers refuse a
|
|
# self-signed certificate.
|
|
install -d -m 0755 "/etc/letsencrypt/live/$MAILHOST"
|
|
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
|
|
-subj "/CN=$MAILHOST" \
|
|
-keyout "/etc/letsencrypt/live/$MAILHOST/privkey.pem" \
|
|
-out "/etc/letsencrypt/live/$MAILHOST/fullchain.pem" 2>/dev/null
|
|
chmod 0600 "/etc/letsencrypt/live/$MAILHOST/privkey.pem"
|
|
warn "Self-signed certificate created - for testing only."
|
|
warn "Replace it before going live: certbot certonly --standalone -d $MAILHOST"
|
|
return 0
|
|
fi
|
|
|
|
# Postfix does not occupy port 80, but a web server might. The standalone
|
|
# mode needs it free.
|
|
info "Asking Let's Encrypt for a certificate (needs port 80 reachable from outside)"
|
|
if certbot certonly --standalone --non-interactive --agree-tos \
|
|
--email "$ACME_EMAIL" -d "$MAILHOST"; then
|
|
ok "Certificate issued for $MAILHOST"
|
|
else
|
|
warn "certbot failed. The usual causes:"
|
|
warn " - the A record for $MAILHOST does not point here yet"
|
|
warn " - port 80 is not reachable from the internet"
|
|
warn "Run again later with: $0 --only certificate"
|
|
warn "Or for a lab setup: $0 --only certificate --self-signed"
|
|
die "Without a certificate Dovecot and Postfix will not start."
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 8 mailctl
|
|
|
|
stage_mailctl() {
|
|
stage "8/10 Building mailctl"
|
|
|
|
local go_bin
|
|
go_bin=$(command -v go || echo /usr/lib/go-1.24/bin/go)
|
|
[ -x "$go_bin" ] || die "No Go compiler found - is golang-go installed?"
|
|
|
|
install -d -m 0755 /usr/local/src
|
|
# Take the sources along so that later changes can be compiled on the
|
|
# server itself.
|
|
rm -rf /usr/local/src/mailctl
|
|
cp -a "$REPO/src/mailctl" /usr/local/src/mailctl
|
|
|
|
info "Fetching Go modules (needs access to proxy.golang.org)"
|
|
( cd /usr/local/src/mailctl && make GO="$go_bin" install ) \
|
|
|| die "mailctl did not build. See the output above."
|
|
|
|
ok "mailctl installed to /usr/local/sbin/mailctl"
|
|
info "Sources under /usr/local/src/mailctl - rebuild with: make install"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 9 Services
|
|
|
|
stage_services() {
|
|
stage "9/10 Starting services"
|
|
|
|
systemctl enable --now redis-server >/dev/null 2>&1 || true
|
|
|
|
# Rspamd first: otherwise Postfix defers every message with "tempfail",
|
|
# because the milter is missing (milter_default_action = tempfail).
|
|
systemctl enable rspamd >/dev/null 2>&1 || true
|
|
systemctl restart rspamd || warn "rspamd did not start - check: journalctl -u rspamd"
|
|
|
|
postfix check || die "Postfix reports a configuration error."
|
|
systemctl enable postfix >/dev/null 2>&1 || true
|
|
systemctl restart postfix
|
|
|
|
doveconf -n >/dev/null || die "Dovecot reports a configuration error."
|
|
systemctl enable dovecot >/dev/null 2>&1 || true
|
|
systemctl restart dovecot
|
|
|
|
systemctl enable --now mailbackup.timer >/dev/null 2>&1 || true
|
|
|
|
local failed=0
|
|
for service in postfix dovecot rspamd redis-server unbound nftables fail2ban; do
|
|
if systemctl is-active --quiet "$service"; then
|
|
ok "$service"
|
|
else
|
|
warn "$service is NOT running - check: journalctl -u $service"
|
|
failed=1
|
|
fi
|
|
done
|
|
[ "$failed" = "0" ] || warn "Some services did not come up. Fix those before sending mail."
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 10 First domain
|
|
|
|
stage_firstdomain() {
|
|
stage "10/10 First domain and postmaster mailbox"
|
|
|
|
# Look in the database directly rather than searching mailctl's output:
|
|
# "grep -w example.com" would also fire on sub.example.com, because the
|
|
# dot counts as a word boundary.
|
|
row_exists() {
|
|
[ -n "$(sqlite3 /etc/mailserver/mail.db "$1" 2>/dev/null)" ]
|
|
}
|
|
|
|
if row_exists "SELECT 1 FROM domains WHERE name = '$MAILDOMAIN'"; then
|
|
ok "Domain $MAILDOMAIN already exists"
|
|
else
|
|
mailctl domain add "$MAILDOMAIN"
|
|
fi
|
|
|
|
if row_exists "SELECT 1 FROM users WHERE email = 'postmaster@$MAILDOMAIN'"; then
|
|
ok "Mailbox postmaster@$MAILDOMAIN already exists"
|
|
else
|
|
# A postmaster@ mailbox is not optional: RFC 5321 requires the
|
|
# address, and blocklist operators write to it.
|
|
mailctl user add "postmaster@$MAILDOMAIN" -g -q "$POSTMASTER_QUOTA"
|
|
fi
|
|
|
|
if [ -n "${ALLOWED_LANGUAGES:-}" ]; then
|
|
# shellcheck disable=SC2086
|
|
mailctl lang allow $ALLOWED_LANGUAGES
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------- Closing
|
|
|
|
closing() {
|
|
printf '\n%s══ Done%s\n\n' "$C_BOLD" "$C_OFF"
|
|
printf '%sPublish these DNS records now:%s\n' "$C_BOLD" "$C_OFF"
|
|
mailctl dns "$MAILDOMAIN" || true
|
|
cat <<EOF
|
|
${C_BOLD}Then, in order:${C_OFF}
|
|
|
|
1. Publish the records above with the DNS provider of $MAILDOMAIN.
|
|
2. Set the PTR record for $SERVER_IP to $MAILHOST.
|
|
That happens at the hosting provider, not in the domain's DNS.
|
|
Without it Gmail and Outlook treat every message as spam.
|
|
3. Ask the provider to unblock outbound port 25 if it is closed.
|
|
4. Wait for the records to spread, then check:
|
|
|
|
mailctl check $MAILDOMAIN
|
|
|
|
5. Create the real mailboxes:
|
|
|
|
mailctl user add you@$MAILDOMAIN -g -q 5G
|
|
|
|
6. Send a test message to check-auth@verifier.port25.com from that
|
|
mailbox. The reply states whether SPF, DKIM and iprev pass.
|
|
|
|
${C_BOLD}Day-to-day:${C_OFF}
|
|
|
|
mailctl status services, totals, queue, certificate
|
|
mailctl help every command
|
|
docs/operations.md the operating manual
|
|
|
|
EOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------- Sequence
|
|
|
|
load_settings
|
|
show_settings
|
|
|
|
if [ -n "$ONLY" ]; then
|
|
case "$ONLY" in
|
|
checks|packages|users|config|resolver|firewall|certificate|mailctl|services|firstdomain)
|
|
"stage_$ONLY"
|
|
printf '\n' ;;
|
|
*) die "Unknown stage: $ONLY" ;;
|
|
esac
|
|
exit 0
|
|
fi
|
|
|
|
confirm "Install the mail server with these settings?" || die "Stopped."
|
|
|
|
stage_checks
|
|
stage_packages
|
|
stage_users
|
|
stage_config
|
|
stage_resolver
|
|
stage_firewall
|
|
stage_certificate
|
|
stage_mailctl
|
|
stage_services
|
|
stage_firstdomain
|
|
closing
|