mailwolt/installer.sh

998 lines
34 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env bash
set -euo pipefail
##############################################
# MailWolt #
# Bootstrap Installer v1.0 #
##############################################
# ===== Branding & Pfade (einmal ändern, überall wirksam) =====
APP_NAME="${APP_NAME:-MailWolt}"
APP_ENV="production"
APP_DEBUG="false"
APP_USER="${APP_USER:-mailwolt}"
APP_GROUP="${APP_GROUP:-www-data}"
# Projektverzeichnis
APP_DIR="/var/www/${APP_USER}"
# Konfigbasis (statt /etc/falcomail -> /etc/${APP_USER})
CONF_BASE="/etc/${APP_USER}"
CERT_DIR="${CONF_BASE}/ssl"
CERT="${CERT_DIR}/cert.pem"
KEY="${CERT_DIR}/key.pem"
# Nginx vHost
NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf"
NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf"
# DB
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
# DB_PASS muss vorher generiert oder gesetzt werden
# Git (Platzhalter; später ersetzen)
GIT_REPO="${GIT_REPO:-https://git.nexlab.at/boban/mailwolt.git}"
GIT_BRANCH="${GIT_BRANCH:-main}"
# Node Setup (deb = apt Pakete; nodesource = NodeSource LTS)
NODE_SETUP="${NODE_SETUP:-deb}"
# ===== Styling =====
GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m"
BAR="──────────────────────────────────────────────────────────────────────────────"
# ===== Install-Log =====
LOG_FILE="/var/log/mailwolt-install.log"
> "$LOG_FILE"
header() {
echo -e "${CYAN}${BAR}${NC}"
echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}"
echo -e "${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}"
echo -e "${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}"
echo -e "${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}"
echo -e "${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}"
echo -e "${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}"
echo -e "${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}"
echo -e "${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}"
echo -e "${CYAN}${BAR}${NC}"
echo
}
footer_ok() {
local ip="$1"
local app_name="${2:-$APP_NAME}"
local app_dir="${3:-$APP_DIR}"
local cert_dir="${4:-$CERT_DIR}"
echo
echo -e "${GREEN}${BAR}${NC}"
echo -e "${GREEN}${app_name} Installation erfolgreich abgeschlossen${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo -e ""
echo -e " ${CYAN}➜ Setup-Wizard jetzt öffnen:${NC}"
echo -e " ${CYAN}http://${ip}/setup${NC}"
echo -e ""
echo -e " Laravel Root: ${GREY}${app_dir}${NC}"
echo -e " Mail-TLS Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC} (Postfix/Dovecot)"
echo -e " Postfix/Dovecot: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}"
echo -e "${GREEN}${BAR}${NC}"
echo
}
_STEP_T=0
_SPIN_PID=
_spin_bg() {
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local n=${#chars} i=0
while true; do
printf "\r \033[0;90m${chars:$i:1}\033[0m"
i=$(( (i + 1) % n ))
sleep 0.1
done
}
start_spin() { _spin_bg & _SPIN_PID=$!; }
stop_spin() {
[[ -n "${_SPIN_PID:-}" ]] || return 0
kill "$_SPIN_PID" 2>/dev/null || true
wait "$_SPIN_PID" 2>/dev/null || true
_SPIN_PID=
printf "\r\033[K"
}
trap 'stop_spin' EXIT INT TERM
step() {
local msg="$1" dur="${2:-}"
[ -n "$dur" ] && dur=" ${GREY}(~${dur})${NC}" || dur=""
printf "\n${CYAN}${NC} %-42s%b\n" "$msg" "$dur"
_STEP_T=$SECONDS
}
ok() {
stop_spin
local t=$(( SECONDS - _STEP_T ))
[ $t -gt 1 ] && printf " ${GREEN}${NC} ${GREY}%ds${NC}\n" $t || printf " ${GREEN}${NC}\n"
}
warn() { stop_spin; printf " ${YELLOW}${NC} %s\n" "$*"; }
err() { stop_spin; printf " ${RED}${NC} %s\n" "$*"; }
quietly() {
start_spin
if ! "$@" >> "$LOG_FILE" 2>&1; then
stop_spin
printf " ${RED}${NC} Fehlgeschlagen. Letzte Log-Zeilen:\n\n"
tail -20 "$LOG_FILE" | sed 's/^/ /'
printf "\n ${GREY}Vollständiges Log: %s${NC}\n\n" "$LOG_FILE"
exit 1
fi
stop_spin
}
try_quiet() { "$@" >> "$LOG_FILE" 2>&1 || true; }
log() { :; }
require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; }
# ===== IP ermitteln =====
detect_ip() {
local ip
ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || { err "Konnte Server-IP nicht ermitteln."; exit 1; }
echo "$ip"
}
# ===== Secrets =====
gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" || true; }
pw() { gen 28; }
short() { gen 16; }
# ===== Argument-Parsing =====
while [[ $# -gt 0 ]]; do
case "$1" in
-dev) APP_ENV="local"; APP_DEBUG="true" ;;
-stag|-staging) APP_ENV="staging"; APP_DEBUG="false" ;;
esac
shift
done
# ===== Start =====
require_root
header
SERVER_IP="$(detect_ip)"
APP_PW="${APP_PW:-$(pw)}"
MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}" # Wizard setzt später FQDN
TZ="${TZ:-""}" # leer; Wizard setzt final
echo -e "\n ${GREY}Server-IP: ${SERVER_IP} Log: ${LOG_FILE}${NC}"
[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; }
step "Paketquellen aktualisieren" "10 Sek"
export DEBIAN_FRONTEND=noninteractive
quietly apt-get update -y
ok
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[ -f /etc/mysql/mariadb.cnf ] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
step "Pakete installieren" "25 Min"
quietly apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client \
redis-server \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd php-sqlite3 unzip curl acl \
composer \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit git
ok
step "Benutzer & Verzeichnisse anlegen" "5 Sek"
mkdir -p ${CERT_DIR} /etc/postfix /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
quietly bash -c "id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail"
chown -R vmail:vmail /var/mail
quietly bash -c "id '$APP_USER' >/dev/null 2>&1 || adduser --disabled-password --gecos '' '$APP_USER'"
quietly usermod -a -G www-data "$APP_USER"
ok
# ===== Self-signed TLS (SAN = IP) =====
CERT="${CERT_DIR}/cert.pem"
KEY="${CERT_DIR}/key.pem"
OSSL_CFG="${CERT_DIR}/openssl.cnf"
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
step "Self-Signed Zertifikat erstellen" "5 Sek"
cat > "$OSSL_CFG" <<CFG
[req]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${SERVER_IP}
O = ${APP_NAME}
C = DE
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
quietly openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chmod 600 "$KEY"; chmod 644 "$CERT"
ok
fi
# ===== MariaDB vorbereiten =====
step "Datenbank einrichten" "10 Sek"
quietly systemctl enable --now mariadb
DB_PASS="$(pw)"
quietly mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
ok
# ===== Postfix konfigurieren (25/465/587) =====
step "Mailserver konfigurieren (Postfix / Dovecot / Rspamd)" "15 Sek"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "smtp_dns_support_level = disabled"
postconf -e "smtpd_tls_cert_file = ${CERT}"
postconf -e "smtpd_tls_key_file = ${KEY}"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtpd_tls_auth_only = yes"
postconf -e "smtp_tls_security_level = may"
# Rspamd + OpenDKIM als Milter (accept)
postconf -e "milter_default_action = accept"
postconf -e "milter_protocol = 6"
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
# Auth via Dovecot
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
# Master-Services
postconf -M "smtp/inet=smtp inet n - n - - smtpd \
-o smtpd_peername_lookup=no \
-o smtpd_timeout=30s"
postconf -M "submission/inet=submission inet n - n - - smtpd \
-o syslog_name=postfix/submission \
-o smtpd_peername_lookup=no \
-o smtpd_tls_security_level=encrypt \
-o smtpd_sasl_auth_enable=yes \
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject \
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd \
-o syslog_name=postfix/smtps \
-o smtpd_peername_lookup=no \
-o smtpd_tls_wrappermode=yes \
-o smtpd_sasl_auth_enable=yes \
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject \
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "pickup/unix=pickup unix n - y 60 1 pickup"
postconf -M "cleanup/unix=cleanup unix n - y - 0 cleanup"
postconf -M "qmgr/unix=qmgr unix n - n 300 1 qmgr"
# --- Postfix SQL-Maps (Platzhalter) ---
install -d -o root -g postfix -m 750 /etc/postfix/sql
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT 1 FROM mail_users WHERE email = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-alias-maps.cf
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT destination FROM mail_aliases WHERE source = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
try_quiet systemctl enable --now postfix
# ===== Dovecot konfigurieren (IMAP/POP3 + SSL) =====
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
}
mail_privileged_group = mail
CONF
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
CONF
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
# password_query = SELECT email AS user, password_hash AS password
# FROM mail_users
# WHERE email = '%u' AND is_active = 1
# LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}
CONF
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
service imap-login {
inet_listener imap { port = 143 }
inet_listener imaps { port = 993 ssl = yes }
}
service pop3-login {
inet_listener pop3 { port = 110 }
inet_listener pop3s { port = 995 ssl = yes }
}
CONF
cat > /etc/dovecot/conf.d/10-ssl.conf <<CONF
ssl = required
ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
try_quiet systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
try_quiet systemctl enable --now rspamd
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
# KeyTable / SigningTable später im Wizard
CONF
try_quiet systemctl enable --now opendkim
try_quiet systemctl enable --now redis-server
ok
# ===== Nginx: Laravel vHost (80/443) =====
step "Webserver konfigurieren (Nginx)" "5 Sek"
rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SOCK="/run/php/php${PHPV}-fpm.sock"
[ -S "$PHP_FPM_SOCK" ] || PHP_FPM_SOCK="/run/php/php-fpm.sock"
cat > ${NGINX_SITE} <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files \$uri /index.php?\$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
access_log off;
}
}
CONF
ln -sf ${NGINX_SITE} ${NGINX_SITE_LINK}
try_quiet nginx -t
try_quiet systemctl enable --now nginx
ok
step "Projekt herunterladen (Git)" "15 Sek"
mkdir -p "$(dirname "$APP_DIR")"
chown "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
if [ ! -d "${APP_DIR}/.git" ]; then
rm -rf "${APP_DIR}"
quietly sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only"
fi
ok
# ===== .env erstellen und befüllen =====
APP_URL="http://${SERVER_IP}"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
sed -i "s|^APP_NAME=.*|APP_NAME=${APP_NAME}|g" "${APP_DIR}/.env"
sed -i "s|^APP_URL=.*|APP_URL=${APP_URL}|g" "${APP_DIR}/.env"
sed -i "s|^APP_ENV=.*|APP_ENV=${APP_ENV}|g" "${APP_DIR}/.env"
sed -i "s|^APP_DEBUG=.*|APP_DEBUG=${APP_DEBUG}|g" "${APP_DIR}/.env"
sed -i "s|^DB_CONNECTION=.*|DB_CONNECTION=mysql|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_HOST=.*|DB_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PORT=.*|DB_PORT=3306|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_DATABASE=.*|DB_DATABASE=${DB_NAME}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_USERNAME=.*|DB_USERNAME=${DB_USER}|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*DB_PASSWORD=.*|DB_PASSWORD=${DB_PASS}|g" "${APP_DIR}/.env"
sed -i "s|^CACHE_DRIVER=.*|CACHE_DRIVER=redis|g" "${APP_DIR}/.env"
sed -i -E "s|^[#[:space:]]*CACHE_PREFIX=.*|CACHE_PREFIX=${APP_USER}|g" "${APP_DIR}/.env"
sed -i "s|^SESSION_DRIVER=.*|SESSION_DRIVER=redis|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_HOST=.*|REDIS_HOST=127.0.0.1|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=null|g" "${APP_DIR}/.env"
sed -i "s|^REDIS_PORT=.*|REDIS_PORT=6379|g" "${APP_DIR}/.env"
REVERB_APP_ID="$(short)"
REVERB_APP_KEY="$(short)"
REVERB_APP_SECRET="$(short)"
grep -q '^REVERB_APP_ID=' "${APP_DIR}/.env" \
|| printf '\nBROADCAST_CONNECTION=reverb\nREVERB_APP_ID=%s\nREVERB_APP_KEY=%s\nREVERB_APP_SECRET=%s\nREVERB_HOST=127.0.0.1\nREVERB_PORT=8080\nREVERB_SCHEME=http\nVITE_REVERB_APP_KEY=%s\nVITE_REVERB_HOST=%s\nVITE_REVERB_PORT=8080\nVITE_REVERB_SCHEME=http\n' \
"$REVERB_APP_ID" "$REVERB_APP_KEY" "$REVERB_APP_SECRET" "$REVERB_APP_KEY" "$SERVER_IP" >> "${APP_DIR}/.env"
# Bootstrap-Admin für den ersten Login
BOOTSTRAP_USER="${APP_USER}"
BOOTSTRAP_EMAIL="${APP_USER}@localhost"
BOOTSTRAP_PASS="$(openssl rand -base64 18 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 12)"
BOOTSTRAP_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$BOOTSTRAP_PASS")"
grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" \
|| echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env"
sed -i "s|^SETUP_PHASE=.*|SETUP_PHASE=bootstrap|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_USER=.*|BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_EMAIL=.*|BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}|g" "${APP_DIR}/.env"
grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" \
|| echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env"
sed -i "s|^BOOTSTRAP_ADMIN_PASSWORD_HASH=.*|BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}|g" "${APP_DIR}/.env"
step "PHP-Abhängigkeiten installieren (Composer)" "12 Min"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-dev --optimize-autoloader --no-interaction"
ok
step "Datenbank migrieren" "15 Sek"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force"
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan migrate --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan storage:link --force"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan config:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan route:cache"
try_quiet sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan view:cache"
ok
if [ -f "${APP_DIR}/package.json" ]; then
step "Frontend bauen (npm)" "13 Min"
# Node/npm falls noch nicht installiert
if ! command -v node >/dev/null 2>&1; then
if [ "$NODE_SETUP" = "nodesource" ]; then
quietly bash -c "curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh && bash /tmp/nodesource_setup.sh && rm -f /tmp/nodesource_setup.sh"
quietly apt-get install -y nodejs
else
quietly apt-get install -y nodejs npm
fi
fi
quietly sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install"
if ! sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build" >> "$LOG_FILE" 2>&1; then
warn "npm run build fehlgeschlagen — manuell nachholen: cd ${APP_DIR} && npm run build"
else
ok
fi
fi
mkdir -p /var/lib/mailwolt/wizard
chown www-data:www-data /var/lib/mailwolt/wizard
chmod 775 /var/lib/mailwolt/wizard
step "Hilfsskripte & Konfiguration installieren" "5 Sek"
cat > /usr/local/sbin/mailwolt-apply-domains <<'HELPER'
#!/usr/bin/env bash
set -euo pipefail
UI_HOST=""; WEBMAIL_HOST=""; MAIL_HOST=""; SSL_AUTO=0
while [[ $# -gt 0 ]]; do
case "$1" in
--ui-host) UI_HOST="$2"; shift 2 ;;
--webmail-host) WEBMAIL_HOST="$2"; shift 2 ;;
--mail-host) MAIL_HOST="$2"; shift 2 ;;
--ssl-auto) SSL_AUTO="$2"; shift 2 ;;
*) shift ;;
esac
done
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SOCK="/run/php/php${PHPV}-fpm.sock"
[ -S "$PHP_FPM_SOCK" ] || PHP_FPM_SOCK="/run/php/php-fpm.sock"
APP_DIR="/var/www/mailwolt"
NGINX_SITE="/etc/nginx/sites-available/mailwolt.conf"
ACME_ROOT="/var/www/letsencrypt"
mkdir -p "${ACME_ROOT}/.well-known/acme-challenge"
# --- Phase 1: HTTP-only Vhosts mit ACME-Challenge ---
cat > "${NGINX_SITE}" <<CONF
server {
listen 80;
listen [::]:80;
server_name ${UI_HOST} ${WEBMAIL_HOST};
root ${APP_DIR}/public;
index index.php index.html;
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files \$uri /index.php?\$query_string;
}
}
CONF
nginx -t && systemctl reload nginx
# --- Phase 2: Let's Encrypt Zertifikate holen ---
# Prüfen ob Server globales IPv6 hat (nötig wenn AAAA-Records existieren)
has_global_ipv6() {
ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'
}
cert_needs_action() {
local domain="$1"
local cert="/etc/letsencrypt/live/${domain}/fullchain.pem"
[ ! -f "${cert}" ] && return 0
# 0 = gültig für >10 Tage → überspringen; 1 = läuft ab → erneuern
openssl x509 -checkend 864000 -noout -in "${cert}" 2>/dev/null && return 1
return 0
}
certbot_safe() {
local domain="$1"
local has_aaaa
has_aaaa=$(dig +short AAAA "${domain}" 2>/dev/null | head -1)
if [ -n "${has_aaaa}" ] && ! has_global_ipv6; then
echo "[!] ${domain}: AAAA-Record vorhanden aber kein IPv6 auf diesem Server — Let's Encrypt würde fehlschlagen. Self-signed wird verwendet." >&2
return 1
fi
certbot certonly --webroot \
-w "${ACME_ROOT}" \
-d "${domain}" \
--non-interactive --agree-tos \
--email "webmaster@${domain}" \
--no-eff-email
}
if [ "${SSL_AUTO}" = "1" ]; then
for DOMAIN in "${UI_HOST}" "${WEBMAIL_HOST}"; do
[ -z "${DOMAIN}" ] && continue
if cert_needs_action "${DOMAIN}"; then
certbot_safe "${DOMAIN}" || true
fi
done
fi
# --- Phase 3: Finale Vhosts ---
# Nur HTTPS wenn LE-Cert tatsächlich vorhanden, sonst HTTP-only (kein self-signed Fallback)
(
UI_HAS_CERT=0
WM_HAS_CERT=0
[ -f "/etc/letsencrypt/live/${UI_HOST}/fullchain.pem" ] && UI_HAS_CERT=1
[ -f "/etc/letsencrypt/live/${WEBMAIL_HOST}/fullchain.pem" ] && WM_HAS_CERT=1
if [ "${UI_HAS_CERT}" = "1" ] || [ "${WM_HAS_CERT}" = "1" ]; then
# Mindestens ein Cert vorhanden → HTTP-Redirect Block
cat <<CONF
server {
listen 80;
listen [::]:80;
server_name ${UI_HOST} ${WEBMAIL_HOST};
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / { return 301 https://\$host\$request_uri; }
}
CONF
else
# Kein Cert → HTTP-only, App läuft auf Port 80 weiter
cat <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
location /.well-known/acme-challenge/ {
root ${ACME_ROOT};
try_files \$uri =404;
}
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
if [ -n "${UI_HOST}" ] && [ "${UI_HAS_CERT}" = "1" ]; then
cat <<CONF
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${UI_HOST};
ssl_certificate /etc/letsencrypt/live/${UI_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${UI_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_param HTTPS on;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
if [ -n "${WEBMAIL_HOST}" ] && [ "${WM_HAS_CERT}" = "1" ]; then
cat <<CONF
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${WEBMAIL_HOST};
ssl_certificate /etc/letsencrypt/live/${WEBMAIL_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${WEBMAIL_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
location / { try_files \$uri \$uri/ /index.php?\$query_string; }
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_param HTTPS on;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ { try_files \$uri /index.php?\$query_string; }
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ { expires 30d; access_log off; }
}
CONF
fi
) > "${NGINX_SITE}"
# State-Dateien VOR dem nginx-Switch schreiben:
# Browser-Poll (alle 2s) liest done=1 → Polling stoppt → "Zum Login" erscheint.
# Danach 6s sleep → nginx switchet auf HTTPS → User klickt Link → funktioniert.
STATE_DIR="/var/lib/mailwolt/wizard"
if [ -d "${STATE_DIR}" ]; then
for k in ui mail webmail; do
[ -f "${STATE_DIR}/${k}" ] && printf "done" > "${STATE_DIR}/${k}"
done
if [ "${UI_HAS_CERT}" = "1" ] || [ "${WM_HAS_CERT}" = "1" ]; then
printf "1" > "${STATE_DIR}/done"
else
printf "0" > "${STATE_DIR}/done"
fi
sleep 6
fi
nginx -t && systemctl reload nginx
HELPER
chmod 755 /usr/local/sbin/mailwolt-apply-domains
# ===== mailwolt-update installieren =====
install -m 755 "${APP_DIR}/update.sh" /usr/local/sbin/mailwolt-update
# ===== Sudoers für www-data (helper + update) =====
cat > /etc/sudoers.d/mailwolt-certbot <<'SUDOERS'
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-apply-domains
www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-update
www-data ALL=(root) NOPASSWD: /usr/bin/certbot
SUDOERS
chmod 440 /etc/sudoers.d/mailwolt-certbot
# git safe.directory damit spätere pulls als root möglich sind
git config --global --add safe.directory "${APP_DIR}" || true
# ===== Version-Datei schreiben =====
mkdir -p /var/lib/mailwolt
GIT_TAG="$(sudo -u "$APP_USER" -H bash -lc "git -C ${APP_DIR} ls-remote --tags --sort=-v:refname origin 'v*' 2>/dev/null | grep -v '\^{}' | head -1 | sed 's|.*refs/tags/||'")"
if [ -n "$GIT_TAG" ]; then
echo "${GIT_TAG#v}" > /var/lib/mailwolt/version
echo "$GIT_TAG" > /var/lib/mailwolt/version_raw
else
warn "Kein Git-Tag gefunden — Version-Datei wird nicht geschrieben"
fi
ok
step "Berechtigungen setzen & Dienste starten" "10 Sek"
if ! id -u "$APP_USER" >/dev/null 2>&1; then
quietly adduser --disabled-password --gecos "" "$APP_USER"
fi
echo "${APP_USER}:${APP_PW}" | quietly chpasswd
quietly usermod -a -G "$APP_GROUP" "$APP_USER"
chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR"
find "$APP_DIR" -type d -exec chmod 775 {} \; 2>>"$LOG_FILE"
find "$APP_DIR" -type f -exec chmod 664 {} \; 2>>"$LOG_FILE"
chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache
if command -v setfacl >/dev/null 2>&1; then
try_quiet setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
try_quiet setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache"
fi
grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile
grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc
try_quiet sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002"
FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL"
sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL"
try_quiet systemctl restart php${PHPV}-fpm
fi
IDE_USER="${SUDO_USER:-}"
if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then
try_quiet usermod -a -G "$APP_GROUP" "$IDE_USER"
try_quiet setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR"
try_quiet setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR"
fi
try_quiet systemctl reload nginx
try_quiet systemctl restart php*-fpm
ok
# ===== Monit =====
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
# set mailserver + set alert werden im Wizard gesetzt
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed host 127.0.0.1 port 25 protocol smtp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process dovecot with pidfile /run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed host 127.0.0.1 port 143 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 993 type tcpssl for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process mariadb matching "mysqld"
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed host 127.0.0.1 port 3306 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process redis with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed host 127.0.0.1 port 6379 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process rspamd matching "rspamd: main process"
start program = "/bin/systemctl start rspamd" with timeout 60 seconds
stop program = "/bin/systemctl stop rspamd"
if failed host 127.0.0.1 port 11332 type tcp for 3 cycles then restart
if failed host 127.0.0.1 port 11334 type tcp for 3 cycles then restart
if 5 restarts within 10 cycles then alert
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed host 127.0.0.1 port 8891 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process opendmarc with pidfile /run/opendmarc/opendmarc.pid
start program = "/bin/systemctl start opendmarc"
stop program = "/bin/systemctl stop opendmarc"
if 5 restarts within 10 cycles then alert
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed host 127.0.0.1 port 80 type tcp for 2 cycles then restart
if 5 restarts within 10 cycles then alert
check process fail2ban with pidfile /run/fail2ban/fail2ban.pid
start program = "/bin/systemctl start fail2ban"
stop program = "/bin/systemctl stop fail2ban"
if 5 restarts within 10 cycles then alert
check process clamav matching "clamd"
start program = "/bin/systemctl start clamav-daemon"
stop program = "/bin/systemctl stop clamav-daemon"
if failed unixsocket /run/clamav/clamd.ctl for 3 cycles then restart
if 5 restarts within 10 cycles then unmonitor
EOF
chmod 600 /etc/monit/monitrc
monit -t || { warn "Monit-Config ungültig — prüfe /etc/monit/monitrc"; }
try_quiet systemctl enable --now monit
# ===== Smoke-Test =====
step "Dienste prüfen (Port-Check)"
_sok=0; _sfail=0
smoke_smtp() {
local port="$1" label="$2"
local out
out=$(printf "EHLO localhost\r\nQUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -q '^220'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_tls() {
local port="$1" label="$2" extra="${3:-}"
local out
out=$(timeout 5s openssl s_client $extra -connect 127.0.0.1:"$port" -brief -quiet </dev/null 2>&1 || true)
if echo "$out" | grep -qiE '(CONNECTED|depth|Verify|^220|\+OK|OK)'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_imap() {
local port="$1" label="$2"
local out
out=$(printf ". CAPABILITY\r\n. LOGOUT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi 'CAPABILITY'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_pop3() {
local port="$1" label="$2"
local out
out=$(printf "QUIT\r\n" | timeout 5s nc -w3 127.0.0.1 "$port" 2>/dev/null || true)
if echo "$out" | grep -qi '^\+OK'; then
printf " ${GREEN}${NC} %-5s %s\n" "$port" "$label"; (( _sok++ )) || true
else
printf " ${YELLOW}${NC} %-5s %s — nicht erreichbar\n" "$port" "$label"; (( _sfail++ )) || true
fi
}
smoke_smtp 25 "SMTP"
smoke_tls 465 "SMTPS" ""
smoke_tls 587 "Submission" "-starttls smtp"
smoke_imap 143 "IMAP"
smoke_tls 993 "IMAPS" ""
smoke_pop3 110 "POP3"
smoke_tls 995 "POP3S" ""
printf "\n ${GREY}%d/%d Dienste erreichbar${NC}\n" "$_sok" "$(( _sok + _sfail ))"
echo
echo -e " ${GREY}Bootstrap-Login (nur für ERSTEN Login & Wizard):${NC}"
echo -e " ${CYAN}User: ${NC}${BOOTSTRAP_USER}"
echo -e " ${CYAN}Passwort: ${NC}${BOOTSTRAP_PASS}"
echo -e " ${GREY}Log: ${LOG_FILE}${NC}"
echo
footer_ok "$SERVER_IP"