Files
doc/nas/transmission-bt.md
T
Julien LutranandClaude Opus 5 a7f1ac691e doc: FTTH migration — gateway is now 192.168.0.1
The Archer C7 at .2 is gone; the FTTH box at .1 is the gateway. Every
static holdout (nas host, nuc host, privoxy, transmission-bt) pointed at
the dead .2 and had no internet — the reported symptom was privoxy.

Also record two things the migration broke that were not obvious:

- DHCP reservations did not carry over. blocky held .254 by reservation
  on the C7; a lease renew on the FTTH box moved it and took LAN DNS
  down. It is static now. jellyfin-* are still DHCP on stale leases.
- The FTTH box advertises native IPv6. transmission-bt's tunnel is
  AllowedIPs = 0.0.0.0/0, so v6 egressed around the kill switch on the
  home address. IPv6 is now disabled in that container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:08:42 +02:00

10 KiB

transmission-bt

Moved from nuc to nas on 2026-08-30, together with the media dataset (nas-install.md §7). Its WireGuard tunnel is entirely in-container, so ks4 needed no change — the peer is still 10.8.0.21. The watch folder moved with it: /export/media/.watchdir on nas, not /srv/media/.watchdir on nuc.

BitTorrent client in an unprivileged Incus container on nas, with an always-on VPN: all peer traffic exits via ks4's public IP through a WireGuard tunnel to the wireguard container on ks4. Kill switch by construction — eth0 has no default route, so with the tunnel down the container simply has no path to the internet.

  • Image: images:ubuntu/24.04, IP: 192.168.0.7 (macvlan, static via netplan)
  • Web UI: http://192.168.0.7:9091 — no auth (rpc-authentication-required: false); access control is the RPC whitelist (192.168.0.* only)
  • Egress: WG peer 10.8.0.21193.70.35.17:51845, AllowedIPs 0.0.0.0/0 (verified: curl ifconfig.me from the container returns ks4's IP)
  • Downloads: /media/downloads (= tank/media, same dataset Jellyfin reads); in-progress files in /media/.incomplete so Jellyfin never scans partials
  • Watch folder: scp a .torrent into /export/media/.watchdir on the host and it auto-downloads (see "Watch folder" below)
  • transmission-daemon is BindsTo=wg-quick@wg0.service and binds peer traffic to 10.8.0.21 — three independent layers against leaks (no default route, unit binding, socket binding)

Anti-leak design

  1. netplan gives eth0 only: LAN /24 (web UI + DNS via blocky) and a /32 host route to the WG endpoint via the home gateway.
  2. wg-quick full-tunnel mode adds its fwmark policy routing + iptables anti-leak rule (iptables package required — its absence makes wg-quick fail with iptables-restore: command not found).
  3. LAN traffic keeps working thanks to wg-quick's suppress_prefixlength 0 rule (connected routes win over the tunnel's default).

Install script

Run as root on the Incus host. Requires the peer added on ks4 (below).

#!/usr/bin/env bash
set -euxo pipefail

CNAME="${CNAME:-transmission-bt}"
IMAGE="${IMAGE:-images:ubuntu/24.04}"

incus launch "$IMAGE" "$CNAME"
sleep 8
incus config set "$CNAME" environment.DEBIAN_FRONTEND=noninteractive
incus exec "$CNAME" -- timedatectl set-timezone Europe/Paris

# static LAN config, NO default route (kill switch), /32 to the WG endpoint
incus exec "$CNAME" -- bash -c 'cat > /etc/netplan/10-lxc.yaml <<EOF
network:
  version: 2
  ethernets:
    eth0:
      addresses: [192.168.0.7/24]
      nameservers:
        addresses: [192.168.0.254]
      routes:
        - to: 193.70.35.17/32
          via: 192.168.0.1
EOF
chmod 600 /etc/netplan/10-lxc.yaml
netplan apply'

# packages need a temporary default route (removed right after)
incus exec "$CNAME" -- ip route add default via 192.168.0.1
incus exec "$CNAME" -- apt-get update
incus exec "$CNAME" -- apt-get install -y --no-install-recommends \
    transmission-daemon wireguard-tools iptables curl
