Files
2026-07-20 11:05:07 +02:00

852 lines
39 KiB
Bash
Executable File

#!/bin/bash
# install.sh — déploiement idempotent de Fail2banActionBanishment
# Cible : VPS Debian 13. Usage : sudo ./install.sh {install|install-master|install-mariadb|join|status}
#
# Modèle de déploiement (2026-07-16) : PAS de copie vers /opt. Le dépôt git
# cloné dans le $HOME d'un utilisateur dédié (ex. /home/banisher/...) EST le
# déploiement — le venv, la base SQLite, staticfiles/ et .env vivent tous
# dans ce checkout, possédés par cet utilisateur. Ce script doit donc être
# lancé depuis un checkout déjà possédé par l'utilisateur de déploiement
# souhaité (jamais root) ; l'utilisateur est déterminé automatiquement via le
# propriétaire du dépôt, pas codé en dur.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$REPO_DIR/.env"
APP_DIR="$REPO_DIR/emitter"
DEPLOY_USER=""
DEPLOY_GROUP=""
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "Ce script doit être exécuté en root (sudo ./install.sh install)." >&2
exit 1
fi
}
# Détermine l'utilisateur de déploiement à partir du propriétaire du dépôt
# lui-même (pas de compte système dédié séparé, cf. note en tête de fichier).
resolve_deploy_user() {
DEPLOY_USER="$(stat -c '%U' "$REPO_DIR")"
if [ "$DEPLOY_USER" = "root" ]; then
echo "ERREUR : $REPO_DIR appartient à root." >&2
echo " Cloner/chown ce dépôt vers un utilisateur non-root dédié" >&2
echo " (ex. 'banisher') avant de lancer install.sh." >&2
exit 1
fi
DEPLOY_GROUP="$(id -gn "$DEPLOY_USER")"
}
# Ajoute/retire une variable dans un fichier .env de façon idempotente.
upsert_env_var() {
local key="$1" value="$2" file="$3"
if grep -q "^${key}=" "$file"; then
sed -i "s#^${key}=.*#${key}=${value}#" "$file"
else
echo "${key}=${value}" >> "$file"
fi
}
install_packages() {
echo " [Paquets]"
apt-get update -qq
apt-get install -y --no-install-recommends \
fail2ban iptables mosquitto mosquitto-clients python3-paho-mqtt \
redis-server python3-venv rsync
}
# coturn : le logpath réel dépend de la config coturn locale (log-file=
# dans /etc/turnserver.conf), jamais une valeur codée en dur partagée entre
# noeuds (piège déjà vécu : /var/log/coturn/turnserver.log sur un VPS,
# /var/log/turnserver.log sur un autre, même paquet Debian coturn). Coturn
# absent/mal configuré sur ce noeud => jail désactivée plutôt qu'un chemin
# invalide qui ferait planter fail2ban au démarrage ("Have not found any
# log file for coturn jail").
deploy_coturn_jail() {
local logpath=""
if [ -f /etc/turnserver.conf ]; then
logpath="$(grep -oP '^log-file=\K.*' /etc/turnserver.conf | head -1)"
fi
if [ -n "$logpath" ]; then
# Sans la directive "simple-log", coturn insère un suffixe (date)
# avant l'extension dans le nom de fichier réel — le chemin exact
# de "log-file=" ne correspond alors à aucun fichier qui existe
# vraiment, faisant planter fail2ban au démarrage ("Have not found
# any log file for coturn jail"), même après la correction du
# chemin lui-même (vécu sur linuxtarn-vps1, corrigé à la main avant
# d'être généralisé ici). Un glob (turnserver*.log au lieu de
# turnserver.log) couvre les deux cas : avec simple-log, "*" ne
# matche que la chaîne vide et retombe sur le nom exact.
local ext="${logpath##*.}"
local base="${logpath%.*}"
logpath="${base}*.${ext}"
echo " -> jail coturn : logpath=$logpath (lu depuis /etc/turnserver.conf)"
sed "s#__COTURN_LOGPATH__#$logpath#g" \
"$REPO_DIR/generic/fail2ban/jail.d/coturn.conf" > /etc/fail2ban/jail.d/coturn.conf
else
echo " -> coturn absent ou log-file non configuré : jail coturn désactivée"
sed -e "s#__COTURN_LOGPATH__#/var/log/coturn/turnserver.log#g" \
-e "s/^enabled[[:space:]]*=[[:space:]]*true/enabled = false/" \
"$REPO_DIR/generic/fail2ban/jail.d/coturn.conf" > /etc/fail2ban/jail.d/coturn.conf
fi
}
# logpath dépend du checkout ($APP_DIR/logs/admin-auth.log, cf.
# emitter/config/settings/base.py LOG_DIR) donc du $HOME de l'utilisateur
# de déploiement — jamais une valeur en dur partagée entre noeuds (même
# raison que deploy_coturn_jail ci-dessus).
deploy_emitter_admin_auth_jail() {
sed "s#__EMITTER_ADMIN_AUTH_LOGPATH__#$APP_DIR/logs/admin-auth.log#g" \
"$REPO_DIR/generic/fail2ban/jail.d/emitter-admin-auth.conf" > /etc/fail2ban/jail.d/emitter-admin-auth.conf
}
# OpenVPN, comme coturn ci-dessus : service tiers optionnel, pas propre à
# ce projet. Sur un noeud sans OpenVPN (cas courant, ex. noeud derrière
# une box résidentielle), les deux logpath codés en dur n'existent pas et
# fail2ban refuse de charger la jail au démarrage ("Have not found any
# log file for openvpn jail") — même piège que coturn, désactiver plutôt
# que planter.
deploy_openvpn_jail() {
if [ -f /var/log/openvpn/openvpn.log ] || [ -f /var/log/openvpn.log ]; then
echo " -> jail openvpn : log détecté, activée"
cp "$REPO_DIR/generic/fail2ban/jail.d/openvpn.conf" /etc/fail2ban/jail.d/openvpn.conf
else
echo " -> OpenVPN absent : jail openvpn désactivée"
sed "s/^enabled[[:space:]]*=[[:space:]]*true/enabled = false/" \
"$REPO_DIR/generic/fail2ban/jail.d/openvpn.conf" > /etc/fail2ban/jail.d/openvpn.conf
fi
}
deploy_fail2ban() {
echo " [fail2ban]"
install -d /etc/fail2ban/action.d /etc/fail2ban/filter.d /etc/fail2ban/jail.d
cp "$REPO_DIR/generic/fail2ban/jail.local" /etc/fail2ban/jail.local
cp "$REPO_DIR/generic/fail2ban/fail2ban.local" /etc/fail2ban/fail2ban.local
for f in "$REPO_DIR"/generic/fail2ban/jail.d/*.conf; do
case "$(basename "$f")" in
coturn.conf|openvpn.conf|emitter-admin-auth.conf) continue ;;
esac
cp "$f" /etc/fail2ban/jail.d/
done
deploy_coturn_jail
deploy_openvpn_jail
deploy_emitter_admin_auth_jail
cp "$REPO_DIR"/generic/fail2ban/filter.d/*.conf /etc/fail2ban/filter.d/
cp "$REPO_DIR"/generic/fail2ban/action.d/*.conf /etc/fail2ban/action.d/
cp "$REPO_DIR/generic/fail2ban/action.d/f2b_mqtt_action_banisher.py" /etc/fail2ban/action.d/
chmod +x /etc/fail2ban/action.d/f2b_mqtt_action_banisher.py
if [ ! -f /etc/fail2ban/mqtt.conf ]; then
echo " -> /etc/fail2ban/mqtt.conf absent : copie du modèle (à éditer avec les vrais identifiants)"
cp "$REPO_DIR/generic/fail2ban/mqtt.conf.example" /etc/fail2ban/mqtt.conf
chmod 600 /etc/fail2ban/mqtt.conf
fi
}
deploy_firewall() {
echo " [firewall]"
install -d /etc/firewall
# firewall-filters.conf : contrairement à firewall-launcher.sh/.service
# (génériques, ré-écrits à chaque déploiement), ce fichier est propre à
# CE noeud (services réellement exposés, SSH_PORT, etc. — voir
# docs/installation.md). Ne jamais l'écraser une fois personnalisé, au
# même titre que .env (deploy_env ci-dessus) : sinon un simple
# `sudo make install` après un `git pull` effacerait silencieusement
# l'adaptation faite pour ce noeud et réintroduirait des règles
# (VPN/coturn/SSH) qui ne lui correspondent pas.
if [ ! -f /etc/firewall/firewall-filters.conf ]; then
echo " -> firewall-filters.conf absent : copié depuis le modèle générique (À ADAPTER à ce noeud)"
cp "$REPO_DIR/generic/firewall/firewall-filters.conf" /etc/firewall/firewall-filters.conf
fi
cp "$REPO_DIR/generic/firewall/firewall-launcher.sh" /etc/firewall/firewall-launcher.sh
chmod +x /etc/firewall/firewall-launcher.sh
sed "s#__ENV_FILE__#$ENV_FILE#g" \
"$REPO_DIR/generic/firewall/firewall.service" > /etc/systemd/system/firewall.service
}
deploy_mosquitto() {
echo " [mosquitto]"
install -d /etc/mosquitto/conf.d /etc/systemd/system/mosquitto.service.d
# Notre drop-in ci-dessous ajoute ProtectSystem=strict + ReadWritePaths
# sur /var/log/mosquitto et /var/lib/mosquitto : ces chemins doivent
# déjà exister AVANT que systemd ne construise le mount namespace de
# l'unité (partagé par tous ses Exec*, y compris le tout premier
# ExecStartPre du paquet Debian, `mkdir -p /var/log/mosquitto`) — sinon
# ce mkdir lui-même échoue avec "226/NAMESPACE" (bind-mount impossible
# d'un chemin qui n'existe pas encore). On les crée donc nous-mêmes ici,
# avant toute tentative de (re)démarrage du service.
install -d -o mosquitto -g mosquitto -m 750 /var/log/mosquitto /var/lib/mosquitto
# MQTT_VPN_LISTENER_IP est propre à CE VPS (chaque noeud a sa propre IP
# VPN, ex. tun0) — jamais une valeur codée en dur partagée entre noeuds
# (piège déjà vécu : un listener qui refuse de démarrer avec "Cannot
# assign requested address" sur tout VPS différent de celui où l'IP a
# été relevée). Lu depuis .env, substitué dans __VPN_LISTENER_BLOCK__ ;
# absent/vide => pas de second listener, loopback seul.
local vpn_ip vpn_block
vpn_ip="$(grep '^MQTT_VPN_LISTENER_IP=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-)"
if [ -n "$vpn_ip" ]; then
echo " -> second listener VPN sur $vpn_ip (MQTT_VPN_LISTENER_IP dans .env)"
vpn_block="listener 1883 $vpn_ip
protocol mqtt
allow_anonymous false
password_file /etc/mosquitto/passwd_local"
else
echo " -> MQTT_VPN_LISTENER_IP absent/vide dans .env : pas de second listener (loopback seul)"
vpn_block="# MQTT_VPN_LISTENER_IP absent/vide dans .env : pas de second listener."
fi
awk -v block="$vpn_block" '{gsub(/__VPN_LISTENER_BLOCK__/, block)}1' \
"$REPO_DIR/generic/mosquitto/mosquitto-local.conf" > /etc/mosquitto/conf.d/mosquitto-local.conf
cp "$REPO_DIR/generic/mosquitto/mosquitto.service.d/override.conf" /etc/systemd/system/mosquitto.service.d/override.conf
# Mots de passe repris tels quels des fichiers qui font déjà foi
# ailleurs (jamais re-demandés interactivement) : sinon rien ne garantit
# que la valeur tapée ici corresponde à celle que lit réellement le
# client MQTT concerné — piège vécu : "Connection Refused: not
# authorised" faute de correspondance entre un mot de passe saisi à la
# volée dans ce prompt et celui laissé dans mqtt.conf/.env.
if [ ! -f /etc/mosquitto/passwd_local ]; then
local fail2ban_mqtt_password
fail2ban_mqtt_password="$(grep '^password' /etc/fail2ban/mqtt.conf | head -1 | cut -d= -f2- | xargs)"
echo " -> compte MQTT 'fail2ban' absent : création depuis /etc/fail2ban/mqtt.conf"
mosquitto_passwd -b -c /etc/mosquitto/passwd_local fail2ban "$fail2ban_mqtt_password"
fi
if ! grep -q '^emitter:' /etc/mosquitto/passwd_local; then
local emitter_mqtt_password
emitter_mqtt_password="$(grep '^MQTT_BROKER_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)"
echo " -> compte MQTT 'emitter' absent : création depuis MQTT_BROKER_PASSWORD (.env)"
mosquitto_passwd -b /etc/mosquitto/passwd_local emitter "$emitter_mqtt_password"
fi
chown mosquitto:mosquitto /etc/mosquitto/passwd_local
chmod 640 /etc/mosquitto/passwd_local
}
deploy_master_broker() {
echo " [broker master (Phase 3)]"
local domain="${MQTT_MASTER_DOMAIN:-mqtt.linuxtarn.org}"
local ca_dir="/etc/mosquitto/master-ca"
if [ ! -f "/etc/letsencrypt/live/$domain/fullchain.pem" ]; then
echo " -> ERREUR : certificat Let's Encrypt introuvable pour $domain" >&2
echo " (attendu : /etc/letsencrypt/live/$domain/fullchain.pem)" >&2
exit 1
fi
if [ ! -f "$ca_dir/ca.key" ]; then
echo " -> CA privée absente : initialisation ($ca_dir)"
"$REPO_DIR/scripts/master-ca-init.sh" "$ca_dir"
fi
sed "s#__MQTT_MASTER_DOMAIN__#$domain#g" "$REPO_DIR/generic/mosquitto/mosquitto-master.conf" > /etc/mosquitto/conf.d/mosquitto-master.conf
cp "$REPO_DIR/generic/mosquitto/master-acl.conf" /etc/mosquitto/master-acl.conf
# mosquitto refuse (ou avertit, selon version) de charger un acl_file dont
# le propriétaire n'est pas mosquitto ou qui est lisible par d'autres.
chown mosquitto:mosquitto /etc/mosquitto/master-acl.conf
chmod 0700 /etc/mosquitto/master-acl.conf
# /etc/letsencrypt est verrouillé root-only par certbot (le dossier
# lui-même, pas seulement privkey.pem) : mosquitto (utilisateur système
# dédié, pas root) ne peut jamais lire les fichiers à leur emplacement
# d'origine. Le deploy-hook copie vers un répertoire dédié, group-lisible
# pour mosquitto (et redémarre mosquitto, son rôle normal déclenché par
# certbot à chaque renouvellement) ; installé ici pour les
# renouvellements futurs, et lancé une fois tout de suite pour peupler
# /etc/mosquitto/master-tls/. Appelé avec || true : en cas d'échec (par
# ex. mosquitto pas encore capable de redémarrer à ce stade), on veut
# quand même atteindre les vérifications ci-dessous avant d'abandonner,
# plutôt qu'un arrêt sec sans explication.
install -d /etc/letsencrypt/renewal-hooks/deploy
sed "s#__MQTT_MASTER_DOMAIN__#$domain#g" "$REPO_DIR/generic/mosquitto/certbot-deploy-mosquitto.sh" > /etc/letsencrypt/renewal-hooks/deploy/mosquitto-master.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mosquitto-master.sh
/etc/letsencrypt/renewal-hooks/deploy/mosquitto-master.sh || true
# Vérification : le process mosquitto tourne sous l'utilisateur
# 'mosquitto', pas root — s'assurer qu'il peut vraiment lire ce dont il a
# besoin plutôt que de découvrir un échec silencieux au redémarrage.
echo " -> vérification des permissions (utilisateur mosquitto)"
local perms_ok=true
for f in "$ca_dir/ca.crt" \
/etc/mosquitto/master-tls/fullchain.pem \
/etc/mosquitto/master-tls/privkey.pem; do
if runuser -u mosquitto -- test -r "$f"; then
echo " OK : $f"
else
echo " ECHEC: mosquitto ne peut pas lire $f" >&2
perms_ok=false
fi
done
if [ "$perms_ok" != true ]; then
echo " -> permissions incomplètes, le broker ne démarrera probablement pas (voir ci-dessus)." >&2
exit 1
fi
systemctl daemon-reload
systemctl restart mosquitto.service
echo " -> pour émettre un certificat client de noeud :"
echo " sudo $REPO_DIR/scripts/generate-node-cert.sh <nom_du_noeud>"
# Ingestion côté master (banevents.master_listen, Phase 3 minimal) :
# nécessite que `./install.sh install` ait déjà tourné au moins une fois
# (venv de l'émetteur provisionné dans $APP_DIR).
if [ ! -d "$APP_DIR/.venv" ]; then
echo " -> émetteur Django absent : lancer 'sudo ./install.sh install' avant" >&2
echo " pour activer l'ingestion master (master_listen)." >&2
return 0
fi
local listener_tls_dir="$APP_DIR/master-tls"
if [ ! -f "$listener_tls_dir/listener.crt" ]; then
echo " -> certificat d'ingestion master-internal absent : génération"
local tmp_cert_dir
tmp_cert_dir="$(mktemp -d)"
"$REPO_DIR/scripts/generate-node-cert.sh" master-internal "$tmp_cert_dir"
install -d "$listener_tls_dir"
cp "$tmp_cert_dir/master-internal.crt" "$listener_tls_dir/listener.crt"
cp "$tmp_cert_dir/master-internal.key" "$listener_tls_dir/listener.key"
rm -rf "$tmp_cert_dir"
chown -R "$DEPLOY_USER:$DEPLOY_GROUP" "$listener_tls_dir"
chmod 600 "$listener_tls_dir/listener.key"
fi
sed "s#__EMITTER_DIR__#$APP_DIR#g; s#__ENV_FILE__#$ENV_FILE#g; s#__EMITTER_USER__#$DEPLOY_USER#g; s#__EMITTER_GROUP__#$DEPLOY_GROUP#g" \
"$REPO_DIR/generic/emitter/fail2ban-emitter-master-listen.service" > /etc/systemd/system/fail2ban-emitter-master-listen.service
systemctl daemon-reload
systemctl enable fail2ban-emitter-master-listen.service
systemctl restart fail2ban-emitter-master-listen.service
}
# sudoers NOPASSWD:ALL pour l'utilisateur de déploiement (banisher ou
# équivalent) : demandé explicitement pour ce modèle "tout depuis le $HOME"
# (déploiement + services tournent sous le même compte, qui doit pouvoir
# reconfigurer systemd/fail2ban/mosquitto/iptables sans mot de passe). Note :
# ceci remplace le sudoers restreint à une seule jail utilisé auparavant pour
# SYNC_BAN (fail2ban-client) — ce compte a désormais accès root complet, à
# n'utiliser que sur un VPS dédié à ce projet.
deploy_sudoers() {
echo " [sudoers]"
local sudoers_file="/etc/sudoers.d/${DEPLOY_USER}-full-access"
if [ -f "$sudoers_file" ]; then
echo " -> $sudoers_file déjà présent, inchangé"
return 0
fi
echo " -> création de $sudoers_file (NOPASSWD:ALL pour '$DEPLOY_USER')"
local tmp
tmp="$(mktemp)"
echo "$DEPLOY_USER ALL=(ALL:ALL) NOPASSWD:ALL" > "$tmp"
if visudo -c -f "$tmp" >/dev/null 2>&1; then
install -o root -g root -m 440 "$tmp" "$sudoers_file"
else
echo " -> ERREUR : le fichier sudoers généré est invalide, non installé" >&2
rm -f "$tmp"
exit 1
fi
rm -f "$tmp"
}
# Prépare .env (copié depuis .env.example à la racine du dépôt s'il est
# absent, jamais régénéré ensuite pour ne pas écraser des valeurs déjà
# personnalisées) et tient FAIL2BAN_CLIENT_PATH à jour à chaque exécution.
deploy_env() {
echo " [.env]"
if [ ! -f "$ENV_FILE" ]; then
echo " -> .env absent : génération depuis .env.example (SECRET_KEY aléatoire, à compléter)"
cp "$REPO_DIR/.env.example" "$ENV_FILE"
local secret_key
secret_key="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')"
sed -i "s#^DJANGO_SECRET_KEY=.*#DJANGO_SECRET_KEY=$secret_key#" "$ENV_FILE"
fi
local fail2ban_client_path
fail2ban_client_path="$(command -v fail2ban-client || echo /usr/bin/fail2ban-client)"
upsert_env_var FAIL2BAN_CLIENT_PATH "$fail2ban_client_path" "$ENV_FILE"
# ETH_IF : contrairement à FAIL2BAN_CLIENT_PATH ci-dessus, ajouté une
# seule fois si absent (jamais réécrit ensuite) — le nom de
# l'interface WAN ne change pas d'une exécution à l'autre, et un
# admin a pu le corriger à la main (multi-NIC, nom non standard).
# Avant ce correctif, firewall-launcher.sh utilisait un défaut codé en
# dur ("eth0") jamais transmis par le service systemd : sur toute
# interface nommée différemment (enp1s0, ens18, ...), aucune règle
# -i $ETH_IF ne matchait jamais le vrai trafic, et tout — y compris
# HTTP/HTTPS — tombait silencieusement sur le DROP final.
if ! grep -q '^ETH_IF=' "$ENV_FILE"; then
local eth_if
eth_if="$(ip -o route show default 2>/dev/null | awk '{print $5; exit}')"
eth_if="${eth_if:-eth0}"
echo " -> ETH_IF absent de .env : détecté automatiquement ($eth_if, interface de la route par défaut) — à corriger ici si erroné (ip -o link show)"
echo "ETH_IF=$eth_if" >> "$ENV_FILE"
fi
chown "$DEPLOY_USER:$DEPLOY_GROUP" "$ENV_FILE"
chmod 600 "$ENV_FILE"
}
deploy_emitter() {
echo " [émetteur Django]"
deploy_env
if [ ! -d "$APP_DIR/.venv" ]; then
echo " -> création du venv Python de l'émetteur"
runuser -u "$DEPLOY_USER" -- python3 -m venv "$APP_DIR/.venv"
fi
runuser -u "$DEPLOY_USER" -- "$APP_DIR/.venv/bin/pip" install --quiet --upgrade pip
runuser -u "$DEPLOY_USER" -- "$APP_DIR/.venv/bin/pip" install --quiet -r "$APP_DIR/requirements.txt"
# logs/admin-auth.log (verrouillages django-axes, cf. AXES_* dans
# config/settings/base.py) : le FileHandler de Django ne crée pas son
# dossier parent, et emitter-admin-auth.conf (jail fail2ban) doit
# trouver un fichier existant dès le démarrage du service — même piège
# que coturn.conf, résolu ici en touch-créant le fichier vide plutôt
# qu'en attendant le premier verrouillage réel.
install -d -o "$DEPLOY_USER" -g "$DEPLOY_GROUP" "$APP_DIR/logs"
[ -f "$APP_DIR/logs/admin-auth.log" ] || runuser -u "$DEPLOY_USER" -- touch "$APP_DIR/logs/admin-auth.log"
# staticfiles/ peut avoir été créé par un autre utilisateur lors d'un
# débogage manuel passé (ex. collectstatic lancé en root) : s'assurer
# qu'il appartient au bon compte avant que collectstatic ne tente d'y
# réécrire, sous peine de PermissionError.
if [ -d "$APP_DIR/staticfiles" ]; then
chown -R "$DEPLOY_USER:$DEPLOY_GROUP" "$APP_DIR/staticfiles"
fi
# migrate/collectstatic tournent en tant que $DEPLOY_USER (propriétaire du
# dépôt) pour que la base SQLite et staticfiles/ lui appartiennent
# directement. Les variables de .env sont passées explicitement à `env`
# plutôt que via source+export+runuser : selon les versions, runuser ne
# propage pas forcément l'environnement du shell appelant.
mapfile -t emitter_env_vars < <(grep -v '^#' "$ENV_FILE" | grep -v '^\s*$')
( cd "$APP_DIR" && runuser -u "$DEPLOY_USER" -- env "${emitter_env_vars[@]}" DJANGO_SETTINGS_MODULE=config.settings.production \
./.venv/bin/python manage.py migrate --noinput )
( cd "$APP_DIR" && runuser -u "$DEPLOY_USER" -- env "${emitter_env_vars[@]}" DJANGO_SETTINGS_MODULE=config.settings.production \
./.venv/bin/python manage.py collectstatic --noinput )
local sed_placeholders="s#__EMITTER_DIR__#$APP_DIR#g; s#__ENV_FILE__#$ENV_FILE#g; s#__EMITTER_USER__#$DEPLOY_USER#g; s#__EMITTER_GROUP__#$DEPLOY_GROUP#g"
sed "$sed_placeholders" "$REPO_DIR/generic/emitter/fail2ban-emitter-web.service" > /etc/systemd/system/fail2ban-emitter-web.service
sed "$sed_placeholders" "$REPO_DIR/generic/emitter/fail2ban-emitter-mqtt.service" > /etc/systemd/system/fail2ban-emitter-mqtt.service
sed "$sed_placeholders" "$REPO_DIR/generic/emitter/fail2ban-emitter-worker.service" > /etc/systemd/system/fail2ban-emitter-worker.service
# master_client (relais SYNC_BAN vers/depuis le master) : déployé sur
# tous les noeuds, pas seulement le master. Ne pourra réellement se
# connecter qu'une fois un certificat de noeud généré et placé
# manuellement (scripts/generate-node-cert.sh + MQTT_MASTER_NODE_NAME) —
# en échouera/retentera (Restart=on-failure) sans bloquer le reste.
sed "$sed_placeholders" "$REPO_DIR/generic/emitter/fail2ban-emitter-master-client.service" > /etc/systemd/system/fail2ban-emitter-master-client.service
sed "$sed_placeholders" "$REPO_DIR/generic/emitter/logrotate-admin-auth.conf" > /etc/logrotate.d/emitter-admin-auth
}
enable_services() {
echo " [services systemd]"
systemctl daemon-reload
# Ordre : redis/mosquitto puis firewall (cf. Before=/PartOf de firewall.service)
# puis fail2ban, puis les services de l'émetteur (dépendent de mosquitto/redis)
#
# mosquitto/firewall/fail2ban : `enable --now` seul ne suffit PAS sur un
# VPS où le service tournait déjà avant ce déploiement (cas réel : VPS
# partagé avec d'autres services, fail2ban actif depuis des semaines) —
# `--now` sur un service déjà actif ne relit pas sa config. Or
# deploy_mosquitto()/deploy_firewall()/deploy_fail2ban() viennent de
# réécrire leurs fichiers de conf juste avant : il faut un `restart`
# explicite pour que le changement s'applique, même déjà signalé pour
# les services de l'émetteur ci-dessous.
systemctl enable --now redis-server.service
systemctl enable mosquitto.service
systemctl restart mosquitto.service
systemctl enable firewall.service
systemctl restart firewall.service
systemctl enable fail2ban.service
systemctl restart fail2ban.service
# restart (pas enable --now) : le code de l'émetteur vient d'être
# resynchronisé, un service déjà actif doit relire la nouvelle version
systemctl enable fail2ban-emitter-web.service fail2ban-emitter-mqtt.service fail2ban-emitter-worker.service fail2ban-emitter-master-client.service
systemctl restart fail2ban-emitter-web.service
systemctl restart fail2ban-emitter-mqtt.service
systemctl restart fail2ban-emitter-worker.service
systemctl restart fail2ban-emitter-master-client.service
}
# MariaDB (opt-in, Phase 5) : sqlite reste le défaut, cette sous-commande
# provisionne un serveur MariaDB local + une base/utilisateur dédiés et
# bascule .env en conséquence. Nécessite qu'un premier 'install' ait déjà
# tourné (venv + .env présents).
deploy_mariadb() {
echo " [MariaDB (opt-in)]"
if [ ! -f "$ENV_FILE" ] || [ ! -d "$APP_DIR/.venv" ]; then
echo " -> ERREUR : lancer 'sudo ./install.sh install' avant install-mariadb" >&2
exit 1
fi
apt-get update -qq
apt-get install -y --no-install-recommends mariadb-server mariadb-client
systemctl enable --now mariadb.service
local db_name db_user db_password
db_name="$(grep '^DB_NAME=' "$ENV_FILE" | cut -d= -f2-)"
db_user="$(grep '^DB_USER=' "$ENV_FILE" | cut -d= -f2-)"
db_password="$(grep '^DB_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)"
[ -n "$db_name" ] || db_name="fail2ban_emitter"
[ -n "$db_user" ] || db_user="fail2ban_emitter"
if [ -z "$db_password" ] || [ "$db_password" = "changeme" ]; then
db_password="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
echo " -> mot de passe DB_PASSWORD généré aléatoirement"
fi
echo " -> création de la base '$db_name' et de l'utilisateur '$db_user' (idempotent)"
mariadb -u root <<-SQL
CREATE DATABASE IF NOT EXISTS \`$db_name\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$db_user'@'127.0.0.1' IDENTIFIED BY '$db_password';
GRANT ALL PRIVILEGES ON \`$db_name\`.* TO '$db_user'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
upsert_env_var DB_ENGINE mariadb "$ENV_FILE"
upsert_env_var DB_NAME "$db_name" "$ENV_FILE"
upsert_env_var DB_USER "$db_user" "$ENV_FILE"
upsert_env_var DB_PASSWORD "$db_password" "$ENV_FILE"
upsert_env_var DB_HOST 127.0.0.1 "$ENV_FILE"
upsert_env_var DB_PORT 3306 "$ENV_FILE"
chown "$DEPLOY_USER:$DEPLOY_GROUP" "$ENV_FILE"
chmod 600 "$ENV_FILE"
runuser -u "$DEPLOY_USER" -- "$APP_DIR/.venv/bin/pip" install --quiet -r "$APP_DIR/requirements-mariadb.txt"
echo " -> DB_ENGINE=mariadb appliqué à .env"
echo " -> relancer 'sudo ./install.sh install' pour migrer vers MariaDB et redémarrer les services"
}
# Auto-inscription d'un noeud par jeton (flux "kubeadm join", Phase 4/5) —
# alternative à scripts/generate-node-cert.sh + copie manuelle : la clé
# privée est générée ICI, sur ce noeud, et n'en sort jamais (seule la CSR,
# une demande de signature, part vers le master). Jeton émis côté master
# via 'manage.py create_join_token <nom>' (banevents/views.py::join_node).
deploy_join() {
local master="" node_name="" token=""
while [ $# -gt 0 ]; do
case "$1" in
--master) master="$2"; shift 2 ;;
--node) node_name="$2"; shift 2 ;;
--token) token="$2"; shift 2 ;;
*) echo "Option inconnue : $1" >&2; exit 1 ;;
esac
done
if [ -z "$master" ] || [ -z "$node_name" ] || [ -z "$token" ]; then
echo "Usage: $0 join --master <url> --node <nom> --token <jeton>" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ] || [ ! -d "$APP_DIR/.venv" ]; then
echo "ERREUR : lancer 'sudo ./install.sh install' avant 'install.sh join'" >&2
exit 1
fi
echo " [auto-inscription (join) auprès de $master]"
local tls_dir="$APP_DIR/master-tls"
install -d "$tls_dir"
local tmp_dir
tmp_dir="$(mktemp -d)"
# Clé + CSR générées ici (root, comme le reste du script — cohérence
# avec generate-node-cert.sh/install-master qui font de même) ; seule
# la CSR quitte cette machine, jamais node.key.
openssl genrsa -out "$tmp_dir/node.key" 2048 2>/dev/null
openssl req -new -key "$tmp_dir/node.key" -subj "/CN=$node_name" -out "$tmp_dir/node.csr"
local payload response http_code
payload="$(python3 -c '
import json, sys
node_name, token, csr = sys.argv[1], sys.argv[2], open(sys.argv[3]).read()
print(json.dumps({"node_name": node_name, "token": token, "csr": csr}))
' "$node_name" "$token" "$tmp_dir/node.csr")"
response="$(mktemp)"
http_code="$(curl -sS -o "$response" -w '%{http_code}' -X POST "${master%/}/api/join/" \
-H 'Content-Type: application/json' -d "$payload")"
if [ "$http_code" != "201" ]; then
echo "ERREUR : échec de l'auto-inscription auprès de $master (HTTP $http_code)" >&2
cat "$response" >&2
rm -rf "$tmp_dir" "$response"
exit 1
fi
python3 -c '
import json, sys
data = json.load(open(sys.argv[1]))
open(sys.argv[2], "w").write(data["cert"])
open(sys.argv[3], "w").write(data["ca_cert"])
' "$response" "$tls_dir/node.crt" "$tls_dir/ca.crt"
mv "$tmp_dir/node.key" "$tls_dir/node.key"
rm -rf "$tmp_dir" "$response"
chown "$DEPLOY_USER:$DEPLOY_GROUP" "$tls_dir/node.key" "$tls_dir/node.crt" "$tls_dir/ca.crt"
chmod 600 "$tls_dir/node.key"
upsert_env_var MQTT_MASTER_NODE_NAME "$node_name" "$ENV_FILE"
chown "$DEPLOY_USER:$DEPLOY_GROUP" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo " -> certificat obtenu pour '$node_name' ($tls_dir)"
echo " -> relancer 'sudo ./install.sh install' pour l'appliquer."
}
# nginx (opt-in, reverse proxy TLS devant le tableau de bord) : lit
# DASHBOARD_DOMAIN dans .env, installe nginx/certbot si absents, obtient un
# certificat Let's Encrypt (webroot) et déploie le gabarit
# generic/nginx/fail2ban-emitter.example.conf. Nécessite qu'un premier
# 'install' ait déjà tourné (staticfiles/, .env, services de l'émetteur).
# Idempotent : relançable pour changer de domaine ou retenter après un échec
# DNS/ACME.
deploy_nginx() {
echo " [nginx (tableau de bord)]"
if [ ! -f "$ENV_FILE" ]; then
echo " -> ERREUR : .env absent — lancer 'sudo ./install.sh install' avant" >&2
exit 1
fi
local domain
domain="$(grep '^DASHBOARD_DOMAIN=' "$ENV_FILE" | cut -d= -f2-)"
if [ -z "$domain" ]; then
echo " -> ERREUR : DASHBOARD_DOMAIN absent/vide dans .env" >&2
echo " Renseigner le domaine public du tableau de bord avant de relancer." >&2
exit 1
fi
if ! command -v nginx >/dev/null 2>&1 || ! command -v certbot >/dev/null 2>&1; then
echo " -> nginx/certbot : installation"
apt-get update -qq
apt-get install -y --no-install-recommends nginx certbot
fi
install -d /var/www/html
if [ ! -f "/etc/letsencrypt/live/$domain/fullchain.pem" ]; then
echo " -> certificat Let's Encrypt absent pour $domain : vhost ACME minimal puis certbot"
# Sans vhost dont le server_name correspond à $domain, nginx retombe
# sur le premier vhost du port 80 pour toute requête non reconnue —
# s'il s'agit d'un reverse-proxy pour un autre service, le challenge
# ACME se prend un 502 (vécu en déployant banishing.miraceti.net).
# Un vhost minimal dédié, juste pour /.well-known/acme-challenge/,
# évite ça avant même d'avoir un certificat.
cat > "/etc/nginx/sites-available/$domain" <<EOF_ACME
server {
listen 80;
listen [::]:80;
server_name $domain;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 444;
}
}
EOF_ACME
ln -sf "/etc/nginx/sites-available/$domain" "/etc/nginx/sites-enabled/$domain"
nginx -t
systemctl reload nginx
certbot certonly --webroot -w /var/www/html -d "$domain" \
--non-interactive --agree-tos --keep-until-expiring \
--register-unsafely-without-email
fi
# http2 : forme de la directive dépendante de la version nginx
# installée sur CE noeud (voir commentaire dans le template) — jamais
# une des deux formes codée en dur, sinon ça casse forcément l'autre
# cas de figure (vécu : "unknown directive http2" avec la forme
# séparée sur une nginx < 1.25.1, avertissement de dépréciation avec
# l'ancienne forme sur les toutes dernières versions).
local nginx_version http2_block
# Chemin explicite plutôt que "nginx" nu : /usr/sbin n'est pas
# toujours sur $PATH selon comment ce script est invoqué.
nginx_version="$(/usr/sbin/nginx -v 2>&1 | grep -oP 'nginx/\K[0-9]+\.[0-9]+\.[0-9]+' || true)"
if [ -n "$nginx_version" ] && \
[ "$(printf '%s\n1.25.1\n' "$nginx_version" | sort -V | tail -1)" = "$nginx_version" ]; then
echo " -> nginx $nginx_version >= 1.25.1 : directive http2 séparée"
http2_block="listen 443 ssl;
listen [::]:443 ssl;
http2 on;"
else
echo " -> nginx ${nginx_version:-inconnue} < 1.25.1 (ou version indétectable) : http2 sur listen"
http2_block="listen 443 ssl http2;
listen [::]:443 ssl http2;"
fi
awk -v block="$http2_block" '{gsub(/__HTTP2_LISTEN_BLOCK__/, block)}1' \
"$REPO_DIR/generic/nginx/fail2ban-emitter.example.conf" | \
sed "s#__DASHBOARD_DOMAIN__#$domain#g" \
> "/etc/nginx/sites-available/$domain"
ln -sf "/etc/nginx/sites-available/$domain" "/etc/nginx/sites-enabled/$domain"
nginx -t
systemctl reload nginx
upsert_env_var DJANGO_ALLOWED_HOSTS "$domain,127.0.0.1,localhost" "$ENV_FILE"
upsert_env_var DJANGO_CSRF_TRUSTED_ORIGINS "https://$domain" "$ENV_FILE"
upsert_env_var DJANGO_SECURE_SSL true "$ENV_FILE"
chown "$DEPLOY_USER:$DEPLOY_GROUP" "$ENV_FILE"
chmod 600 "$ENV_FILE"
systemctl restart fail2ban-emitter-web.service
echo " -> tableau de bord disponible sur https://$domain/"
}
# Liste des 4 services de l'émetteur présents sur tout noeud (web/mqtt/worker/
# master-client) — master-listen n'existe que sur le VPS master, traité à
# part (conditionnel à sa présence) dans install-supervisor/uninstall-supervisor.
EMITTER_SERVICE_NAMES=(fail2ban-emitter-web fail2ban-emitter-mqtt fail2ban-emitter-worker fail2ban-emitter-master-client)
# Bascule d'un déploiement systemd vers supervisor (Phase 5, option) : arrête
# et désactive les services systemd de l'émetteur (jamais les deux process
# managers en même temps sur le même service), déploie les 5 gabarits
# generic/supervisor/*.example.conf substitués (mêmes placeholders que les
# units systemd) et démarre supervisor.
deploy_supervisor() {
echo " [supervisor (bascule depuis systemd)]"
echo " -> arrêt/désactivation des services systemd de l'émetteur"
systemctl stop "${EMITTER_SERVICE_NAMES[@]/%/.service}" 2>/dev/null || true
systemctl disable "${EMITTER_SERVICE_NAMES[@]/%/.service}" 2>/dev/null || true
if systemctl list-unit-files fail2ban-emitter-master-listen.service >/dev/null 2>&1; then
systemctl stop fail2ban-emitter-master-listen.service 2>/dev/null || true
systemctl disable fail2ban-emitter-master-listen.service 2>/dev/null || true
fi
apt-get update -qq
apt-get install -y --no-install-recommends supervisor
install -d /var/log/supervisor
local sed_placeholders="s#__EMITTER_DIR__#$APP_DIR#g; s#__ENV_FILE__#$ENV_FILE#g; s#__EMITTER_USER__#$DEPLOY_USER#g; s#__EMITTER_GROUP__#$DEPLOY_GROUP#g"
local name
for name in "${EMITTER_SERVICE_NAMES[@]}"; do
sed "$sed_placeholders" "$REPO_DIR/generic/supervisor/${name}.example.conf" > "/etc/supervisor/conf.d/${name}.conf"
done
if [ -f "$REPO_DIR/generic/supervisor/fail2ban-emitter-master-listen.example.conf" ] && [ -d "$APP_DIR/master-tls" ]; then
sed "$sed_placeholders" "$REPO_DIR/generic/supervisor/fail2ban-emitter-master-listen.example.conf" > /etc/supervisor/conf.d/fail2ban-emitter-master-listen.conf
fi
systemctl enable --now supervisor.service
supervisorctl reread
supervisorctl update
echo " -> pour revenir à systemd : sudo ./install.sh uninstall-supervisor"
supervisorctl status || true
}
# Retour arrière : arrête/retire les confs supervisor et réactive les
# services systemd correspondants.
undo_supervisor() {
echo " [retour à systemd (arrêt de supervisor)]"
local name
for name in "${EMITTER_SERVICE_NAMES[@]}" fail2ban-emitter-master-listen; do
if [ -f "/etc/supervisor/conf.d/${name}.conf" ]; then
supervisorctl stop "$name" 2>/dev/null || true
rm -f "/etc/supervisor/conf.d/${name}.conf"
fi
done
supervisorctl reread
supervisorctl update
systemctl daemon-reload
systemctl enable "${EMITTER_SERVICE_NAMES[@]/%/.service}"
systemctl restart "${EMITTER_SERVICE_NAMES[@]/%/.service}"
if systemctl list-unit-files fail2ban-emitter-master-listen.service >/dev/null 2>&1; then
systemctl enable --now fail2ban-emitter-master-listen.service
fi
echo " -> services systemd de l'émetteur réactivés"
}
print_status() {
echo "=== firewall ==="
systemctl is-active firewall.service || true
echo "=== fail2ban ==="
systemctl is-active fail2ban.service || true
fail2ban-client status 2>/dev/null || echo "(fail2ban-client indisponible)"
echo "=== mosquitto ==="
systemctl is-active mosquitto.service || true
ss -tln 2>/dev/null | grep -q ':1883 ' \
&& echo "127.0.0.1:1883 en écoute" \
|| echo "(mosquitto : port 1883 non détecté en écoute)"
echo "=== redis ==="
systemctl is-active redis-server.service || true
echo "=== émetteur Django ($APP_DIR) ==="
systemctl is-active fail2ban-emitter-web.service || true
systemctl is-active fail2ban-emitter-mqtt.service || true
systemctl is-active fail2ban-emitter-worker.service || true
systemctl is-active fail2ban-emitter-master-client.service || true
ss -tln 2>/dev/null | grep -q ':8050 ' \
&& echo "127.0.0.1:8050 en écoute (tableau de bord)" \
|| echo "(émetteur : port 8050 non détecté en écoute)"
if systemctl list-unit-files fail2ban-emitter-master-listen.service >/dev/null 2>&1; then
echo "=== ingestion master (master_listen) ==="
systemctl is-active fail2ban-emitter-master-listen.service || true
fi
}
case "${1:-}" in
install)
require_root
resolve_deploy_user
install_packages
deploy_fail2ban
deploy_firewall
# .env doit exister avant deploy_mosquitto (lit MQTT_VPN_LISTENER_IP) ;
# deploy_emitter l'appelle aussi plus loin, idempotent (ne recrée pas
# .env s'il existe déjà).
deploy_env
deploy_mosquitto
deploy_sudoers
deploy_emitter
enable_services
echo " [installation terminée]"
print_status
;;
install-master)
require_root
resolve_deploy_user
deploy_master_broker
echo " [broker master installé]"
;;
install-mariadb)
require_root
resolve_deploy_user
deploy_mariadb
;;
join)
require_root
resolve_deploy_user
shift
deploy_join "$@"
;;
install-nginx)
require_root
resolve_deploy_user
deploy_nginx
;;
install-supervisor)
require_root
resolve_deploy_user
deploy_supervisor
;;
uninstall-supervisor)
require_root
resolve_deploy_user
undo_supervisor
;;
status)
print_status
;;
*)
echo "Usage: $0 {install|install-master|install-mariadb|join|install-nginx|install-supervisor|uninstall-supervisor|status}"
exit 1
;;
esac