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.
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.
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.
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
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.
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
- Adapter les variables en tête du script :
QBT_PASSobligatoire, éventuellementVPN_GATEWAYsi votre setup GlueTUN utilise une autre plage. - Installer
natpmpcsi absent : sur Synology,sudo synopkg install natpmpou via Entware. - Tester en console :
bash /volume1/docker/scripts/pf-proton.sh. Vous devez voir "qBittorrent ecoute maintenant sur le port XXXXX". - Ajouter au Planificateur de tâches DSM (cron) :
*/10 * * * * /volume1/docker/scripts/pf-proton.sh
Backup config NAS Synology vers cloud chiffré
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.
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
- Installer rclone et gpg sur le NAS (Entware conseillé :
opkg install rclone gnupg). - Générer ou importer une paire GPG :
gpg --gen-key, puis noter l'identifiant utilisé dansGPG_RECIPIENT. - Configurer un remote rclone :
rclone config(choisir Backblaze B2 ou Storj, coller les clés API). - Test manuel :
bash /volume1/docker/scripts/backup-configs.sh. Vérifier l'upload sur le bucket. - Ajouter en cron :
0 4 * * * /volume1/docker/scripts/backup-configs.sh(tous les jours à 4h).
Monitoring stack (Discord webhook)
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.
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
- Créer un webhook Discord sur votre serveur perso : Paramètres du salon > Intégrations > Webhooks > Copier l'URL.
- Récupérer votre vraie IP publique (ex: sur ifconfig.me depuis un navigateur) et la coller dans
MY_REAL_IP. - Tester en tuant volontairement un container :
docker stop sonarrpuis lancer le script. Vous devez recevoir un ping Discord. - Ajouter en cron :
*/15 * * * * /volume1/docker/scripts/monitor-stack.sh
Nettoyage qBittorrent (HnR prevention)
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.
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
- Installer la lib :
pip3 install qbittorrent-api(sur DSM, via Entware ou un venv dans/volume1/docker/scripts/). - Taguer les torrents que vous voulez garder à vie avec
keep-forever(clic droit dans qBittorrent > Tags). - Test en mode dry-run : mettre
DELETE_FILES = Falsepuis inspecter le log pour vérifier que les bons torrents sont sélectionnés. - Une fois sûr : remettre
DELETE_FILES = Trueet ajouter en cron :0 3 * * * /usr/bin/python3 /volume1/docker/scripts/qbt-cleanup.py
Synchroniser horloge NAS (NTP force)
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.
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
- Lancer manuellement :
sudo bash /volume1/docker/scripts/ntp-sync.sh(requiert root pour modifier l'horloge système). - Optionnel en cron hebdo :
0 6 * * 1 /volume1/docker/scripts/ntp-sync.sh(tous les lundis à 6h). - Si
ntpdaten'est pas installé :sudo synopkg install ntpdateou utiliserchronyvia Entware.
Renommage en masse Sonarr/Radarr (bonus)
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.
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
- Installer guessit :
pip3 install guessit. - 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. - Lancer pour de vrai :
python3 rename-for-arr.py /volume1/video/Films. - 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".
Et ensuite ?
Ces scripts supposent un stack déjà en place. Si ce n'est pas le cas :
Pas encore le stack en place ?
Le docker-compose complet GlueTUN + *arr + Jellyfin prêt à déployer.
Voir le templatePas encore les profils qualité ?
Profils TRaSH adaptés au marché français (VFF/MULTi prioritaires).
Voir le templateVous voulez plus d'infos sur les *arr ?
Le tuto complet : Sonarr, Radarr, Prowlarr, Bazarr, comment ils s'articulent.
Lire le tuto