# transmission-bt > Moved from nuc to `nas` on 2026-08-30, together with the media > dataset ([nas-install.md](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.21` → `193.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). ```bash #!/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 < /etc/wireguard/wg0.key wg pubkey < /etc/wireguard/wg0.key cat > /etc/wireguard/wg0.conf < /etc/systemd/system/transmission-daemon.service.d/vpn.conf < 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. ```bash 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: ```sh 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 ```sh 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`: ```json "script-torrent-done-enabled": true, "script-torrent-done-filename": "/usr/local/bin/jellyfin-scan.sh" ``` The hook POSTs to Jellyfin's `/Library/Refresh`: ```sh #!/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: ```sh 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 ```sh printf %s '' | 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.