#!/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 " # 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 ' (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 --node --token " >&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" < 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