Skip to content

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'interface wg0 tombe, le conteneur passe en état unhealthy.
  • 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 :

# /etc/systemd/system/ratio-refresh.timer
[Unit]
Description=Timer de synchronisation de ratio BitTorrent
After=docker.service

[Timer]
OnCalendar=*-*-* 00,04,08,12,16,20:00:00
Persistent=true

[Install]
WantedBy=timers.target