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
13 changed files with 675 additions and 3 deletions
|
|
@ -55,6 +55,10 @@ jobs:
|
||||||
if timeout 5 bash -c 'exec 3<>/dev/tcp/fd4a:6c6b:7e80::2/443' 2>/dev/null; then
|
if timeout 5 bash -c 'exec 3<>/dev/tcp/fd4a:6c6b:7e80::2/443' 2>/dev/null; then
|
||||||
fail "Caddy's IPv6 network is reachable"
|
fail "Caddy's IPv6 network is reachable"
|
||||||
fi
|
fi
|
||||||
|
# Nor is Jellyfin at home through the host's tunnel, where it is up (ADR 0032, D6).
|
||||||
|
if timeout 5 bash -c 'exec 3<>/dev/tcp/10.61.96.2/8096' 2>/dev/null; then
|
||||||
|
fail "the tunnel to Jellyfin at home is reachable"
|
||||||
|
fi
|
||||||
echo "This job can neither see nor reach production."
|
echo "This job can neither see nor reach production."
|
||||||
|
|
||||||
# A job cancelled, or stopped at timeout-minutes, is killed with its step's process
|
# A job cancelled, or stopped at timeout-minutes, is killed with its step's process
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -28,6 +28,10 @@ deploy/.env
|
||||||
deploy/assistants.env
|
deploy/assistants.env
|
||||||
# A sidecar's files on the server, such as Jellyfin's configuration and media
|
# A sidecar's files on the server, such as Jellyfin's configuration and media
|
||||||
deploy/jellyfin/
|
deploy/jellyfin/
|
||||||
|
# Jellyfin at home's own (ADR 0032): the tunnel's key, Jellyfin's files, the media's path
|
||||||
|
deploy/home-jellyfin/.env
|
||||||
|
deploy/home-jellyfin/wireguard/
|
||||||
|
deploy/home-jellyfin/jellyfin/
|
||||||
deploy/languagetool/
|
deploy/languagetool/
|
||||||
# BRouter's segments, the map's tiles and Photon's database (Train's ADR 0030, D44,
|
# BRouter's segments, the map's tiles and Photon's database (Train's ADR 0030, D44,
|
||||||
# D50), downloaded on
|
# D50), downloaded on
|
||||||
|
|
|
||||||
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
|
# 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
|
# 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,
|
# 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
|
ProtectHome=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths=/var/lib/forgejo-runner
|
ReadWritePaths=/var/lib/forgejo-runner
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectProc=invisible
|
ProtectProc=invisible
|
||||||
InaccessiblePaths=-/run/docker.sock -/run/containerd -/var/lib/docker
|
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
|
NoNewPrivileges=yes
|
||||||
CapabilityBoundingSet=
|
CapabilityBoundingSet=
|
||||||
AmbientCapabilities=
|
AmbientCapabilities=
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ site changes.
|
||||||
| Sidecar | Compose file | Site |
|
| Sidecar | Compose file | Site |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Jellyfin, which people watch and listen to learn from | `compose.jellyfin.yaml` | `jellyfin.caddy` |
|
| 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 |
|
| LanguageTool, which checks spelling and grammar | `compose.languagetool.yaml` | none |
|
||||||
| Halkyon Train's server, with BRouter for its routes | `compose.train.yaml` | `train.caddy` |
|
| 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) |
|
| 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
|
Switching it off: the compose file out of `COMPOSE_FILE`, then `docker compose up -d
|
||||||
--remove-orphans`; `deploy/jellyfin/` stays until removed by hand.
|
--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
|
||||||
|
|
||||||
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling
|
LanguageTool's open-source server beside the instance (ADR 0024), which checks spelling
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,7 @@
|
||||||
respond @userChanges 403
|
respond @userChanges 403
|
||||||
respond /Startup/* 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}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
docs/adr/0032-jellyfin-at-home.md
Normal file
188
docs/adr/0032-jellyfin-at-home.md
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
158
test/deploy/jellyfin_home_test.dart
Normal file
158
test/deploy/jellyfin_home_test.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
|
/// Jellyfin at home, published by the instance through WireGuard (ADR 0032):
|
||||||
|
/// the tunnel reaches Jellyfin from Caddy alone, and nothing else of the
|
||||||
|
/// instance's server; nothing CI's checks would ever run, so held here.
|
||||||
|
void main() {
|
||||||
|
YamlMap compose(String path) =>
|
||||||
|
loadYaml(File(path).readAsStringSync()) as YamlMap;
|
||||||
|
|
||||||
|
group('the instance publishes it (D7, D9)', () {
|
||||||
|
final home = compose('deploy/compose.jellyfin-home.yaml');
|
||||||
|
final services = home['services'] as YamlMap;
|
||||||
|
|
||||||
|
test('runs no Jellyfin of its own', () {
|
||||||
|
expect(services.keys, isNot(contains('jellyfin')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has Caddy proxy to the home end over the tunnel', () {
|
||||||
|
final caddy = services['caddy'] as YamlMap;
|
||||||
|
final environment = caddy['environment'] as YamlMap;
|
||||||
|
expect(environment['JELLYFIN_UPSTREAM'], '10.61.96.2:8096');
|
||||||
|
expect(
|
||||||
|
caddy['volumes'],
|
||||||
|
contains(
|
||||||
|
'./sidecars/jellyfin.caddy:/etc/caddy/sidecars/jellyfin.caddy:ro',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never sets JELLYFIN_URL, so the instance suggests it only while it '
|
||||||
|
'answers', () {
|
||||||
|
final server = services['server'] as YamlMap?;
|
||||||
|
final environment = server?['environment'] as YamlMap?;
|
||||||
|
expect(environment?.keys ?? const [], isNot(contains('JELLYFIN_URL')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has the server look at Jellyfin through Caddy on edge', () {
|
||||||
|
final server = services['server'] as YamlMap;
|
||||||
|
expect(
|
||||||
|
server['extra_hosts'],
|
||||||
|
contains(
|
||||||
|
r'${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}:'
|
||||||
|
'172.30.80.2',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("the sidecar's site", () {
|
||||||
|
final site = File('deploy/sidecars/jellyfin.caddy').readAsStringSync();
|
||||||
|
|
||||||
|
test('proxies to the sidecar unless told otherwise', () {
|
||||||
|
expect(
|
||||||
|
site,
|
||||||
|
contains(r'reverse_proxy {$JELLYFIN_UPSTREAM:jellyfin:8096}'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still refuses changes to users and the setup wizard', () {
|
||||||
|
expect(site, contains('respond @userChanges 403'));
|
||||||
|
expect(site, contains('respond /Startup/* 403'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("the server's end of the tunnel (D4, D5)", () {
|
||||||
|
final config = File('deploy/home-jellyfin/server/wg-jellyfin.conf')
|
||||||
|
.readAsStringSync();
|
||||||
|
final rules = File('deploy/home-jellyfin/server/wg-jellyfin-rules.sh')
|
||||||
|
.readAsStringSync();
|
||||||
|
|
||||||
|
test('lets the home end send from its own address alone', () {
|
||||||
|
expect(
|
||||||
|
RegExp(
|
||||||
|
r'^AllowedIPs = (.+)$',
|
||||||
|
multiLine: true,
|
||||||
|
).allMatches(config).map((match) => match[1]),
|
||||||
|
['10.61.96.2/32'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holds no endpoint for the home end', () {
|
||||||
|
expect(config, isNot(contains('Endpoint')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets its rules as it comes up and takes them away as it goes', () {
|
||||||
|
expect(
|
||||||
|
config,
|
||||||
|
contains('PostUp = /etc/wireguard/wg-jellyfin-rules.sh up'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
config,
|
||||||
|
contains('PreDown = /etc/wireguard/wg-jellyfin-rules.sh down'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwards only Caddy to Jellyfin into the tunnel, and only replies '
|
||||||
|
'out of it', () {
|
||||||
|
expect(
|
||||||
|
rules,
|
||||||
|
contains(
|
||||||
|
r'-o "$interface" -s 172.30.80.2 -d 10.61.96.2 -p tcp --dport 8096 '
|
||||||
|
'-j RETURN',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
rules,
|
||||||
|
contains(
|
||||||
|
r'-i "$interface" -m conntrack --ctstate ESTABLISHED,RELATED '
|
||||||
|
'-j RETURN',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rules, contains(r'iptables -A "$forward" -j DROP'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts no new connection from the tunnel on the host', () {
|
||||||
|
expect(rules, contains(r'iptables -A "$input" -j DROP'));
|
||||||
|
expect(rules, contains(r'iptables -I INPUT -i "$interface" -j "$input"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CI runner can't reach the tunnel (D6)", () {
|
||||||
|
final unit = File('deploy/runner/forgejo-runner.service')
|
||||||
|
.readAsStringSync();
|
||||||
|
final denied = RegExp(
|
||||||
|
r'^IPAddressDeny=(.+)$',
|
||||||
|
multiLine: true,
|
||||||
|
).firstMatch(unit)![1]!.split(' ');
|
||||||
|
expect(denied, contains('10.61.96.0/30'));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('the home end (D2)', () {
|
||||||
|
final home = compose('deploy/home-jellyfin/compose.yaml');
|
||||||
|
final services = home['services'] as YamlMap;
|
||||||
|
|
||||||
|
test("has Jellyfin inside the tunnel's network namespace", () {
|
||||||
|
final jellyfin = services['jellyfin'] as YamlMap;
|
||||||
|
expect(jellyfin['network_mode'], 'service:wireguard');
|
||||||
|
expect(jellyfin.keys, isNot(contains('ports')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes Jellyfin's port on this machine's loopback alone", () {
|
||||||
|
final wireguard = services['wireguard'] as YamlMap;
|
||||||
|
expect(wireguard['ports'], ['127.0.0.1:8096:8096']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells Jellyfin its public address, never a home one', () {
|
||||||
|
final jellyfin = services['jellyfin'] as YamlMap;
|
||||||
|
final environment = jellyfin['environment'] as YamlMap;
|
||||||
|
expect(
|
||||||
|
environment['JELLYFIN_PublishedServerUrl'],
|
||||||
|
'https://jellyfin.halkyon.eu',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue