Isolation VPN de Client Torrent et Maintien de Ratio (Transmission & Rustatio)
Isolation VPN de Client Torrent et Maintien de Ratio (Transmission & Rustatio)
Note
La gestion de flux BitTorrent en environnement serveur impose deux impératifs : 1. Protection de l'adresse IP publique : Tout le trafic P2P doit impérativement être acheminé à travers un tunnel VPN chiffré. En cas de coupure de ce tunnel, le trafic doit être bloqué immédiatement pour éviter toute fuite d'adresse IP. 2. Préservation du ratio de partage : Sur les trackers privés, la suppression locale des fichiers téléchargés pour récupérer de l'espace disque stoppe le partage et abaisse le ratio.
Cette architecture configure un client WireGuard qui sert de namespace réseau commun pour Transmission et Rustatio, des relais TCP Socat pour autoriser les échanges avec les applications internes (Radarr, Sonarr), des sondes de santé (healthchecks) et un script Python dédié (ratio_refresh.py) qui synchronise automatiquement les torrents terminés vers un seeder virtuel.
1. Topologie Réseau et Relais Socat
graph TD
WAN["Internet / Trackers P2P"] <==>|"Tunnel VPN WireGuard"| WG["Conteneur wireguard-client"]
subgraph VPN_Net["Namespace Réseau du VPN (network_mode: service:wireguard-client)"]
WG
Trans["Transmission (Client Torrent)"]
Rust["Rustatio (Seeder Virtuel)"]
end
subgraph Relais["Relais TCP Socat"]
SocatT["socat-transmission<br/>Port 9091"]
SocatR["socat-rustatio<br/>Port 80"]
end
subgraph Reseau_Interne["Réseau Interne (backend-network)"]
Arr["Radarr / Sonarr"]
Cron["Planificateur (Cron / Timer)<br/>ratio_refresh.py"]
end
Trans -.->|"Trafic réseau forcé"| WG
Rust -.->|"Trafic réseau forcé"| WG
Arr -->|"Contrôle API RPC"| SocatT
SocatT -->|"Redirection locale 127.0.0.1:9091"| Trans
Cron -->|"Contrôle API Rustatio"| SocatR
SocatR -->|"Redirection locale 127.0.0.1:8080"| Rust
2. Déploiement Docker Compose avec Healthchecks
L'isolation réseau repose sur network_mode: "service:wireguard-client".
Pour que les services dépendants (transmission, rustatio, socat-rustatio) ne démarrent pas ou soient redémarrés si le tunnel VPN tombe, un healthcheck actif teste en continu la connectivité réseau sortante à travers le VPN.
services:
# 1. Passerelle VPN et Namespace Réseau Commun
wireguard-client:
image: linuxserver/wireguard:latest
container_name: wireguard-client
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
volumes:
- /opt/wireguard/client:/config
- /lib/modules:/lib/modules:ro
ports:
- "8091:9091" # Interface Web Transmission
- "8080:8080" # Interface Web Rustatio
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
healthcheck:
test: ["CMD", "ping", "-c", "1", "1.1.1.1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# 2. Client Torrent Transmission
transmission:
image: linuxserver/transmission:latest
container_name: transmission
restart: unless-stopped
network_mode: "service:wireguard-client"
depends_on:
wireguard-client:
condition: service_healthy
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- USER=transmission_user
- PASS=votre_mot_de_passe
volumes:
- /opt/transmission/config:/config
- /srv/data/downloads:/downloads
- /srv/data/watch:/watch
healthcheck:
test: ["CMD", "ping", "-c", "1", "1.1.1.1"]
interval: 60s
timeout: 20s
start_period: 60s
# 3. Émulateur de Seeding Virtuel (Rustatio)
rustatio:
image: ghcr.io/takitsu21/rustatio:latest
container_name: rustatio
restart: unless-stopped
network_mode: "service:wireguard-client"
depends_on:
wireguard-client:
condition: service_healthy
environment:
- RUSTATIO_AUTH_TOKEN=${RUSTATIO_AUTH_TOKEN}
volumes:
- /opt/rustatio/data:/data
- /opt/torrents/current:/torrents
# 4. Relais TCP pour Transmission
socat-transmission:
image: alpine/socat:latest
container_name: socat-transmission
restart: unless-stopped
command: tcp-listen:9091,fork,reuseaddr tcp-connect:127.0.0.1:9091
network_mode: "service:wireguard-client"
depends_on:
wireguard-client:
condition: service_healthy
# 5. Relais TCP pour Rustatio sur le réseau interne
socat-rustatio:
image: alpine/socat:latest
container_name: socat-rustatio
restart: unless-stopped
command: tcp-listen:80,fork,reuseaddr tcp-connect:127.0.0.1:8080
networks:
backend-network:
ipv4_address: 10.2.1.32
healthcheck:
test: ["CMD-SHELL", "wget --spider http://127.0.0.1/ || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
wireguard-client:
condition: service_healthy
networks:
backend-network:
driver: bridge
ipam:
config:
- subnet: 10.2.1.0/24
Analyse des Sondes de Santé (Healthchecks)
wireguard-client: Émet un ping ICMP régulier vers une adresse publique (1.1.1.1). Si le handshake WireGuard est interrompu ou que l'interfacewg0tombe, le conteneur passe en étatunhealthy.transmission: Vérifie également que sa connectivité sortante est opérationnelle.socat-rustatio: Interroge via HTTP l'interface exposée pour s'assurer que le proxy TCP fonctionne et que le serveur web Rustatio répond.
3. Le Script de Maintien de Ratio (ratio_refresh.py)
Lorsque des fichiers téléchargés par Transmission sont supprimés du disque de stockage (après visionnage ou archivage), le fichier .torrent d'origine doit continuer à être seedé virtuellement pour alimenter le ratio de partage.
Le script Python ci-dessous assure cette tâche de manière autonome :
1. Extraction et Parsing Bencode : Analyse les fichiers .torrent pour en extraire l'empreinte info_hash (SHA-1) et les URLs des trackers.
2. Détection des Suppressions Locales : Compare les torrents gérés par Transmission avec ceux archivés dans son miroir. Tout torrent absent de Transmission est déplacé vers le dossier de Rustatio (/opt/torrents/current).
3. Filtrage des Trackers Non Désirés : Écarte automatiquement les torrents dont les trackers correspondent à une liste noire (BANNED_TRACKERS).
4. Purge des Torrents Périmés : Supprime les fichiers .torrent âgés de plus de 180 jours (6 mois).
5. Réconciliation API avec Rustatio : Interroge l'API HTTP de Rustatio (GET /api/instances) et supprime via l'API (POST /api/grid/delete) les instances orphelines dont le fichier .torrent n'existe plus sur le disque.
Code Source Complet
#!/usr/bin/env python3
"""
Script de synchronisation et de maintenance des torrents entre Transmission et Rustatio.
Exécuté périodiquement via cron ou timer systemd.
"""
import hashlib
import json
import os
import random
import shutil
import string
import sys
import time
import urllib.request
TORRENT_DIR = "/opt/torrents/current"
MIRROR_DIR = "/opt/torrents/mirror"
BANNED_DIR = "/opt/torrents/banned"
TRANSMISSION_TORRENTS_DIR = "/opt/transmission/config/torrents"
SIX_MONTHS_DAYS = 180
BANNED_TRACKERS = ["tracker-public", "tracker-obsolete", "tracker-invalide"]
RUSTATIO_API_URL = "http://10.2.1.32:80"
# =====================================================================
# Décodeur et Encodeur Bencode
# =====================================================================
def decode_bencode(data, index=0):
if index >= len(data):
return None, index
char = data[index:index + 1]
if char == b'i':
end = data.find(b'e', index)
if end == -1:
raise ValueError("Entier non terminé")
val = int(data[index + 1:end])
return val, end + 1
elif char == b'l':
index += 1
lst = []
while index < len(data) and data[index:index + 1] != b'e':
val, index = decode_bencode(data, index)
lst.append(val)
return lst, index + 1
elif char == b'd':
index += 1
dct = {}
while index < len(data) and data[index:index + 1] != b'e':
key, index = decode_bencode(data, index)
if isinstance(key, bytes):
key = key.decode('utf-8', errors='replace')
val, index = decode_bencode(data, index)
dct[key] = val
return dct, index + 1
elif char.isdigit():
colon = data.find(b':', index)
if colon == -1:
raise ValueError("Format de chaîne invalide")
length = int(data[index:colon])
start = colon + 1
end = start + length
val = data[start:end]
return val, end
else:
raise ValueError(f"Caractère bencode invalide '{char}' à l'index {index}")
def encode_bencode(val):
if isinstance(val, int):
return f"i{val}e".encode('utf-8')
elif isinstance(val, str):
b = val.encode('utf-8')
return f"{len(b)}:".encode('utf-8') + b
elif isinstance(val, bytes):
return f"{len(val)}:".encode('utf-8') + val
elif isinstance(val, list):
return b"l" + b"".join(encode_bencode(x) for x in val) + b"e"
elif isinstance(val, dict):
sorted_keys = sorted(val.keys())
parts = []
for k in sorted_keys:
parts.append(encode_bencode(k))
parts.append(encode_bencode(val[k]))
return b"d" + b"".join(parts) + b"e"
else:
raise TypeError(f"Type non supporté : {type(val)}")
def clean_value(val):
if isinstance(val, bytes):
return val.decode('utf-8', errors='replace')
elif isinstance(val, list):
return [clean_value(v) for v in val]
elif isinstance(val, dict):
return {k: clean_value(v) for k, v in val.items()}
return val
def get_torrent_metadata(filepath):
try:
with open(filepath, 'rb') as f:
data = f.read()
decoded, _ = decode_bencode(data)
except Exception as e:
print(f" [Erreur] Lecture impossible de {filepath}: {e}")
return None
if not decoded or 'info' not in decoded:
return None
info = decoded['info']
try:
info_bytes = encode_bencode(info)
sha1 = hashlib.sha1(info_bytes).digest()
info_hash = list(sha1)
except Exception as e:
print(f" [Erreur] Calcul SHA-1 impossible pour {filepath}: {e}")
return None
name = clean_value(info.get('name', b''))
announce = clean_value(decoded.get('announce', b''))
trackers = []
if announce:
trackers.append(announce)
announce_list = clean_value(decoded.get('announce-list'))
if announce_list:
for item in announce_list:
if isinstance(item, list):
trackers.extend(item)
else:
trackers.append(item)
return {
"info_hash": info_hash,
"name": name,
"trackers": trackers,
"comment": clean_value(decoded.get('comment', b'')),
"created_by": clean_value(decoded.get('created by', b''))
}
def is_torrent_banned(metadata, banned_keywords):
if not metadata:
return False
trackers = metadata.get("trackers", [])
for keyword in banned_keywords:
kw = keyword.lower()
for t in trackers:
if kw in t.lower():
return True
return False
# =====================================================================
# Réconciliation avec l'API Rustatio
# =====================================================================
def reconcile_rustatio_instances():
token = os.environ.get("RUSTATIO_AUTH_TOKEN")
if not token:
print(" [API Info] RUSTATIO_AUTH_TOKEN non défini, réconciliation ignorée.")
return
print(">>> Réconciliation des instances dans Rustatio...")
url = f"{RUSTATIO_API_URL}/api/instances"
req = urllib.request.Request(
url,
headers={"Authorization": f"Bearer {token}"},
method="GET"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
res_data = json.loads(response.read().decode("utf-8"))
except Exception as e:
print(f" [API Erreur] Impossible de joindre l'API Rustatio : {e}")
return
if not res_data or not res_data.get("success"):
return
instances = res_data.get("data", [])
instances_to_delete = []
for inst in instances:
if inst.get("source") != "watch_folder":
continue
torrent_info = inst.get("torrent", {})
info_hash_list = torrent_info.get("info_hash")
if not info_hash_list:
continue
try:
info_hash_hex = bytes(info_hash_list).hex()
except Exception:
continue
torrent_filename = f"{info_hash_hex}.torrent"
torrent_filepath = os.path.join(TORRENT_DIR, torrent_filename)
if not os.path.exists(torrent_filepath):
instances_to_delete.append({
"id": inst.get("id"),
"name": torrent_info.get("name", "Inconnu")
})
if not instances_to_delete:
print(" Aucune instance orpheline détectée.")
return
print(f" {len(instances_to_delete)} instance(s) orpheline(s) à supprimer.")
for target in instances_to_delete:
delete_url = f"{RUSTATIO_API_URL}/api/grid/delete"
body = json.dumps({"ids": [target["id"]]}).encode("utf-8")
del_req = urllib.request.Request(
delete_url,
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},
method="POST"
)
try:
with urllib.request.urlopen(del_req, timeout=10) as response:
del_res = json.loads(response.read().decode("utf-8"))
if del_res.get("success"):
print(f" - Supprimé de Rustatio : {target['name']}")
except Exception as e:
print(f" [API Erreur] Échec de suppression : {e}")
# =====================================================================
# Exécution Principale
# =====================================================================
def main():
os.makedirs(MIRROR_DIR, exist_ok=True)
os.makedirs(TORRENT_DIR, exist_ok=True)
os.makedirs(BANNED_DIR, exist_ok=True)
# 1. Sauvegarde des torrents Transmission vers le miroir local
if os.path.exists(TRANSMISSION_TORRENTS_DIR):
for f in os.listdir(TRANSMISSION_TORRENTS_DIR):
if f.endswith(".torrent"):
src = os.path.join(TRANSMISSION_TORRENTS_DIR, f)
dst = os.path.join(MIRROR_DIR, f)
shutil.copy2(src, dst)
# 2. Détection des torrents supprimés de Transmission -> Déplacement vers Rustatio
active_in_trans = set()
if os.path.exists(TRANSMISSION_TORRENTS_DIR):
active_in_trans = set(f for f in os.listdir(TRANSMISSION_TORRENTS_DIR) if f.endswith(".torrent"))
moved_count = 0
for f in os.listdir(MIRROR_DIR):
if f.endswith(".torrent") and f not in active_in_trans:
src = os.path.join(MIRROR_DIR, f)
dst = os.path.join(TORRENT_DIR, f)
shutil.move(src, dst)
moved_count += 1
if moved_count > 0:
print(f">>> {moved_count} torrent(s) transféré(s) vers le seeder virtuel.")
# 3. Filtrage des trackers exclus
for f in os.listdir(TORRENT_DIR):
if f.endswith(".torrent"):
filepath = os.path.join(TORRENT_DIR, f)
meta = get_torrent_metadata(filepath)
if meta and is_torrent_banned(meta, BANNED_TRACKERS):
shutil.move(filepath, os.path.join(BANNED_DIR, f))
# 4. Purge des fichiers âgés de plus de 180 jours
now = time.time()
cutoff = now - (SIX_MONTHS_DAYS * 86400)
for f in os.listdir(TORRENT_DIR):
if f.endswith(".torrent"):
filepath = os.path.join(TORRENT_DIR, f)
if os.path.getmtime(filepath) < cutoff:
os.remove(filepath)
# 5. Réconciliation via API
reconcile_rustatio_instances()
if __name__ == "__main__":
main()
4. Automatisation de l'Exécution (Cron ou Systemd Timer)
Le script peut être programmé via une tâche Cron standard toutes les 4 heures :
# Synchronisation de ratio toutes les 4 heures
0 */4 * * * /usr/bin/python3 /usr/local/bin/ratio_refresh.py >> /var/log/ratio_refresh.log 2>&1
Ou via une unité Systemd ratio-refresh.timer :