incus exec "$CNAME" -- ip route del default via 192.168.0.1

# WireGuard full tunnel (generate key, print pubkey for the ks4 side)
incus exec "$CNAME" -- bash -c 'umask 077
wg genkey > /etc/wireguard/wg0.key
wg pubkey < /etc/wireguard/wg0.key
cat > /etc/wireguard/wg0.conf <<EOF
[Interface]
Address = 10.8.0.21/24
PrivateKey = $(cat /etc/wireguard/wg0.key)

[Peer]
# wireguard container on ks4 — ALL traffic routes through it
PublicKey = TVs6d7bXTvJ0ZluTLb8wR+zIrsLvkH1944pzM+3dZXM=
Endpoint = 193.70.35.17:51845
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
EOF'
incus exec "$CNAME" -- systemctl enable --now wg-quick@wg0

# media share (same dataset as jellyfin-server)
incus config device add "$CNAME" media disk source=/export/media path=/media shift=true
incus exec "$CNAME" -- mkdir -p /media/downloads /media/.incomplete
incus exec "$CNAME" -- chown debian-transmission:debian-transmission \
    /media/downloads /media/.incomplete

# transmission config (edit only while the daemon is stopped)
incus exec "$CNAME" -- systemctl stop transmission-daemon
incus exec "$CNAME" -- bash -c '
cd /var/lib/transmission-daemon/.config/transmission-daemon
sed -i \
  -e "s|\"download-dir\":.*|\"download-dir\": \"/media/downloads\",|" \
  -e "s|\"incomplete-dir\":.*|\"incomplete-dir\": \"/media/.incomplete\",|" \
  -e "s|\"incomplete-dir-enabled\":.*|\"incomplete-dir-enabled\": true,|" \
  -e "s|\"rpc-whitelist\":.*|\"rpc-whitelist\": \"127.0.0.1,::1,192.168.0.*\",|" \
  -e "s|\"rpc-authentication-required\":.*|\"rpc-authentication-required\": false,|" \
  -e "s|\"bind-address-ipv4\":.*|\"bind-address-ipv4\": \"10.8.0.21\",|" \
  settings.json'

# transmission lives and dies with the tunnel
incus exec "$CNAME" -- mkdir -p /etc/systemd/system/transmission-daemon.service.d
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/transmission-daemon.service.d/vpn.conf <<EOF
[Unit]
BindsTo=wg-quick@wg0.service
After=wg-quick@wg0.service
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl start transmission-daemon
incus config set "$CNAME" boot.autostart=true

On ks4 (root), authorize the peer with the pubkey printed above:

incus exec wireguard -- wg set wg0 peer <PUBKEY> allowed-ips 10.8.0.21/32
incus exec wireguard -- wg-quick save wg0

Watch folder (auto-add torrents)

Drop a .torrent into /export/media/.watchdir on the host and Transmission auto-adds it and starts downloading — no web UI needed. The folder lives on the shared tank/media dataset (/media/.watchdir inside the container).

Edit the active config only while the daemon is stopped (it rewrites settings.json on exit). The active file is /var/lib/transmission-daemon/info/settings.json — the daemon runs with --config-dir /var/lib/transmission-daemon/info from /etc/default/transmission-daemon, not the .config dir.

incus exec transmission-bt -- install -d -o debian-transmission \
    -g debian-transmission -m 0775 /media/.watchdir
incus exec transmission-bt -- systemctl stop transmission-daemon
incus exec transmission-bt -- python3 - <<'PY'
import json
p = "/var/lib/transmission-daemon/info/settings.json"
c = json.load(open(p))
c.update({
    "watch-dir": "/media/.watchdir",
    "watch-dir-enabled": True,
    "watch-dir-force-generic": True,      # poll (reliable across the shift mount)
    "trash-original-torrent-files": True, # delete the .torrent once added
})
json.dump(c, open(p, "w"), indent=4)
PY
incus exec transmission-bt -- systemctl start transmission-daemon

