Scripts production-ready

Ces six scripts sont la petite plomberie qui fait la différence entre un setup "qui marche à peu près" et un setup vraiment automatisé. Ils tournent en cron sur des centaines de NAS depuis des mois, survivent aux mises à jour DSM, et vous épargnent les mauvaises surprises : port fermé, backup raté, container silencieusement mort.

Tous testés sur Synology DS220+, DS920+ et DS923+ en DSM 7.2.1, avril 2026.

Comment utiliser ces scripts

La même procédure pour les 6. 5 minutes, pas de sorcellerie.

1

Coller le script dans un fichier

Connectez-vous en SSH à votre NAS, créez le dossier /volume1/docker/scripts/, et collez le script dans un fichier .sh (Bash) ou .py (Python). Vous pouvez aussi passer par File Station puis éditer avec Text Editor.

2

Rendre le script exécutable

En SSH : chmod +x /volume1/docker/scripts/mon-script.sh. Sans ça, le cron ne pourra rien lancer. Les scripts Python peuvent aussi être exécutés via python3 mon-script.py sans cette étape.

3

Planifier ou lancer

Testez d'abord manuellement dans la console. Si tout est OK, ajoutez le script au Planificateur de tâches DSM (Panneau de configuration > Planificateur de tâches > Créer > Tâche planifiée > Script défini par l'utilisateur). L'utilisateur root est le plus simple pour commencer.

Les six scripts

Commentaires inline en français. Toutes les variables à adapter sont regroupées en début de fichier.

Port forwarding ProtonVPN auto pour qBittorrent

Bash · 55 lignes · Cron toutes les 10 min

Le problème

ProtonVPN attribue un port différent à chaque reconnexion WireGuard. qBittorrent garde son vieux port (fermé) et vos uploads s'effondrent. Sur HD-Bits ou BHD, ça se termine en HnR en 72h.

La solution

Le script appelle natpmpc sur la gateway VPN pour récupérer le port externe attribué, puis PUT ce port vers l'API qBittorrent (/api/v2/app/setPreferences). Aucun redémarrage de container nécessaire.

Compatibilité : NAS Synology + GlueTUN + qBittorrent Web API v2

Le code

#!/bin/bash
#
# Port forwarding ProtonVPN auto -> qBittorrent
# Dependances : natpmpc (paquet natpmp sous DSM), curl, jq
# Usage cron : */10 * * * * /volume1/docker/scripts/pf-proton.sh >> /var/log/pf-proton.log 2>&1
#

set -euo pipefail

# ============ VARIABLES A ADAPTER ============
QBT_HOST="http://localhost:8080"          # URL Web UI qBittorrent
QBT_USER="admin"                          # Identifiant Web UI
QBT_PASS="votre_password_qbt"             # Mot de passe Web UI
VPN_GATEWAY="10.2.0.1"                    # Gateway du tunnel WireGuard ProtonVPN
LOG_FILE="/var/log/pf-proton.log"         # Fichier de log
# =============================================

timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(timestamp)] $*" | tee -a "$LOG_FILE"; }

# 1. Demander un port forward TCP + UDP via NAT-PMP pour 60 secondes
#    (ProtonVPN renouvelle le bail si on refait la demande regulierement)
log "Demande NAT-PMP sur gateway $VPN_GATEWAY…"
PORT=$(natpmpc -a 1 0 udp 60 -g "$VPN_GATEWAY" 2>/dev/null \
  | grep -oP 'Mapped public port \K[0-9]+' \
  | head -n1)

if [[ -z "${PORT:-}" ]]; then
  log "ERREUR : impossible de recuperer un port via NAT-PMP (VPN down ?)"
  exit 1
fi

# On refait aussi la demande TCP pour coherence
natpmpc -a 1 0 tcp 60 -g "$VPN_GATEWAY" >/dev/null 2>&1 || true

log "Port attribue par ProtonVPN : $PORT"

# 2. Se logger sur qBittorrent Web API (cookie session)
COOKIE=$(curl -s -i --header "Referer: $QBT_HOST" \
  --data "username=$QBT_USER&password=$QBT_PASS" \
  "$QBT_HOST/api/v2/auth/login" \
  | grep -i '^set-cookie:' | sed 's/.*SID=\([^;]*\).*/\1/')

if [[ -z "${COOKIE:-}" ]]; then
  log "ERREUR : login qBittorrent echoue (verifier QBT_USER/QBT_PASS)"
  exit 2
fi

# 3. Pousser le nouveau port dans les preferences qBittorrent
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
  --cookie "SID=$COOKIE" \
  --header "Referer: $QBT_HOST" \
  --data "json={\"listen_port\":$PORT}" \
  "$QBT_HOST/api/v2/app/setPreferences")

if [[ "$HTTP_CODE" != "200" ]]; then
  log "ERREUR : setPreferences a renvoye HTTP $HTTP_CODE"
  exit 3
fi

log "OK : qBittorrent ecoute maintenant sur le port $PORT"
exit 0

Comment l'utiliser

  1. Adapter les variables en tête du script : QBT_PASS obligatoire, éventuellement VPN_GATEWAY si votre setup GlueTUN utilise une autre plage.
  2. Installer natpmpc si absent : sur Synology, sudo synopkg install natpmp ou via Entware.
  3. Tester en console : bash /volume1/docker/scripts/pf-proton.sh. Vous devez voir "qBittorrent ecoute maintenant sur le port XXXXX".
  4. Ajouter au Planificateur de tâches DSM (cron) : */10 * * * * /volume1/docker/scripts/pf-proton.sh

Backup config NAS Synology vers cloud chiffré

Bash · 55 lignes · Cron quotidien 4h du matin

Le problème

Reconstituer de zéro ses profils TRaSH, ses custom formats, ses règles Prowlarr… c'est plusieurs dizaines d'heures. Et Hyper Backup ne protège pas contre un ransomware qui chiffre le NAS entier.

La solution

Tar + GPG local (clé asymétrique), puis rclone vers B2/Storj. Rotation à 7 backups. Notification DSM sur échec. Le cloud ne voit QUE de la donnée chiffrée.

Compatibilité : Synology DSM 7 · rclone · gpg · Backblaze B2 / Storj

Le code

#!/bin/bash
#
# Backup chiffre des configs Docker vers cloud (B2 / Storj)
# Dependances : tar, gpg, rclone (installer via Entware ou package center)
# Usage cron : 0 4 * * * /volume1/docker/scripts/backup-configs.sh
#

set -euo pipefail

# ============ VARIABLES A ADAPTER ============
SRC_DIR="/volume1/docker"                 # Dossier a sauvegarder (tous les configs containers)
TMP_DIR="/volume1/tmp/backup"             # Dossier tampon local
GPG_RECIPIENT="votre-email@example.com"   # Identite GPG (cle publique deja importee)
RCLONE_REMOTE="b2-chiffre:monratio-backup" # Nom remote rclone + bucket
KEEP_BACKUPS=7                            # Nombre de backups a conserver
LOG_FILE="/var/log/backup-configs.log"
# =============================================

timestamp() { date '+%Y%m%d-%H%M%S'; }
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE"; }

# Notification DSM si erreur (trap sur exit anormal)
notify_error() {
  /usr/syno/bin/synodsmnotify @administrators "Backup configs KO" "Consultez $LOG_FILE"
}
trap 'notify_error' ERR

mkdir -p "$TMP_DIR"
STAMP=$(timestamp)
ARCHIVE="$TMP_DIR/docker-configs-$STAMP.tar.gz"
ENCRYPTED="$ARCHIVE.gpg"

# 1. Creer l'archive compressee (exclure les caches et media lourds)
log "Creation archive $ARCHIVE…"
tar --exclude='*/cache/*' --exclude='*/logs/*' --exclude='*/MediaCover/*' \
    -czf "$ARCHIVE" -C "$(dirname "$SRC_DIR")" "$(basename "$SRC_DIR")"

# 2. Chiffrer avec GPG (cle publique du destinataire)
log "Chiffrement GPG (recipient: $GPG_RECIPIENT)..."
gpg --batch --yes --trust-model always --encrypt \
    --recipient "$GPG_RECIPIENT" \
    --output "$ENCRYPTED" "$ARCHIVE"

rm -f "$ARCHIVE"  # plus besoin du tar en clair

# 3. Upload rclone
log "Upload vers $RCLONE_REMOTE…"
rclone copy "$ENCRYPTED" "$RCLONE_REMOTE/" --progress --stats 0

# 4. Rotation : garder les $KEEP_BACKUPS plus recents
log "Rotation (garde les $KEEP_BACKUPS plus recents)..."
rclone lsf "$RCLONE_REMOTE/" --include 'docker-configs-*.tar.gz.gpg' \
  | sort -r | tail -n +$((KEEP_BACKUPS + 1)) \
  | while IFS= read -r OLD; do
      log "  Suppression : $OLD"
      rclone delete "$RCLONE_REMOTE/$OLD"
    done

rm -f "$ENCRYPTED"
log "OK : backup $STAMP termine"
exit 0

Comment l'utiliser

  1. Installer rclone et gpg sur le NAS (Entware conseillé : opkg install rclone gnupg).
  2. Générer ou importer une paire GPG : gpg --gen-key, puis noter l'identifiant utilisé dans GPG_RECIPIENT.
  3. Configurer un remote rclone : rclone config (choisir Backblaze B2 ou Storj, coller les clés API).
  4. Test manuel : bash /volume1/docker/scripts/backup-configs.sh. Vérifier l'upload sur le bucket.
  5. Ajouter en cron : 0 4 * * * /volume1/docker/scripts/backup-configs.sh (tous les jours à 4h).

Monitoring stack (Discord webhook)

Bash · 45 lignes · Cron toutes les 15 min

Le problème

Rien n'est pire qu'un stack qui tourne en mode "zombie" : GlueTUN mort, qBittorrent qui continue avec l'IP réelle du NAS. Vous ne le voyez pas, mais votre FAI si.

La solution

Un health check léger : containers UP ? IP publique != IP réelle ? Si problème, un POST Discord vous réveille sur mobile. Pas besoin de Prometheus/Grafana pour ça.

Compatibilité : Docker · curl · ipinfo.io · Discord webhook

Le code

#!/bin/bash
#
# Monitoring stack docker + VPN leak check -> Discord webhook
# Dependances : docker, curl, jq
# Usage cron : */15 * * * * /volume1/docker/scripts/monitor-stack.sh
#

set -uo pipefail  # pas de -e : on veut continuer meme si un check echoue

# ============ VARIABLES A ADAPTER ============
CONTAINERS=("gluetun" "qbittorrent" "prowlarr" "sonarr" "radarr" "bazarr" "jellyfin")
DISCORD_WEBHOOK="https://discord.com/api/webhooks/XXXXX/YYYYY"
MY_REAL_IP="82.66.XX.XX"                   # Votre IP publique reelle (celle du FAI)
IP_CHECK_CMD="docker exec gluetun wget -qO- https://ipinfo.io/ip"
# =============================================

ALERTS=()

# 1. Verifier que chaque container est UP
for C in "${CONTAINERS[@]}"; do
  STATUS=$(docker inspect -f '{{.State.Status}}' "$C" 2>/dev/null || echo "missing")
  if [[ "$STATUS" != "running" ]]; then
    ALERTS+=("Container **$C** est en etat : \`$STATUS\`")
  fi
done

# 2. Verifier que l'IP publique via GlueTUN n'est PAS l'IP reelle
#    (si c'est l'IP reelle = fuite VPN = urgence)
VPN_IP=$($IP_CHECK_CMD 2>/dev/null | tr -d '[:space:]')
if [[ -z "$VPN_IP" ]]; then
  ALERTS+=("Impossible de recuperer l'IP publique via GlueTUN (VPN down ?)")
elif [[ "$VPN_IP" == "$MY_REAL_IP" ]]; then
  ALERTS+=("FUITE VPN : IP publique = IP reelle (**$VPN_IP**) !")
fi

# 3. Si alerte : POST Discord
if [[ ${#ALERTS[@]} -gt 0 ]]; then
  MESSAGE=":rotating_light: **Alerte stack NAS** ($(date '+%F %H:%M'))\n"
  for A in "${ALERTS[@]}"; do
    MESSAGE+="- $A\n"
  done

  curl -sS -X POST -H "Content-Type: application/json" \
    -d "{\"content\": \"$MESSAGE\"}" \
    "$DISCORD_WEBHOOK" >/dev/null
fi

exit 0

Comment l'utiliser

  1. Créer un webhook Discord sur votre serveur perso : Paramètres du salon > Intégrations > Webhooks > Copier l'URL.
  2. Récupérer votre vraie IP publique (ex: sur ifconfig.me depuis un navigateur) et la coller dans MY_REAL_IP.
  3. Tester en tuant volontairement un container : docker stop sonarr puis lancer le script. Vous devez recevoir un ping Discord.
  4. Ajouter en cron : */15 * * * * /volume1/docker/scripts/monitor-stack.sh

Nettoyage qBittorrent (HnR prevention)

Python · 55 lignes · Cron quotidien 3h du matin

Le problème

Votre disque sature à cause de centaines de torrents finis depuis des mois. Supprimer à la main = risque d'effacer des torrents qui portent encore votre ratio, ou des HnR en cours de seed obligatoire.

La solution

Règle conservatrice : on ne supprime qu'au cumul de 3 conditions (terminé + seedé > 14j + ratio > 1.5), sauf si le torrent porte un tag "keep-forever". Ça libère 20-40% d'espace sans jamais casser un Hit and Run.

Compatibilité : qBittorrent v4.5+ · Python 3.9+ · qbittorrent-api

Le code

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Nettoyage qBittorrent : supprime les torrents termines et seedes assez longtemps,
en preservant ceux marques 'keep-forever'.

Dependance : pip install qbittorrent-api
Usage cron : 0 3 * * * /usr/bin/python3 /volume1/docker/scripts/qbt-cleanup.py
"""

import sys
import time
import logging
from datetime import datetime
import qbittorrentapi

# ============ VARIABLES A ADAPTER ============
QBT_HOST = "http://localhost:8080"
QBT_USER = "admin"
QBT_PASS = "votre_password_qbt"

MIN_SEEDING_DAYS = 14       # Age minimum en jours depuis la fin du dl
MIN_RATIO = 1.5             # Ratio minimum pour suppression
KEEP_TAG = "keep-forever"   # Tag qBittorrent a preserver
DELETE_FILES = True         # True = supprime aussi les fichiers, False = juste le torrent
LOG_FILE = "/var/log/qbt-cleanup.log"
# =============================================

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger(__name__)


def main() -> int:
    try:
        client = qbittorrentapi.Client(host=QBT_HOST, username=QBT_USER, password=QBT_PASS)
        client.auth_log_in()
    except qbittorrentapi.LoginFailed as exc:
        log.error("Login echec : %s", exc)
        return 1

    now = time.time()
    min_age_sec = MIN_SEEDING_DAYS * 86400
    to_delete = []

    for t in client.torrents_info():
        # Condition 1 : torrent effectivement termine (downloaded = 100%)
        if t.progress < 1.0:
            continue

        # Condition 2 : tag de preservation ?
        tags = [tag.strip() for tag in (t.tags or "").split(",")]
        if KEEP_TAG in tags:
            continue

        # Condition 3 : seede depuis assez longtemps ?
        completion_ts = t.completion_on or 0
        if completion_ts == 0 or (now - completion_ts) < min_age_sec:
            continue

        # Condition 4 : ratio suffisant ?
        if t.ratio < MIN_RATIO:
            continue

        to_delete.append(t)

    log.info("Candidats a la suppression : %d / %d torrents", len(to_delete), len(list(client.torrents_info())))

    for t in to_delete:
        age_days = (now - t.completion_on) / 86400
        log.info(
            "  SUPPRESSION : %s (ratio=%.2f, seed=%.1fj, size=%.1f GB)",
            t.name, t.ratio, age_days, t.size / (1024 ** 3),
        )
        client.torrents_delete(delete_files=DELETE_FILES, torrent_hashes=t.hash)

    log.info("Termine. Supprimes : %d", len(to_delete))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Comment l'utiliser

  1. Installer la lib : pip3 install qbittorrent-api (sur DSM, via Entware ou un venv dans /volume1/docker/scripts/).
  2. Taguer les torrents que vous voulez garder à vie avec keep-forever (clic droit dans qBittorrent > Tags).
  3. Test en mode dry-run : mettre DELETE_FILES = False puis inspecter le log pour vérifier que les bons torrents sont sélectionnés.
  4. Une fois sûr : remettre DELETE_FILES = True et ajouter en cron : 0 3 * * * /usr/bin/python3 /volume1/docker/scripts/qbt-cleanup.py

Synchroniser horloge NAS (NTP force)

Bash · 10 lignes · Manuel ou cron hebdo

Le problème

Certains trackers privés (HDB, BTN) refusent les annonces dont le timestamp dérive de plus de 60 secondes. Un NAS en dérive silencieuse finit par voir ses torrents rester en "stalled".

La solution

Une passe de force NTP contre plusieurs serveurs français, puis log du décalage corrigé. 10 lignes, zéro dépendance.

Compatibilité : Synology DSM 7 · ntpdate / systemd-timesyncd

Le code

#!/bin/bash
#
# Force une sync NTP immediate contre plusieurs serveurs.
# Usage manuel ou cron : 0 6 * * 1 /volume1/docker/scripts/ntp-sync.sh
#
set -euo pipefail

NTP_SERVERS=("ntp.obspm.fr" "fr.pool.ntp.org" "time.google.com")

for SERVER in "${NTP_SERVERS[@]}"; do
  echo "[$(date '+%F %T')] Sync contre $SERVER…"
  ntpdate -u "$SERVER" && break
done

echo "[$(date '+%F %T')] Heure systeme : $(date)"

Comment l'utiliser

  1. Lancer manuellement : sudo bash /volume1/docker/scripts/ntp-sync.sh (requiert root pour modifier l'horloge système).
  2. Optionnel en cron hebdo : 0 6 * * 1 /volume1/docker/scripts/ntp-sync.sh (tous les lundis à 6h).
  3. Si ntpdate n'est pas installé : sudo synopkg install ntpdate ou utiliser chrony via Entware.

Renommage en masse Sonarr/Radarr (bonus)

Python · 60 lignes · One-shot (migration)

Le problème

Vous migrez une vieille bibliothèque vers Sonarr/Radarr. Les fichiers ont des noms genre LeFilm.2021.1080p.FRENCH.BluRay.x264-TEAM.mkv. Les *arr refusent d'importer ou se plantent dans les matches.

La solution

La lib guessit fait le gros du travail : elle détecte titre, année, saison/épisode. Le script renomme ensuite au format exact attendu par les *arr : Titre (Année).ext ou Série - S01E01 - Titre.ext.

Compatibilité : Python 3.9+ · guessit · standalone

Le code

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Renommage en masse au format Sonarr/Radarr.
Films  : 'Titre (Annee).ext'
Series : 'Titre - S01E01 - Nom episode.ext'

Dependance : pip install guessit
Usage : python3 rename-for-arr.py /volume1/video/Films --dry-run
"""

import argparse
import logging
import sys
from pathlib import Path
from guessit import guessit

# ============ VARIABLES A ADAPTER ============
VIDEO_EXT = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts"}
# =============================================

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
log = logging.getLogger(__name__)


def sanitize(name: str) -> str:
    """Retire les caracteres interdits dans les noms de fichiers."""
    bad = '<>:"/\\|?*'
    return "".join("_" if c in bad else c for c in name).strip()


def build_new_name(info: dict, ext: str) -> str | None:
    title = info.get("title")
    if not title:
        return None
    title = sanitize(title)

    # Cas serie : episode present
    if info.get("type") == "episode" and info.get("season") and info.get("episode"):
        season = int(info["season"])
        ep = int(info["episode"])
        ep_title = sanitize(info.get("episode_title", "")) if info.get("episode_title") else ""
        if ep_title:
            return f"{title} - S{season:02d}E{ep:02d} - {ep_title}{ext}"
        return f"{title} - S{season:02d}E{ep:02d}{ext}"

    # Cas film : annee presente
    year = info.get("year")
    if year:
        return f"{title} ({year}){ext}"

    return f"{title}{ext}"


def main() -> int:
    parser = argparse.ArgumentParser(description="Renommage pour Sonarr/Radarr")
    parser.add_argument("folder", type=Path, help="Dossier a parcourir")
    parser.add_argument("--dry-run", action="store_true", help="Affiche sans renommer")
    args = parser.parse_args()

    if not args.folder.is_dir():
        log.error("Dossier introuvable : %s", args.folder)
        return 1

    count_ok, count_skip = 0, 0
    for path in args.folder.rglob("*"):
        if not path.is_file() or path.suffix.lower() not in VIDEO_EXT:
            continue

        info = guessit(path.name)
        new_name = build_new_name(info, path.suffix.lower())

        if not new_name or new_name == path.name:
            count_skip += 1
            continue

        new_path = path.with_name(new_name)
        log.info("%s -> %s", path.name, new_name)

        if not args.dry_run:
            path.rename(new_path)
        count_ok += 1

    log.info("Fini. Renommes : %d / Ignores : %d", count_ok, count_skip)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Comment l'utiliser

  1. Installer guessit : pip3 install guessit.
  2. Toujours tester en dry-run d'abord : python3 rename-for-arr.py /volume1/video/Films --dry-run. Lire la sortie pour vérifier que les renommages sont corrects.
  3. Lancer pour de vrai : python3 rename-for-arr.py /volume1/video/Films.
  4. Relancer un scan dans Sonarr/Radarr : ils devraient tout importer proprement.

Bonnes pratiques (non négociables)

5 règles qui séparent un setup fiable d'un setup qui plante en silence.

Toujours tester en interactif d'abord

Avant de mettre un script en cron, exécutez-le à la main dans la console SSH. Lisez toute la sortie. Si ça plante sous cron mais pas en interactif, c'est souvent une question de PATH ou de variables d'environnement manquantes.

Logguer toutes les sorties

Redirigez stdout ET stderr vers un fichier : >> /var/log/mon-script.log 2>&1. Sans log, vous ne saurez jamais pourquoi la tâche a échoué à 4h du matin il y a trois semaines.

Notifier en cas d'erreur

Discord webhook, email, notification DSM (synodsmnotify)... peu importe. Un script qui échoue silencieusement, c'est un script qui ne sert à rien. Un trap ERR en Bash ou un try/except en Python, c'est cinq lignes.

JAMAIS de credentials en dur

Sortez les mots de passe dans un fichier .env avec permissions chmod 600, ou utilisez les variables d'environnement du Planificateur DSM. Si vous versionnez vos scripts (Git), ajoutez .env au .gitignore. Personne ne veut retrouver un mot de passe qBittorrent sur GitHub.

Permissions strictes sur les scripts sensibles

Un script qui contient des tokens ou des clés API mérite chmod 700 (lisible par son propriétaire uniquement). Les fichiers .env idem : chmod 600 systématique. Pas d'exception "juste pour tester".