diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index b3a48218..331d1186 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -55,6 +55,10 @@ jobs: 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" 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." # A job cancelled, or stopped at timeout-minutes, is killed with its step's process diff --git a/.gitignore b/.gitignore index fdda3f06..94147b99 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ deploy/.env deploy/assistants.env # A sidecar's files on the server, such as Jellyfin's configuration and media 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/ # BRouter's segments, the map's tiles and Photon's database (Train's ADR 0030, D44, # D50), downloaded on diff --git a/deploy/compose.jellyfin-home.yaml b/deploy/compose.jellyfin-home.yaml new file mode 100644 index 00000000..ac1d6583 --- /dev/null +++ b/deploy/compose.jellyfin-home.yaml @@ -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 diff --git a/deploy/home-jellyfin/compose.yaml b/deploy/home-jellyfin/compose.yaml new file mode 100644 index 00000000..2d5beb5f --- /dev/null +++ b/deploy/home-jellyfin/compose.yaml @@ -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 diff --git a/deploy/home-jellyfin/server/wg-jellyfin-rules.sh b/deploy/home-jellyfin/server/wg-jellyfin-rules.sh new file mode 100755 index 00000000..c11006bd --- /dev/null +++ b/deploy/home-jellyfin/server/wg-jellyfin-rules.sh @@ -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 `. 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 " >&2 + exit 2 + ;; +esac diff --git a/deploy/home-jellyfin/server/wg-jellyfin.conf b/deploy/home-jellyfin/server/wg-jellyfin.conf new file mode 100644 index 00000000..ffe4dd0f --- /dev/null +++ b/deploy/home-jellyfin/server/wg-jellyfin.conf @@ -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 = +AllowedIPs = 10.61.96.2/32 diff --git a/deploy/home-jellyfin/server/wg-quick-after-docker.conf b/deploy/home-jellyfin/server/wg-quick-after-docker.conf new file mode 100644 index 00000000..baf47504 --- /dev/null +++ b/deploy/home-jellyfin/server/wg-quick-after-docker.conf @@ -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 diff --git a/deploy/home-jellyfin/wg0.conf.example b/deploy/home-jellyfin/wg0.conf.example new file mode 100644 index 00000000..74a6b92a --- /dev/null +++ b/deploy/home-jellyfin/wg0.conf.example @@ -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 = + +[Peer] +PublicKey = +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 diff --git a/deploy/runner/forgejo-runner.service b/deploy/runner/forgejo-runner.service index f61f46c6..ae9a4c4f 100644 --- a/deploy/runner/forgejo-runner.service +++ b/deploy/runner/forgejo-runner.service @@ -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= diff --git a/deploy/sidecars/README.md b/deploy/sidecars/README.md index 6595000b..4891c9f7 100644 --- a/deploy/sidecars/README.md +++ b/deploy/sidecars/README.md @@ -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 ``; 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||$(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 + 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', + ); + }); + }); +}