halkyon/docs/adr/0032-jellyfin-at-home.md
Tizian 「ティツィアーン」 82fa7aec1b
All checks were successful
CI / check (push) Successful in 41m12s
CI / deploy (push) Successful in 56s
fix(deploy): the home end dials the instance's server by its address (ADR 0032, D11)
A tunnel that fails to come up takes Jellyfin's internet with it, as
linuxserver/wireguard's client mode deletes the default route. Named by
learn.halkyon.eu, the endpoint needed DNS to come up, which the home end
starting or waking hasn't always got yet; by its address the tunnel comes
up whatever the network does. Closes ADR 0032's open point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 15:16:05 +01:00

208 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR 0032: Jellyfin at home, published by the instance through WireGuard
- **Status:** decided 30 Oct 2026, by the developer (D1, D9 and D10 their own choices,
D2–D8 agreed as recommended). Built the same day (*As built*, below); not brought up
yet, which takes the developer's steps as root on the instance's server.
- **Builds on:** the Jellyfin sidecar (`deploy/compose.jellyfin.yaml`,
`deploy/sidecars/README.md`, decided 14 Oct 2026) and Halkyon Learn's suggested
server (`JellyfinSuggestion`, decided 13 Oct). Train's Jellyfin (ADR 0030, D47, D52,
D54) reaches it the same way.
## Context
The Jellyfin sidecar runs Jellyfin on the instance's server, with its media copied
there (`deploy/jellyfin/media/`). The developer's media is at home, on a machine in the
home network (192.168.178.0/24, behind a FRITZ!Box), and should be reachable as
`jellyfin.halkyon.eu`, where learn.halkyon.eu's people would find it, under three
conditions the developer set on 30 Oct:
- the home network's address is never exposed: neither DNS nor anything Jellyfin
answers names it;
- nobody signs up: accounts are the admin's to make (Jellyfin has no self-registration,
but a fresh server's setup wizard is open to whoever reaches it first, and a user's
password can be set from outside, which `deploy/sidecars/jellyfin.caddy` refuses);
- Halkyon's apps reach Jellyfin over HTTPS alone (Learn's `JellyfinRepository.find`,
Train's D54), so the name needs a certificate every device trusts.
Found on 30 Oct: halkyon.eu's DNS is at Infomaniak; `jellyfin.halkyon.eu` already points
at learn.halkyon.eu's server (`A` 89.58.53.154 and an `AAAA`), but nothing answers there,
as the sidecar isn't switched on. With `JELLYFIN_URL` unset, the instance suggests
`https://jellyfin.halkyon.eu` to its people as soon as a Jellyfin server answers there.
## Decisions
- **D1 A WireGuard tunnel from home to the instance's server, which publishes Jellyfin**
(the developer's choice, 30 Oct, as recommended). Alternatives weighed:
- *Cloudflare Tunnel*: hides the address too, but needs all of halkyon.eu's DNS moved
to Cloudflare (a tunnel needs a zone on Cloudflare's nameservers on the free plan),
lets Cloudflare read every password, token and video (it ends TLS), conflicts with
Cloudflare's terms for serving mostly video outside its paid video products, and
leaves `jellyfin.caddy`'s rules to be rebuilt as Cloudflare's.
- *An SSH reverse tunnel* (`ssh -R` as `deploy`, kept up by autossh): no new software
on the server, but TCP over TCP stalls a stream on a lossy line, and binding where a
container reaches it needs sshd's `GatewayPorts` changed.
- *The sidecar as built*, the media copied to the server: no tunnel at all, but the
server's disk holds the library.
The public sees only the instance's server, TLS ends on a machine the operator holds,
`jellyfin.caddy` and the sidecar README's settings hold unchanged, and the DNS record
is already right. Streams are bounded by the home line's upload speed and count
towards the server's traffic.
- **D2 The home end runs in Compose, Jellyfin inside the tunnel's network namespace**
(recommended; over WireGuard on the home host with Jellyfin's port bound to the
tunnel's address, which Docker can't bind before the interface is up, and over
firewall rules on the home host). A `wireguard` container (`cap_add: NET_ADMIN`, the
host's kernel module) holds the tunnel; Jellyfin joins it with
`network_mode: service:wireguard`, so it listens on the tunnel and on that namespace's
loopback alone, and is not on the home network at all (no DLNA, no discovery on UDP
7359). The admin port is published on the home host's loopback only
(`127.0.0.1:8096`), where Learn on the same machine may reach it over plain HTTP
(`isLoopback`). The home end dials out with `PersistentKeepalive = 25`, so the FRITZ!Box
needs no open port, and the server's config holds no endpoint for it. The files live
beside the deploy as `deploy/home-jellyfin/` (compose file, WireGuard config without
its key; the steps in the sidecar README); the key stays out of the repository.
- **D3 The tunnel's addresses are 10.61.96.1 (the server) and 10.61.96.2 (home), a /30,
IPv4 alone** (recommended): outside Docker's address pools (172.17.0.0/16 to
172.31.0.0/16 and 192.168.0.0/16 in /20s, which a new network could take), the edge
network (172.30.80.0/24), the home network and Multipass' bridges on the laptop
(10.97.x.0/24). Caddy reaches Jellyfin over IPv4; visitors keep coming over either.
- **D4 WireGuard on the server's host, as `wg-quick@wg-jellyfin`, on UDP 51820**
(recommended, over a WireGuard container on `edge`, which would need `NET_ADMIN` beside
the instance and routes into it from Caddy). The port is opened in the host's
firewall; WireGuard answers nothing that isn't signed by a known key, so it shows no
service to a scan. The home peer's `AllowedIPs` on the server is `10.61.96.2/32`, so
nothing it sends under another source address gets in. The server's key is kept in
`/etc/wireguard/` (root, 0600) and in the operator's password manager, as a new
server needs it again.
- **D5 The tunnel reaches Jellyfin from Caddy alone, and reaches nothing on the server**
(recommended), by rules that `wg-quick`'s `PostUp` sets and `PreDown` removes, so they
live exactly as long as the interface:
- in `DOCKER-USER`: `172.30.80.2` (Caddy) to `10.61.96.2` on TCP 8096 accepted; every
other new connection into or out of `wg-jellyfin` dropped, replies
(`ESTABLISHED,RELATED`) passing;
- in `INPUT`: nothing new from `wg-jellyfin` accepted, so home can't reach sshd,
published ports or anything else listening on the host.
The rules match Caddy's own address, as `DOCKER-USER` sees packets before Docker
masquerades them; home then sees them from `10.61.96.1` (Docker's `MASQUERADE` for
`edge`, which takes every packet leaving the network; found while building, the
proposal had them arrive as `172.30.80.2`), so home's `AllowedIPs` is
`10.61.96.1/32` alone. A compromised home machine thus gets replies to Caddy's
requests and nothing else of the production server.
- **D6 The CI runner can't reach the tunnel either** (recommended): `10.61.96.0/30` is
added to `IPAddressDeny` in `deploy/runner/forgejo-runner.service`, beside Docker's
ranges. Without it, a job could talk to the home Jellyfin through the host's route.
- **D7 One site, with its upstream a setting** (recommended, over a second site file
repeating the rules): `jellyfin.caddy` proxies to `{$JELLYFIN_UPSTREAM:jellyfin:8096}`.
A new `deploy/compose.jellyfin-home.yaml` mounts the same site into Caddy, sets
`JELLYFIN_DOMAIN` and `JELLYFIN_UPSTREAM=10.61.96.2:8096`, leaves the server's
`JELLYFIN_URL` unset (D9) and runs no Jellyfin container; `COMPOSE_FILE` takes either it or
`compose.jellyfin.yaml`, never both. `redeploy.sh` needs no change: WireGuard is the
host's, set up once by root, as the sidecars' users are.
- **D8 Jellyfin's own settings for a server seen through a proxy** (recommended; the
sidecar README's step 6, with two additions): `JELLYFIN_PublishedServerUrl =
https://jellyfin.halkyon.eu`, so Jellyfin never hands out a home address; **LAN
networks** set to `172.29.96.1/32` alone (the gateway of the home end's Compose
network, its subnet fixed for it, through which Docker hands on what reaches the
machine's loopback; the proposal said `127.0.0.1/32`, which no request comes from),
as Jellyfin counts every private range as local when it's left empty, which would
count the tunnel too and let an admin without remote access sign in through it;
**Known proxies** `10.61.96.1`, as which Caddy's requests arrive (D5). The admin works
from the home machine's `http://localhost:8096`, not through an SSH tunnel; the
wizard is finished there before the tunnel is first brought up.
- **D9 learn.halkyon.eu suggests it to all its people** (the developer's, 30 Oct, over
`JELLYFIN_URL=none`), while it answers: the compose file leaves `JELLYFIN_URL` unset,
so the instance looks at `https://jellyfin.` on its own domain (`JellyfinSuggestion`)
and suggests it only once it answers as a Jellyfin server. Set, the instance would
suggest it whether it answers or not, which D10 makes the wrong default. Its login
screen shows only the accounts meant for everyone; the admin and every personal
account are hidden from it (D8, the sidecar README's step 6).
- **D10 The home end is the developer's own machine, this laptop** (the developer's, 30
Oct, over an always-on machine at home): Jellyfin is reachable while it runs and away
otherwise, which is accepted. It dials out, so it serves from wherever the laptop is
online, not only at home, as long as outgoing UDP passes. Asleep, the tunnel stops; on
waking, the next keepalive brings it back within 25 s.
- **D11 The home end dials the server by its address** (the developer's, 31 Oct, found
setting the home end up): `Endpoint = 89.58.53.154:51820` in `wg0.conf`, not
`learn.halkyon.eu`. Whenever `wg0.conf` fails to come up, linuxserver/wireguard's client
mode deletes the namespace's default route (`ip route del default` in its
`svc-wireguard/run`, ls126: a kill switch, for the VPN clients it is made for), so
Jellyfin, in that namespace, reaches neither TMDb nor anything else, and a library
scanned then gets frames of its videos for posters, until both containers are
recreated. Bringing an interface up needs no network, but resolving a name does
(`wg` retries it for some seconds, then fails), so on the home end (D10), starting or
waking before its network and DNS, the name made that failure recur. By address it
comes up whatever the network does, the handshake following once it is back; what
is left is a placeholder or a wrong key, met once while setting it up, which the
sidecar README's step 4 says how to recover from. IPv4, which every home network has.
A new address of the server's has to be written in by hand. Over a custom service
in the image's `/custom-services.d` putting the route back (working against the
image's kill switch and resting on its scripts as ls126 has them, which an update
may change unnoticed), and over turning the namespace round, Jellyfin owning it and
a WireGuard container without a kill switch joining it (a new D2 for a failure the
address all but removes).
## Consequences
- People reaching `jellyfin.halkyon.eu` see the instance's server alone. The instance's
server knows the home address (the tunnel's endpoint, in `wg show`), as its operator
does anyway; nothing it serves names it.
- Every byte streamed crosses the home line's upload and the server twice (in through
the tunnel, out to the visitor). Learn streams as stored (`static=true`), so a video's
bitrate must fit the upload; Train's smaller copies (D52) are transcoded at home.
- The production server gains a listening UDP port, a kernel interface and firewall
rules outside Compose; they belong in the sidecar README's steps, with how to switch
them off (`systemctl disable --now wg-quick@wg-jellyfin`, the port closed).
- With the laptop off, asleep or offline, Caddy answers 502 at `jellyfin.halkyon.eu`;
Learn and Train show the server as unreachable, as for any Jellyfin that is away. The
instance stops suggesting it at its first look after that: a look counts for an hour,
and the first `instance.info()` after the hour starts the next one in the background,
that answer still giving the old suggestion. Apps that took the suggestion already
keep it (the app asks once), and devices signed in to it keep their token.
- The instance's own look reaches `jellyfin.halkyon.eu` at its public address from
inside its container, through the host back to Caddy. With the sidecar this was never
used in production, as its compose file sets `JELLYFIN_URL`; the check after setup
confirms that `instance.info()` names it.
## As built (30 Oct 2026, the agent's calls, each with its reason)
- **Files**: `deploy/compose.jellyfin-home.yaml` and `jellyfin.caddy`'s upstream
(`{$JELLYFIN_UPSTREAM:jellyfin:8096}`, D7); `deploy/home-jellyfin/` with the home end's
`compose.yaml` and `wg0.conf.example`, and under `server/` the instance's
`wg-jellyfin.conf`, its rules (`wg-jellyfin-rules.sh`, POSIX sh as the host's
scripts are: the host has no Dart) and a systemd drop-in; the steps in
`deploy/sidecars/README.md`, *Jellyfin at home*. `test/deploy/jellyfin_home_test.dart`
holds each rule above to the files, refusals first: no `JELLYFIN_URL`, the home peer's
one address, the drops, the runner's deny, Jellyfin inside the tunnel's namespace,
its port on the loopback alone.
- **The rules in chains of their own** (`halkyon-jellyfin-forward`, `-input`), jumped to
from `DOCKER-USER` and `INPUT` for the tunnel's interface alone, so their order
against Docker's rules is fixed and `down` removes all of them, a half-made set too.
`RETURN` rather than `ACCEPT` for what passes, so Docker's own rules still apply.
- **The tunnel starts after Docker and restarts with it** (`After`, `Requires`,
`PartOf=docker.service`): its rules go into Docker's chain, which exists only once
Docker runs.
- **The server's private key is a file beside the config** (`PostUp = wg set %i
private-key …`), so `wg-jellyfin.conf` is copied from the repository as it is, but
for the home end's public key.
- **The server looks at Jellyfin through Caddy on `edge`** (`extra_hosts`, the domain at
172.30.80.2), not at the public address from inside its container, which needs
Docker's hairpin NAT and, by the `AAAA` record, IPv6 to work for it. The certificate
is the domain's, so the look is as anyone's.
- **CI's Sandbox step also fails if a job reaches 10.61.96.2:8096**, as it does for
Docker's networks: the deny in the runner's unit is only on the server once installed
again (step 3).
- **The home end's image** is `linuxserver/wireguard` 1.0.20260223-r0-ls126, of 15 Oct
2026, pinned by its digest: two weeks old, rather than the build of the day before;
its client mode brings up `wg0.conf` with the host's kernel module, and its `wg`
makes the home end's keys, so nothing is installed on the machine. Jellyfin's image
is the sidecar's, and so is its user ID, 61096.