Initial import: nuc and ks4 infrastructure documentation

README with instance tables and nuc<->ks4 network flow chart;
per-container install/troubleshooting docs for nuc (jellyfin
server/client, transmission-bt, bare-metal reinstall) and the
ks4 two-leg backup scheme (incus-copy over wireguard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Julien Lutran
2026-08-09 22:50:07 +02:00
co-authored by Claude Fable 5
commit ee9eaee889
7 changed files with 1255 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
# jellyfin-client
HTPC kiosk **client** in a privileged Incus container on `nuc`:
Jellyfin Media Player fullscreen inside `cage` (Wayland kiosk compositor)
rendering directly to HDMI via DRM/KMS — no X server or desktop on the host.
Also hosts the Spotify Connect endpoint (go-librespot, see below).
- Image: `images:ubuntu/24.04`, IP: `192.168.0.6` (LAN bridge)
- Video: Intel Alder Lake-N iGPU → HDMI (`gpu` device, full card access)
- Audio: Pioneer USB audio (`08e4:0176`), ALSA card `Device`, set as ALSA
default via `/etc/asound.conf`
- Input: Logitech Unifying receiver (K400) via `/dev/input` bind + host udev
database bind; FR (AZERTY) keymap
- Why privileged: hotplug-following directory bind-mounts of `/dev/snd` and
`/dev/input`. Unprivileged alternative: one `unix-char` device per node
(doesn't follow card renumbering on replug).
## How the tricky parts work
Container-specific pitfalls that cost debugging time — all handled by the
install script:
1. **seatd must not bind a VT** (`SEATD_VTBOUND=0` drop-in): with VT binding
it tries to open the host's active tty, which isn't in the container, and
cage hangs forever ("running", `Tasks: 0`, black screen).
2. **libinput needs a udev database**: it only accepts input devices carrying
`ID_INPUT*` properties. The container can't run its own udevd (sysfs
uevent writes are silently denied by the Incus AppArmor profile — an
explicit deny rule that `raw.apparmor` cannot override), so the **host's**
`/run/udev` is bind-mounted read-only to `/opt/host-udev` and a
`run-udev.mount` unit re-binds it onto `/run/udev` at boot (a direct bind
would be shadowed by systemd's `/run` tmpfs). `WLR_LIBINPUT_NO_DEVICES=1`
additionally keeps cage alive when no input device is present.
3. **ALSA default must be pinned**: mpv opens device `default`, which maps to
card 0 (Intel HDA, HDMI-only pcm devices 3/7/8/9) → open fails and mpv
silently falls back to the **null** output (video OK, no sound).
`/etc/asound.conf` pins the default to the Pioneer by card *name*.
4. The kiosk service must NOT use `TTYPath`/`StandardInput=tty` — it hides
all cage/JMP errors on an invisible tty. Log to the journal.
## Install script
```bash
#!/usr/bin/env bash
set -euxo pipefail
CNAME="${CNAME:-jellyfin-client}"
IMAGE="${IMAGE:-images:ubuntu/24.04}"
JMP_VER="${JMP_VER:-1.12.0}" # https://github.com/jellyfin/jellyfin-desktop/releases
incus launch "$IMAGE" "$CNAME" -c security.privileged=true
for i in $(seq 1 30); do
incus exec "$CNAME" -- getent hosts github.com >/dev/null 2>&1 && break
sleep 2
done
incus config set "$CNAME" environment.DEBIAN_FRONTEND=noninteractive
# --- Host devices -------------------------------------------------------------
# iGPU (card + render nodes). gid=44 = "video" group inside the container.
incus config device add "$CNAME" gpu gpu gid=44
# Sound (Pioneer USB audio -> ALSA card) and input devices (keyboard/remote).
# Directory bind-mounts follow hotplug events, so the USB card can be
# re-plugged without restarting the container. No VT/tty devices needed:
# seatd runs with SEATD_VTBOUND=0 (see below).
#
# The host udev database is bound to /opt/host-udev; a mount unit inside the
# container re-binds it to /run/udev at boot (binding /run/udev directly gets
# shadowed when systemd mounts its own tmpfs on /run). libinput only accepts
# input devices that carry ID_INPUT* properties from a udev database, and the
# container cannot run its own udevd (sysfs uevent writes are blocked by the
# Incus AppArmor profile), so it reads the host's database instead.
RAW_LXC="$(cat <<'EOF'
lxc.cgroup2.devices.allow = c 116:* rwm
lxc.cgroup2.devices.allow = c 13:* rwm
lxc.mount.entry = /dev/snd dev/snd none bind,optional,create=dir
lxc.mount.entry = /dev/input dev/input none bind,optional,create=dir
lxc.mount.entry = /run/udev opt/host-udev none bind,ro,optional,create=dir
EOF
)"
incus config set "$CNAME" raw.lxc="$RAW_LXC"
incus restart "$CNAME" # raw.lxc mount entries apply at container start
sleep 5
# --- Packages ------------------------------------------------------------------
incus exec "$CNAME" -- apt-get update
incus exec "$CNAME" -- apt-get upgrade -y
incus exec "$CNAME" -- apt-get install -y --no-install-recommends software-properties-common curl ca-certificates
incus exec "$CNAME" -- add-apt-repository -y universe
incus exec "$CNAME" -- add-apt-repository -y multiverse
incus exec "$CNAME" -- apt-get install -y --no-install-recommends \
cage seatd dbus dbus-user-session qtwayland5 \
intel-media-va-driver-non-free vainfo mesa-utils-bin \
alsa-utils
# Jellyfin Media Player (repo renamed to jellyfin-desktop upstream)
incus exec "$CNAME" -- bash -c "curl -fLo /tmp/jmp.deb \
https://github.com/jellyfin/jellyfin-desktop/releases/download/v${JMP_VER}/jellyfin-media-player_${JMP_VER}-noble.deb"
incus exec "$CNAME" -- apt-get install -y /tmp/jmp.deb
# --- Default audio output = Pioneer -------------------------------------------
# mpv opens ALSA device "default", which otherwise maps to card 0 (Intel HDA,
# HDMI-only pcm devices 3/7/8/9 -> open fails with ENOENT and mpv silently
# falls back to the null output). Pin the default to the Pioneer by card NAME
# so it survives card renumbering. "!" is required to override the compound
# definition in alsa.conf.
incus exec "$CNAME" -- bash -c 'cat > /etc/asound.conf <<EOF
# Pioneer USB audio (card name "Device") is the default output
defaults.pcm.!card "Device"
defaults.ctl.!card "Device"
EOF'
# --- Kiosk user + seat management ----------------------------------------------
incus exec "$CNAME" -- bash -c 'id kiosk >/dev/null 2>&1 || useradd -m -G video,render,input,audio kiosk'
# In a container seatd must NOT bind the seat to a VT (there is no usable VT;
# it would try to open the host's active tty and hang the compositor forever).
incus exec "$CNAME" -- mkdir -p /etc/systemd/system/seatd.service.d
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/seatd.service.d/novt.conf <<EOF
[Service]
Environment=SEATD_VTBOUND=0
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable --now seatd
# Re-bind the host udev database onto /run/udev after systemd sets up /run
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/run-udev.mount <<EOF
[Unit]
Description=Host udev database (read-only bind)
Before=jellyfin-kiosk.service
[Mount]
What=/opt/host-udev
Where=/run/udev
Type=none
Options=bind,ro
[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl enable run-udev.mount
# --- Autostart service -----------------------------------------------------------
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/jellyfin-kiosk.service <<EOF
[Unit]
Description=Jellyfin Media Player (cage Wayland kiosk)
After=seatd.service systemd-user-sessions.service network-online.target
Wants=seatd.service network-online.target
[Service]
User=kiosk
PAMName=login
Environment=QT_QPA_PLATFORM=wayland
Environment=LIBSEAT_BACKEND=seatd
Environment=WLR_LIBINPUT_NO_DEVICES=1
Environment=XKB_DEFAULT_LAYOUT=fr
ExecStart=/usr/bin/cage -d -- /usr/bin/jellyfinmediaplayer --tv --fullscreen
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable jellyfin-kiosk
incus config set "$CNAME" boot.autostart=true
incus restart "$CNAME"
echo "Done. The Jellyfin Media Player UI should appear on the HDMI output."
echo "Check status with: incus exec $CNAME -- systemctl status jellyfin-kiosk"
```
## First-run configuration
1. On the TV, connect JMP to the server: `http://192.168.0.5:8096`.
2. Settings → Audio: "Auto" lands on the Pioneer (ALSA default). Enable
AC3/DTS passthrough only if the DAC/amp decodes them (the A-70 doesn't).
3. Control: K400 keyboard/touchpad, or any Jellyfin app via "Play On"
(JMP announces itself as a remote-control target).
## Spotify Connect (go-librespot)
Same container, same Pioneer output. Announces itself as **"Pioneer A-70"**;
zeroconf auth (no credentials stored), Premium account required.
```bash
#!/usr/bin/env bash
set -euxo pipefail
CNAME="${CNAME:-jellyfin-client}"
GLS_VER="${GLS_VER:-0.7.4}" # https://github.com/devgianlu/go-librespot/releases
DEVICE_NAME="${DEVICE_NAME:-Pioneer A-70}"
incus exec "$CNAME" -- bash -c "
set -e
curl -fsSL -o /tmp/golibrespot.tar.gz https://github.com/devgianlu/go-librespot/releases/download/v${GLS_VER}/go-librespot_linux_x86_64.tar.gz
tar -xzf /tmp/golibrespot.tar.gz -C /tmp
mv /tmp/go-librespot /usr/local/bin/go-librespot
chmod 755 /usr/local/bin/go-librespot
id spotify >/dev/null 2>&1 || useradd -r -m -d /var/lib/go-librespot -G audio spotify
mkdir -p /var/lib/go-librespot/config
cat > /var/lib/go-librespot/config/config.yml <<EOF
device_name: ${DEVICE_NAME}
device_type: speaker
bitrate: 320
EOF
chown -R spotify:spotify /var/lib/go-librespot
"
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/go-librespot.service <<EOF
[Unit]
Description=Spotify Connect client (go-librespot)
After=network-online.target avahi-daemon.service
Wants=network-online.target avahi-daemon.service
[Service]
User=spotify
Group=spotify
ExecStart=/usr/local/bin/go-librespot --config_dir /var/lib/go-librespot/config
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable --now go-librespot
```
## Troubleshooting
```sh
incus exec jellyfin-client -- systemctl status seatd jellyfin-kiosk go-librespot
incus exec jellyfin-client -- journalctl -u jellyfin-kiosk -b
incus exec jellyfin-client -- su -s /bin/bash kiosk -c vainfo # GPU/VAAPI
incus exec jellyfin-client -- aplay -l # ALSA cards
incus exec jellyfin-client -- su -s /bin/bash kiosk -c "aplay -D default /usr/share/sounds/alsa/Front_Center.wav"
incus exec jellyfin-client -- udevadm info /dev/input/event0 # udev db visible?
```
- **cage "running" but stuck, `Tasks: 0`, black screen** → seatd VT binding
(see "How the tricky parts work" #1). Note: `Tasks: 0` alone is normal —
`PAMName=login` moves the process into a logind session scope.
- **"libinput initialization failed, no input devices"** → udev db not
visible (#2); check `run-udev.mount` is active.
- **Video OK, no sound; JMP log shows `AO: [null]`** → ALSA default broken
(#3); check `/etc/asound.conf` and the card name in `aplay -l`.
- **Keyboard plugged in after boot isn't seen** — the host udev db is live
through the bind, but udev hotplug *events* don't cross the container's
network namespace, so cage only enumerates at startup:
`systemctl restart jellyfin-kiosk` after plugging new input hardware.
- **cage can't open card0** — something on the host holds DRM master; the
host must boot to `multi-user.target` with no display manager.
- **Jellyfin + Spotify at the same time** — the Pioneer PCM is exclusive;
the second opener gets "device busy". Add an ALSA `dmix` config if
simultaneous mixing is ever needed.
- JMP log: `/home/kiosk/.local/share/jellyfinmediaplayer/logs/jellyfinmediaplayer.log`.
+107
View File
@@ -0,0 +1,107 @@
# jellyfin-server
Jellyfin media **server** in an unprivileged Incus container on `nuc`.
- Image: `images:ubuntu/24.04`, Jellyfin from the official repo (repo.jellyfin.org)
- IP: `192.168.0.5` (LAN bridge) — web UI/API on `http://192.168.0.5:8096`
- iGPU render node (`/dev/dri/renderD128`) passed for QSV/VAAPI hardware transcoding
- Media library: host `/srv/media` (ZFS dataset `usb4t/media`, USB 4 TB)
mounted at `/media` with `shift=true` (needs ZFS ≥ 2.2 for idmapped mounts)
- Port 8096 additionally proxied to the host address (`web` proxy device)
## Install script
Run as root on the Incus host: `MEDIA_DIR=/srv/media ./install.sh`
```bash
#!/usr/bin/env bash
# Adapted from:
# https://forgejo.benoit.jp.net/benoitjpnet/Laminar/src/branch/main/cfg/jobs/jellyfin.run
set -euxo pipefail
CNAME="${CNAME:-jellyfin-server}"
IMAGE="${IMAGE:-images:ubuntu/24.04}"
MEDIA_DIR="${MEDIA_DIR:-/srv/media}" # host directory containing your media library
[ -d "$MEDIA_DIR" ] || { echo "MEDIA_DIR=$MEDIA_DIR does not exist on the host"; exit 1; }
incus launch "$IMAGE" "$CNAME"
# Wait until the container has working DNS/network
for i in $(seq 1 30); do
incus exec "$CNAME" -- getent hosts repo.jellyfin.org >/dev/null 2>&1 && break
sleep 2
done
incus config set "$CNAME" environment.DEBIAN_FRONTEND=noninteractive
incus config set "$CNAME" environment.DEBCONF_NONINTERACTIVE_SEEN=true
# --- Jellyfin from the official repository -----------------------------------
incus exec "$CNAME" -- apt-get update
incus exec "$CNAME" -- apt-get upgrade -y
incus exec "$CNAME" -- apt-get install -y --no-install-recommends curl gnupg ca-certificates
incus exec "$CNAME" -- mkdir -p /etc/apt/keyrings
incus exec "$CNAME" -- bash -c 'curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg'
incus exec "$CNAME" -- bash -c 'cat > /etc/apt/sources.list.d/jellyfin.sources <<EOF
Types: deb
URIs: https://repo.jellyfin.org/ubuntu
Suites: noble
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/jellyfin.gpg
EOF'
incus exec "$CNAME" -- apt-get update
incus exec "$CNAME" -- apt-get install -y jellyfin
# Optional: OpenCL runtime, only needed for HDR tone-mapping during transcodes
incus exec "$CNAME" -- apt-get install -y --no-install-recommends intel-opencl-icd || true
# --- iGPU render node for hardware transcoding (QSV/VAAPI) -------------------
RENDER_GID="$(incus exec "$CNAME" -- getent group render | cut -d: -f3)"
incus config device add "$CNAME" igpu gpu gid="$RENDER_GID"
incus exec "$CNAME" -- usermod -aG render,video jellyfin
# --- Media library (read-only is fine unless you let Jellyfin save NFO/artwork
# next to the files) --------------------------------------------------
# NOTE: shift=true requires idmapped-mount support on MEDIA_DIR's filesystem —
# fine on local fs (ext4/btrfs/xfs), NOT supported on CIFS/NFS mounts.
incus config device add "$CNAME" media disk source="$MEDIA_DIR" path=/media shift=true
# --- Expose the web UI/API on the host's LAN address -------------------------
incus config device add "$CNAME" web proxy listen=tcp:0.0.0.0:8096 connect=tcp:127.0.0.1:8096
incus config set "$CNAME" boot.autostart=true
incus restart "$CNAME"
echo "Done. Open http://<host-ip>:8096 to run the setup wizard."
```
## First-run configuration
1. Run the setup wizard; add libraries pointing at `/media/...`.
2. Dashboard → Playback → Transcoding:
- Hardware acceleration: **Intel QuickSync (QSV)** (fallback: VA-API),
device `/dev/dri/renderD128`.
- Enable hardware decoding for the codecs you use; Alder Lake-N does
H.264/HEVC/VP9/AV1 decode and H.264/HEVC encode.
- Enable "Low-Power" encoders only if GuC/HuC is loaded (see below).
## Troubleshooting / notes
- **`Failed to setup device mount "media": idmapping abilities are required
but aren't supported on system`** — the media source is on a filesystem
without idmapped-mount support (CIFS/NFS). Either move the media to a
local fs, or drop the shift (`incus config device set jellyfin-server
media shift=false`; on CIFS files are world-readable synthetic ownership,
enough for a read-only library). Hot-applying `shift` on a running
container fails — stop it first.
- **QSV "low-power" encode fails** — Alder Lake-N needs GuC/HuC firmware
submission. On the **host**:
`echo 'options i915 enable_guc=3' > /etc/modprobe.d/i915.conf`,
`update-initramfs -u`, reboot; verify with `dmesg | grep -i 'guc\|huc'`.
Or just untick the low-power options.
- GPU check: `incus exec jellyfin-server -- ls -l /dev/dri`.
- Pin a version with `apt-get install jellyfin=<ver>+ubu2404` for
reproducibility; keep major versions in sync with the client's JMP.
- To bake a reusable image instead: run the script, then
`incus publish jellyfin-server --alias jellyfin-server-<ver>`.
+261
View File
@@ -0,0 +1,261 @@
# nuc — bare-metal reinstall procedure
How to rebuild the Incus host from scratch if `/dev/sda` (512 GB SSD,
`XINCUU-512GB`) has to be replaced.
## Hardware
- NUC-class mini PC, Intel Alder Lake-N (iGPU `i915`, HDMI to projector/TV)
- `sda`: 512 GB SSD — OS **and** ZFS pool `data` (all Incus instance disks)
- `sdb`: 4 TB WD Red (USB enclosure) — ZFS pool `usb4t`:
`usb4t/backup``/backup` (incus exports for nuc + ks4, 1 TB quota),
`usb4t/media``/srv/media` (media library)
- USB: Pioneer USB audio (`08e4:0176`), Logitech Unifying receiver (K400),
CSCTEK USB Audio and HID
- NIC: `enp1s0` (static `192.168.0.3/24`, gw `192.168.0.1`)
## ⚠️ What dies with sda
The ZFS pool `data` lives on `sda5`**all instance root disks are lost**
with the OS. The USB pool `usb4t` (backups + media) survives — all
instances are replicated onto it with
[`scripts/incus-copy.sh`](../scripts/incus-copy.sh) (deployed at
`/root/scripts/` on nuc; set up 2026-08-09):
```sh
# one-time setup (already done; re-run only on a fresh install)
incus storage create nucbackup zfs source=usb4t/backup/nuc
incus project create backup -c features.images=false -c features.profiles=false
# refresh the replicas (all instances, stopped ones included)
/root/scripts/incus-copy.sh -p backup -s nucbackup
```
Runs nightly via `/etc/cron.d/incus-copy` at **03:30** (30 min after
the profile-scheduled 03:00 instance snapshots, so VM refreshes stay
incremental), logging to `/var/log/incus-copy.log` (logrotate:
`/etc/logrotate.d/incus-copy`). Note the script's `flock` is global:
if a future 02:30 ks4 pull overruns past 03:30, the local run aborts
loudly for that night instead of overlapping.
Replicas sit **stopped** in project `backup` with `boot.autostart=false`
(the script enforces it). Containers refresh in seconds; the
homeassistant **VM re-sends its full volume** unless the source has
snapshots (`snapshots.schedule`) to diff against.
`jellyfin-server` / `jellyfin-client` are also rebuildable from
[jellyfin-server.md](jellyfin-server.md) /
[jellyfin-client.md](jellyfin-client.md); the media library lives on
`usb4t/media` (originals on the NAS `//192.168.0.10/Shared`).
## 1. Install Debian 13 (trixie)
Netinst ISO, manual partitioning of the new sda (GPT/UEFI):
| Part | Size | Type / FS | Mount |
|------|---------|-----------|-------------|
| sda1 | ~1 GB | EFI vfat | `/boot/efi` |
| sda2 | ~2 GB | ext4 | `/boot` |
| sda3 | ~47 GB | ext4 | `/` |
| sda4 | ~1 GB | swap | — |
| sda5 | rest (~427 GB) | **leave unformatted** (ZFS later) | — |
- Tasks: only "SSH server" + standard utilities (no desktop).
- Sources: `main contrib non-free-firmware` (contrib is required for
`zfs-dkms`).
## 2. Base system
```sh
apt update && apt full-upgrade -y
apt install -y \
firmware-intel-graphics firmware-iwlwifi firmware-realtek \
firmware-sof-signed intel-microcode \
linux-headers-amd64 zfs-dkms zfsutils-linux zfs-zed \
cifs-utils curl vim htop ripgrep sysstat dmidecode pciutils usbutils
```
Static network — `/etc/network/interfaces` (ifupdown):
```
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
allow-hotplug enp1s0
iface enp1s0 inet static
address 192.168.0.3
netmask 255.255.255.0
gateway 192.168.0.1
dns-nameservers 192.168.0.1 1.1.1.1
```
Restore `/root/.ssh/authorized_keys` (3 keys; one is `id_rsa_claude.pub`
from this repo).
## 3. ZFS pools
```sh
# instance pool on the new SSD
ls -l /dev/disk/by-id/ | grep sda5
zpool create data /dev/disk/by-id/<new-ssd-id>-part5
# USB pool (backups + media) survived — just import it
zpool import usb4t
```
(If the old sda still works and only the OS was reinstalled elsewhere:
`zpool import data` instead — the instances survive.)
`usb4t` reference (as created 2026-08: whole disk, `ashift=12`,
`compression=zstd`, `atime=off`, `xattr=sa`, `acltype=posixacl`;
datasets `usb4t/backup``/backup` with `quota=1T`, subdatasets
`nuc`/`ks4`, and `usb4t/media``/srv/media`).
## 4. Incus (Zabbly stable repo)
```sh
mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc
cat > /etc/apt/sources.list.d/zabbly-incus-stable.sources <<EOF
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: trixie
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/zabbly.asc
EOF
apt update && apt install -y incus
```
Initialize with the existing pool and the macvlan network (this is what
gives every instance a real LAN IP):
```sh
cat <<EOF | incus admin init --preseed
storage_pools:
- name: data
driver: zfs
config:
source: data # use the existing/just-created zpool
networks:
- name: macvlan
type: macvlan
config:
parent: enp1s0
profiles:
- name: default
devices:
eth0:
name: eth0
network: macvlan
type: nic
root:
path: /
pool: data
type: disk
EOF
```
Known macvlan quirk: the **host cannot talk to its own instances** (and
vice versa) over macvlan — management is via `incus exec`, and other LAN
hosts reach them normally.
Let `julien` run harmless incus commands (list/info/config/show…)
without a password — mutating ones (`exec`, `start/stop`, `delete`)
still prompt:
```sh
cat > /etc/sudoers.d/incus <<'EOF'
## Allow harmless incus commands to be called through sudo
## without a password.
##
## CAUTION: Any syntax error introduced here will break sudo.
## Always edit/validate with: visudo -cf /etc/sudoers.d/incus
##
## Note: "incus config *" also covers config set/edit and device add,
## which can escalate (e.g. security.privileged, host disk mounts).
## Trim to "config show/get" if strict read-only is wanted.
## Cmnd alias specification
Cmnd_Alias C_INCUS = \
/usr/bin/incus config, /usr/bin/incus config *, \
/usr/bin/incus list, /usr/bin/incus list *, \
/usr/bin/incus info, /usr/bin/incus info *, \
/usr/bin/incus version, \
/usr/bin/incus top, \
/usr/bin/incus monitor, /usr/bin/incus monitor *, \
/usr/bin/incus snapshot list, /usr/bin/incus snapshot list *, \
/usr/bin/incus image list, /usr/bin/incus image list *, \
/usr/bin/incus image info *, \
/usr/bin/incus profile list, /usr/bin/incus profile show *, \
/usr/bin/incus project list, /usr/bin/incus project info *, \
/usr/bin/incus network list, /usr/bin/incus network list *, \
/usr/bin/incus network show *, /usr/bin/incus network info *, \
/usr/bin/incus storage list, /usr/bin/incus storage show *, \
/usr/bin/incus storage info *, \
/usr/bin/incus storage volume list, /usr/bin/incus storage volume list *, \
/usr/bin/incus operation list, /usr/bin/incus operation show *, \
/usr/bin/incus remote list, \
/usr/bin/incus warning list, /usr/bin/incus warning show *
## allow julien to use harmless incus commands without a password
julien ALL = (root) NOPASSWD: C_INCUS
EOF
chmod 0440 /etc/sudoers.d/incus
visudo -cf /etc/sudoers.d/incus # must print "parsed OK"
```
## 5. Restore instances
The replicas live on `usb4t/backup/nuc` and survived. Re-register them
with the fresh Incus, then copy back onto the rebuilt `data` pool:
```sh
# recreate the containers' home first (section 4), then:
incus project create backup -c features.images=false -c features.profiles=false
incus admin recover # point it at pool nucbackup (zfs, source=usb4t/backup/nuc)
# copy each replica back to the default project / SSD pool:
incus copy blocky blocky --project backup --target-project default -s data
incus config set blocky boot.autostart=true
incus start blocky
# … same for privoxy, homeassistant, jellyfin-*
```
(jellyfin-* can alternatively be rebuilt from
[jellyfin-server.md](jellyfin-server.md) /
[jellyfin-client.md](jellyfin-client.md); `/srv/media` is already there
once `usb4t` is imported.)
## 6. Nightly automation
| When | What | Where |
|-------|------|-------|
| 03:00 | instance snapshots (`snapshots.schedule` on the default profile, expiry 7d) | incus |
| 03:30 | replicate all instances to the USB pool (`incus-copy.sh -p backup -s nucbackup`) | `/etc/cron.d/incus-copy``/var/log/incus-copy.log` |
| 05:00 | apt dist-upgrade all running containers (`incus-container-upgrade.sh`; VMs and non-apt containers skipped; jellyfin pinned to the 10.11 series in-container) | `/etc/cron.d/incus-container-upgrade``/var/log/incus-container-upgrade.log` |
The ordering is deliberate: snapshot → backup → upgrade, so a broken
upgrade is always one snapshot-restore away and the replicas predate it.
Both logs rotate monthly (`/etc/logrotate.d/incus-*`).
## 7. Post-install checklist
- [ ] `zpool status` healthy (both `data` and `usb4t`), `incus list`
shows expected instances
- [ ] incus ordered after ZFS mounts (instances bind-mount `/srv/media`):
`/etc/systemd/system/incus.service.d/after-zfs.conf` with
`[Unit]` / `After=zfs-mount.service zfs.target`
- [ ] `boot.autostart=true` on blocky, privoxy, jellyfin-*
- [ ] Host boots to `multi-user.target`, nothing grabs the GPU
(required by the jellyfin-client kiosk)
- [ ] Jellyfin web at `http://192.168.0.5:8096`, kiosk UI on HDMI,
sound on the Pioneer, "Pioneer A-70" visible in Spotify Connect
- [ ] LAN DNS: clients use blocky at `192.168.0.254` (host itself uses
`192.168.0.1` + `1.1.1.1` to avoid a bootstrap loop)
- [ ] Optional (only if QSV low-power encoders are wanted):
`echo 'options i915 enable_guc=3' > /etc/modprobe.d/i915.conf
&& update-initramfs -u`
+146
View File
@@ -0,0 +1,146 @@
# transmission-bt
BitTorrent client in an unprivileged Incus container on `nuc`, 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` (= `usb4t/media`, same dataset Jellyfin
reads); in-progress files in `/media/.incomplete` so Jellyfin never
scans partials
- `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
# 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=/srv/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
```
## 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
# nuc host itself cannot reach 192.168.0.7
```
## 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.