feat(deploy): Jellyfin at home, published through WireGuard (ADR 0032)
The instance serves jellyfin.halkyon.eu and its Caddy proxies through a WireGuard tunnel to a Jellyfin on the operator's own machine, which dials out, so nothing names the home network's address and its router opens no port. compose.jellyfin-home.yaml takes the sidecar's place, with its site's rules unchanged (jellyfin.caddy's upstream is now a setting), and leaves JELLYFIN_URL unset, so the instance suggests it only while it answers. On the instance's server the tunnel reaches Jellyfin from Caddy alone and nothing of the host (wg-jellyfin-rules.sh, set and removed with the tunnel); the CI runner's unit denies it, and CI's Sandbox step fails if a job reaches it. The home end keeps Jellyfin in WireGuard's network namespace, its port on the loopback alone. The steps are in deploy/sidecars/README.md, the rules held by test/deploy/jellyfin_home_test.dart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
991af7af48
commit
d8c827eab6
9 changed files with 675 additions and 3 deletions
27
deploy/compose.jellyfin-home.yaml
Normal file
27
deploy/compose.jellyfin-home.yaml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Jellyfin at home, published by the instance (ADR 0032): the Jellyfin sidecar's site,
|
||||
# with Caddy proxying through a WireGuard tunnel to a Jellyfin on the operator's own
|
||||
# machine (home-jellyfin/), rather than to one beside the instance. Nothing of the home
|
||||
# network shows: people reach the instance's server alone. Switched on in .env, in place
|
||||
# of compose.jellyfin.yaml, never beside it:
|
||||
#
|
||||
# COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml
|
||||
# JELLYFIN_DOMAIN=jellyfin.halkyon.eu
|
||||
#
|
||||
# The tunnel is the host's (wg-quick@wg-jellyfin, with the rules letting Caddy alone
|
||||
# through it), set up once as root: sidecars/README.md, *Jellyfin at home*.
|
||||
|
||||
services:
|
||||
caddy:
|
||||
environment:
|
||||
JELLYFIN_DOMAIN: ${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}
|
||||
# The home end's address in the tunnel (D3).
|
||||
JELLYFIN_UPSTREAM: 10.61.96.2:8096
|
||||
volumes:
|
||||
- ./sidecars/jellyfin.caddy:/etc/caddy/sidecars/jellyfin.caddy:ro
|
||||
|
||||
server:
|
||||
# No JELLYFIN_URL (D9): the instance looks at jellyfin. on its own domain and
|
||||
# suggests it only while it answers, as the home end is away whenever its machine
|
||||
# is. It looks through Caddy on edge, not by the public address back in.
|
||||
extra_hosts:
|
||||
- ${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}:172.30.80.2
|
||||
59
deploy/home-jellyfin/compose.yaml
Normal file
59
deploy/home-jellyfin/compose.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Jellyfin at home (ADR 0032): the home end, on the operator's own machine, which
|
||||
# learn.halkyon.eu's server publishes as jellyfin.halkyon.eu (compose.jellyfin-home.yaml).
|
||||
# WireGuard dials out to the instance's server, so the router opens nothing; Jellyfin
|
||||
# lives in WireGuard's network namespace, so it listens on the tunnel and on this
|
||||
# machine's loopback alone, and is not on the home network (no DLNA, no discovery).
|
||||
# Run here, as the operator: `docker compose up -d`. Setting it up:
|
||||
# ../sidecars/README.md, *Jellyfin at home*. Its folders are git-ignored: the tunnel's
|
||||
# key (wireguard/), Jellyfin's configuration and cache (jellyfin/), and .env.
|
||||
|
||||
name: halkyon-home-jellyfin
|
||||
|
||||
services:
|
||||
wireguard:
|
||||
# linuxserver/wireguard 1.0.20260223-r0-ls126 (15 Oct 2026), its client mode: it
|
||||
# brings up wireguard/wg_confs/wg0.conf with the host's kernel module.
|
||||
image: lscr.io/linuxserver/wireguard@sha256:6bcf5d2f5a265c7e9835eaadd989938e681aabe8bf8bcd4556b61d463da6555c
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
environment:
|
||||
PUID: "61096"
|
||||
PGID: "61096"
|
||||
volumes:
|
||||
- ./wireguard:/config
|
||||
# Jellyfin's port, which lives here: on this machine's loopback alone, for its
|
||||
# admin and for Halkyon Learn on this machine over plain HTTP (D2).
|
||||
ports:
|
||||
- 127.0.0.1:8096:8096
|
||||
networks:
|
||||
- outside
|
||||
|
||||
jellyfin:
|
||||
# As the sidecar's (../compose.jellyfin.yaml): v12.2, the version Halkyon Learn's
|
||||
# Jellyfin feature is checked with.
|
||||
image: jellyfin/jellyfin@sha256:357724bf0ae27a672c7cbaa899db2d9abeb13dbd8657ccce750258a4c059d037
|
||||
restart: unless-stopped
|
||||
# As on the instance's server: a user ID no person and no other container uses.
|
||||
user: "61096:61096"
|
||||
network_mode: service:wireguard
|
||||
depends_on:
|
||||
- wireguard
|
||||
environment:
|
||||
# Never a home address in what Jellyfin hands out (D8).
|
||||
JELLYFIN_PublishedServerUrl: https://jellyfin.halkyon.eu
|
||||
volumes:
|
||||
- ./jellyfin/config:/config
|
||||
- ./jellyfin/cache:/cache
|
||||
- ${JELLYFIN_MEDIA:?set JELLYFIN_MEDIA in deploy/home-jellyfin/.env}:/media:ro
|
||||
|
||||
networks:
|
||||
# Out to the internet, for the tunnel and Jellyfin's metadata. A fixed subnet, so
|
||||
# Jellyfin's LAN networks can name its gateway alone, through which this machine's
|
||||
# loopback reaches it (D8).
|
||||
outside:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.29.96.0/24
|
||||
52
deploy/home-jellyfin/server/wg-jellyfin-rules.sh
Executable file
52
deploy/home-jellyfin/server/wg-jellyfin-rules.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/bin/sh
|
||||
# The rules around the tunnel to Jellyfin at home (ADR 0032, D5), set by wg-quick as
|
||||
# the tunnel comes up and taken away as it goes (wg-jellyfin.conf), so they live
|
||||
# exactly as long as it: `wg-jellyfin-rules.sh up|down <interface>`. As
|
||||
# /etc/wireguard/wg-jellyfin-rules.sh on the instance's server (root, 0700).
|
||||
#
|
||||
# - Forwarded (Docker's DOCKER-USER, before Docker's own rules): into the tunnel only
|
||||
# Caddy (172.30.80.2, its fixed address on edge) to Jellyfin (10.61.96.2:8096), out
|
||||
# of it only the replies; everything else dropped. Docker then masquerades Caddy's
|
||||
# requests as the tunnel's 10.61.96.1, which the home end sees as its proxy.
|
||||
# - To the host itself (INPUT): only replies from the tunnel, so the home end reaches
|
||||
# neither sshd nor anything else listening here.
|
||||
#
|
||||
# IPv4 alone: the tunnel carries no IPv6 (D3). Needs Docker's chains, so the tunnel
|
||||
# starts after Docker (wg-quick-after-docker.conf).
|
||||
|
||||
set -eu
|
||||
|
||||
action=$1
|
||||
interface=$2
|
||||
forward=halkyon-jellyfin-forward
|
||||
input=halkyon-jellyfin-input
|
||||
|
||||
case "$action" in
|
||||
up)
|
||||
iptables -N "$forward"
|
||||
iptables -A "$forward" -i "$interface" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
|
||||
iptables -A "$forward" -o "$interface" -s 172.30.80.2 -d 10.61.96.2 -p tcp --dport 8096 -j RETURN
|
||||
iptables -A "$forward" -j DROP
|
||||
iptables -I DOCKER-USER -i "$interface" -j "$forward"
|
||||
iptables -I DOCKER-USER -o "$interface" -j "$forward"
|
||||
|
||||
iptables -N "$input"
|
||||
iptables -A "$input" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A "$input" -j DROP
|
||||
iptables -I INPUT -i "$interface" -j "$input"
|
||||
;;
|
||||
down)
|
||||
# Whatever is there of it, so a half-made set goes too.
|
||||
iptables -D DOCKER-USER -i "$interface" -j "$forward" 2>/dev/null || true
|
||||
iptables -D DOCKER-USER -o "$interface" -j "$forward" 2>/dev/null || true
|
||||
iptables -F "$forward" 2>/dev/null || true
|
||||
iptables -X "$forward" 2>/dev/null || true
|
||||
iptables -D INPUT -i "$interface" -j "$input" 2>/dev/null || true
|
||||
iptables -F "$input" 2>/dev/null || true
|
||||
iptables -X "$input" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 up|down <interface>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
17
deploy/home-jellyfin/server/wg-jellyfin.conf
Normal file
17
deploy/home-jellyfin/server/wg-jellyfin.conf
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# The instance's end of the tunnel to Jellyfin at home (ADR 0032, D4), as
|
||||
# /etc/wireguard/wg-jellyfin.conf on the instance's server (root, 0600), brought up by
|
||||
# wg-quick@wg-jellyfin. Its private key is a file of its own beside it, never here.
|
||||
# Setting it up: sidecars/README.md, *Jellyfin at home*.
|
||||
|
||||
[Interface]
|
||||
Address = 10.61.96.1/30
|
||||
ListenPort = 51820
|
||||
PostUp = wg set %i private-key /etc/wireguard/wg-jellyfin.key
|
||||
# Caddy alone through the tunnel, nothing of the host from it (D5).
|
||||
PostUp = /etc/wireguard/wg-jellyfin-rules.sh up %i
|
||||
PreDown = /etc/wireguard/wg-jellyfin-rules.sh down %i
|
||||
|
||||
# The home end, which dials in: no endpoint, so this server never names its address.
|
||||
[Peer]
|
||||
PublicKey = <the home end's public key>
|
||||
AllowedIPs = 10.61.96.2/32
|
||||
7
deploy/home-jellyfin/server/wg-quick-after-docker.conf
Normal file
7
deploy/home-jellyfin/server/wg-quick-after-docker.conf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# The tunnel to Jellyfin at home starts after Docker, whose DOCKER-USER chain its rules
|
||||
# go into, and restarts with it (ADR 0032, D5). As
|
||||
# /etc/systemd/system/wg-quick@wg-jellyfin.service.d/after-docker.conf.
|
||||
[Unit]
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
PartOf=docker.service
|
||||
17
deploy/home-jellyfin/wg0.conf.example
Normal file
17
deploy/home-jellyfin/wg0.conf.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# The home end of the tunnel to the instance's server (ADR 0032), copied to
|
||||
# wireguard/wg_confs/wg0.conf (git-ignored) with its two keys filled in. Setting it up:
|
||||
# ../sidecars/README.md, *Jellyfin at home*.
|
||||
|
||||
[Interface]
|
||||
Address = 10.61.96.2/32
|
||||
PrivateKey = <the home end's private key>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <the instance's public key>
|
||||
Endpoint = learn.halkyon.eu:51820
|
||||
# The instance's end alone, through which Caddy's requests come (masqueraded by Docker
|
||||
# on the instance's server): nothing else of the internet goes through the tunnel.
|
||||
AllowedIPs = 10.61.96.1/32
|
||||
# Dialling out keeps the way open through the router and brings the tunnel back within
|
||||
# 25 s after the machine wakes (D10).
|
||||
PersistentKeepalive = 25
|
||||
|
|
@ -36,14 +36,14 @@ TimeoutStopSec=11min
|
|||
# Away from production: /home (deploy's .env, the checkout, the database dumps) is
|
||||
# invisible, the rest of the system read-only except the runner's own directory, other
|
||||
# users' processes hidden, and Docker's container networks, where the database listens,
|
||||
# unreachable.
|
||||
# unreachable, and so is the tunnel to Jellyfin at home (10.61.96.0/30, ADR 0032, D6).
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/forgejo-runner
|
||||
PrivateTmp=yes
|
||||
ProtectProc=invisible
|
||||
InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker
|
||||
IPAddressDeny=172.16.0.0/12 192.168.0.0/16 fd00::/8
|
||||
IPAddressDeny=172.16.0.0/12 192.168.0.0/16 fd00::/8 10.61.96.0/30
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ site changes.
|
|||
| Sidecar | Compose file | Site |
|
||||
|---|---|---|
|
||||
| Jellyfin, which people watch and listen to learn from | `compose.jellyfin.yaml` | `jellyfin.caddy` |
|
||||
| Jellyfin at home, published through a WireGuard tunnel, in its place | `compose.jellyfin-home.yaml` | `jellyfin.caddy` |
|
||||
| LanguageTool, which checks spelling and grammar | `compose.languagetool.yaml` | none |
|
||||
| Halkyon Train's server, with BRouter for its routes | `compose.train.yaml` | `train.caddy` |
|
||||
| Train's own map tiles, beside Train | `compose.maps.yaml` | `train-maps.caddy` (in Train's site) |
|
||||
|
|
@ -106,6 +107,142 @@ Steps, in order; ✱ marks a step as root.
|
|||
Switching it off: the compose file out of `COMPOSE_FILE`, then `docker compose up -d
|
||||
--remove-orphans`; `deploy/jellyfin/` stays until removed by hand.
|
||||
|
||||
## Jellyfin at home
|
||||
|
||||
The Jellyfin sidecar's site, served by the instance, with Jellyfin on the operator's own
|
||||
machine rather than beside the instance (ADR 0032): `compose.jellyfin-home.yaml` on the
|
||||
instance's server, `home-jellyfin/` on that machine, a WireGuard tunnel between them
|
||||
that the machine dials out on. People reach the instance's server alone; nothing names
|
||||
the home network's address, and its router opens no port. Caddy's rules are the
|
||||
sidecar's (`jellyfin.caddy`), and the tunnel reaches Jellyfin from Caddy alone and
|
||||
nothing of the instance's server (`home-jellyfin/server/wg-jellyfin-rules.sh`; nor can
|
||||
the CI runner reach it, `IPAddressDeny`). The instance suggests it only while it
|
||||
answers: `JELLYFIN_URL` stays unset, so the instance looks at `jellyfin.` on its own
|
||||
domain, which `JELLYFIN_DOMAIN` must therefore be. With the machine off, asleep or
|
||||
offline, Caddy answers 502, and the instance stops suggesting it at its first look
|
||||
after that, within about an hour. It takes the place of the sidecar, never runs
|
||||
beside it.
|
||||
|
||||
The tunnel's addresses: 10.61.96.1 the instance's end, 10.61.96.2 the home end, UDP
|
||||
51820 on the instance's server. Steps, in order; ✱ marks a step as root, 🏠 one on the
|
||||
home machine.
|
||||
|
||||
1. 🏠 **The home end's key** (the operator, in `deploy/home-jellyfin/` of their
|
||||
checkout): WireGuard's own tool from the image, so nothing is installed; the
|
||||
private key goes into the git-ignored config, which only its owner reads, the
|
||||
public key is printed for step 3.
|
||||
|
||||
```sh
|
||||
cd deploy/home-jellyfin
|
||||
image=$(sed -n 's/^ *image: \(lscr.io\/linuxserver\/wireguard@.*\)/\1/p' compose.yaml)
|
||||
(umask 077 && mkdir -p wireguard/wg_confs &&
|
||||
docker run --rm --entrypoint wg "$image" genkey > wireguard/home.key)
|
||||
docker run --rm -i --entrypoint wg "$image" pubkey < wireguard/home.key
|
||||
```
|
||||
|
||||
2. ✱ **The instance's end** (the operator, on the instance's server, as a user with
|
||||
sudo): WireGuard's tools, the key, and the files from the checkout. The key is in
|
||||
the password manager too: a new server needs it again, or the home end a new
|
||||
public key.
|
||||
|
||||
```sh
|
||||
sudo apt-get install wireguard-tools
|
||||
(umask 077 && wg genkey | sudo tee /etc/wireguard/wg-jellyfin.key | wg pubkey)
|
||||
from=~deploy/halkyon-learn/deploy/home-jellyfin/server
|
||||
sudo install -m 0600 "$from/wg-jellyfin.conf" /etc/wireguard/wg-jellyfin.conf
|
||||
sudo install -m 0700 "$from/wg-jellyfin-rules.sh" /etc/wireguard/wg-jellyfin-rules.sh
|
||||
sudo install -D -m 0644 "$from/wg-quick-after-docker.conf" \
|
||||
/etc/systemd/system/wg-quick@wg-jellyfin.service.d/after-docker.conf
|
||||
```
|
||||
|
||||
The printed public key is the instance's, for step 4.
|
||||
|
||||
3. ✱ **Introduce the home end, open the port, start it** (as in step 2):
|
||||
`sudo nano /etc/wireguard/wg-jellyfin.conf`, the home end's public key from step 1
|
||||
in place of `<the home end's public key>`; where the host has a firewall, UDP 51820
|
||||
opened (`sudo ufw allow 51820/udp`); the runner's unit as the checkout has it, so it
|
||||
can't reach the tunnel; then the tunnel.
|
||||
|
||||
```sh
|
||||
sudo install -m 0644 ~deploy/halkyon-learn/deploy/runner/forgejo-runner.service \
|
||||
/etc/systemd/system/forgejo-runner.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart forgejo-runner
|
||||
sudo systemctl enable --now wg-quick@wg-jellyfin
|
||||
```
|
||||
|
||||
4. 🏠 **The home end's config, folders and media** (in `deploy/home-jellyfin/`):
|
||||
`wg0.conf.example` as `wireguard/wg_confs/wg0.conf`, with the key from step 1 and
|
||||
the instance's public key from step 2; Jellyfin's folders owned by its user ID
|
||||
(61096, as on the instance's server: no person and no other container uses it); the
|
||||
media folder named in `.env` and readable by all, a folder per film, show or album
|
||||
as Jellyfin expects them.
|
||||
|
||||
```sh
|
||||
(umask 077 && sed "s|<the home end's private key>|$(cat wireguard/home.key)|" \
|
||||
wg0.conf.example > wireguard/wg_confs/wg0.conf)
|
||||
nano wireguard/wg_confs/wg0.conf # the instance's public key
|
||||
sudo install -d -o 61096 -g 61096 jellyfin/config jellyfin/cache
|
||||
echo "JELLYFIN_MEDIA=$HOME/Videos" > .env
|
||||
chmod -R o+rX "$HOME/Videos"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`sudo wg show wg-jellyfin` on the instance's server now shows a handshake.
|
||||
|
||||
5. 🏠 **Set Jellyfin up** at http://localhost:8096 on the home machine, before the site is
|
||||
switched on: the sidecar's step 6, but for its networking, which is the tunnel's:
|
||||
- Dashboard › Networking › **LAN networks**: `172.29.96.1/32` alone, the gateway
|
||||
through which this machine's loopback reaches it (`home-jellyfin/compose.yaml`).
|
||||
Left empty, Jellyfin counts every private range as local, the tunnel's too, and an
|
||||
admin without remote access could sign in through it.
|
||||
- Dashboard › Networking › **Known proxies**: `10.61.96.1`, as which Docker on the
|
||||
instance's server masquerades Caddy's requests. Jellyfin then sees each visitor's
|
||||
own address.
|
||||
- **Allow remote connections** on; the admin hidden from login screens, its remote
|
||||
connections off; Quick Connect, the demo account and its lockout as in the
|
||||
sidecar's step 6. Every account but those meant for everyone hidden from login
|
||||
screens: the instance suggests this server to all its people.
|
||||
|
||||
6. **Switch the site on** (the operator, on the instance's server, as `deploy`): in
|
||||
`deploy/.env`, `JELLYFIN_URL` left empty,
|
||||
|
||||
```sh
|
||||
COMPOSE_FILE=compose.yaml:compose.jellyfin-home.yaml
|
||||
JELLYFIN_DOMAIN=jellyfin.halkyon.eu
|
||||
```
|
||||
|
||||
then `cd ~/halkyon-learn/deploy && docker compose up -d`. Caddy and the server are
|
||||
recreated with it; the instance is away for seconds.
|
||||
|
||||
7. **Check from outside** (anyone, anywhere): the sidecar's step 7; and on the instance's
|
||||
server, as `deploy`, that nothing reaches the home end but Caddy, and the home end
|
||||
nothing of the server:
|
||||
|
||||
```sh
|
||||
# Refused (on edge, but not at Caddy's address): times out.
|
||||
docker run --rm --network halkyon-learn-edge caddy:2 \
|
||||
wget -T 5 -q -O /dev/null http://10.61.96.2:8096/System/Info/Public && echo LEAK
|
||||
# Caddy itself gets through.
|
||||
docker compose exec -T caddy wget -T 5 -q -O - http://10.61.96.2:8096/System/Info/Public
|
||||
```
|
||||
|
||||
and on the home machine, in `deploy/home-jellyfin/`:
|
||||
|
||||
```sh
|
||||
# Refused (nothing new from the tunnel reaches the host): times out.
|
||||
docker compose exec -T wireguard sh -c 'timeout 5 nc 10.61.96.1 22 </dev/null' && echo LEAK
|
||||
```
|
||||
|
||||
Stopping the home end (`docker compose stop`) makes the site answer 502, and
|
||||
`instance.info()` names no `jellyfinUrl` after the instance's next look, within about
|
||||
an hour.
|
||||
|
||||
Switching it off: on the instance's server the compose file out of `COMPOSE_FILE`, `docker
|
||||
compose up -d --remove-orphans`, and ✱ `sudo systemctl disable --now
|
||||
wg-quick@wg-jellyfin`, the port closed again; at home `docker compose down`, with
|
||||
`deploy/home-jellyfin/jellyfin/` kept until removed by hand.
|
||||
|
||||
## LanguageTool
|
||||
|
||||
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling
|
||||
|
|
|
|||
|
|
@ -13,5 +13,7 @@
|
|||
respond @userChanges 403
|
||||
respond /Startup/* 403
|
||||
|
||||
reverse_proxy jellyfin:8096
|
||||
# The sidecar's Jellyfin, or Jellyfin at home through WireGuard
|
||||
# (compose.jellyfin-home.yaml, ADR 0032).
|
||||
reverse_proxy {$JELLYFIN_UPSTREAM:jellyfin:8096}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue