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>
269 lines
10 KiB
Markdown
269 lines
10 KiB
Markdown
# 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 <<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:
|
|
|
|
```sh
|
|
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.
|
|
|
|
```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 '<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.
|
|
|