Usage — the .torrent is consumed within a few seconds:

scp some.torrent root@192.168.0.4:/export/media/.watchdir/
  • watch-dir-force-generic: true makes Transmission poll the folder instead of using inotify, so it reliably sees files written from the host side across the shift-mounted share.
  • trash-original-torrent-files: true self-cleans the folder. A malformed .torrent is not trashed and gets retried each poll — delete it by hand.
  • The watchdir is owned by debian-transmission (0775): root's scp writes fine, and Transmission can read the file and remove it after adding.

Verification

incus exec transmission-bt -- wg show wg0 latest-handshakes   # non-zero timestamp
incus exec transmission-bt -- curl -s https://ifconfig.me     # must print 193.70.35.17
incus exec transmission-bt -- bash -c "ping -c1 -W2 8.8.8.8 || echo kill-switch OK"  # with wg0 down
# web UI must be tested from a LAN machine — the macvlan quirk means the
# nas host itself cannot reach 192.168.0.7 (macvlan, by design)

Notes

  • No inbound peer port is forwarded (would need a proxy device on ks4
    • DNAT through the tunnel); torrents work fine outbound-only, just connect to fewer peers.
  • Jellyfin sees finished downloads under /media/downloads — add it as a library folder or move files into the movie/show trees.
  • The image server check can make incus launch hang on slow WAN — launching from the cached image fingerprint (incus image list) bypasses it.

Jellyfin library scan on completion (2026-08-31)

Jellyfin cannot notice finished downloads by itself any more. It watches libraries with inotify, but since the media moved to nas the writer (transmission, here) and the reader (jellyfin-server on nuc, over NFS) are on different machines — an inotify event never crosses that. Before the move both shared one local dataset on nuc, so it just worked.

So transmission tells Jellyfin explicitly, via script-torrent-done:

"script-torrent-done-enabled": true,
"script-torrent-done-filename": "/usr/local/bin/jellyfin-scan.sh"

The hook POSTs to Jellyfin's /Library/Refresh:

#!/bin/sh
KEY_FILE=/etc/jellyfin-scan.key
JF=http://192.168.0.5:8096
NAME="${TR_TORRENT_NAME:-unknown}"
[ -r "$KEY_FILE" ] || { logger -t jellyfin-scan "no readable key file; skipped ($NAME)"; exit 0; }
KEY=$(tr -d " \t\r\n" < "$KEY_FILE")
if curl -fsS -m 15 -X POST -H "X-Emby-Token: $KEY" "$JF/Library/Refresh" >/dev/null 2>&1; then
    logger -t jellyfin-scan "library scan requested after: $NAME"
else
    logger -t jellyfin-scan "library scan request FAILED (nuc off?) after: $NAME"
fi
exit 0

Design points, each of which matters:

  • Always exit 0, never block. transmission runs the hook synchronously; a hanging or failing hook stalls the daemon. Both paths are tested — success and unreadable-key both exit 0.
  • nuc is usually powered off. The request then fails, logs FAILED (nuc off?), and Jellyfin picks the file up on its next scheduled scan. Not an error worth alerting on.
  • The key file is 640 root:debian-transmission — the hook runs as debian-transmission, so it must be group-readable, and nothing wider.
  • Reachable despite the kill switch: 192.168.0.5 is on the directly connected LAN, so it needs no default route.

Verify:

incus exec transmission-bt -- su -s /bin/sh debian-transmission \
    -c 'TR_TORRENT_NAME=selftest /usr/local/bin/jellyfin-scan.sh'
incus exec transmission-bt -- journalctl -t jellyfin-scan -n 3

Rotating the key: create a new one in Jellyfin (Dashboard → API Keys), then

printf %s '<new-key>' | incus exec transmission-bt -- sh -c \
  'umask 027; cat > /etc/jellyfin-scan.key; chown root:debian-transmission /etc/jellyfin-scan.key'

⚠️ Edit settings.json only while the daemon is stopped — transmission rewrites the whole file on shutdown and will silently discard changes made underneath it.