feat(deploy)!: Jellyfin on halkyon.li as the sidecar, ADR 0032's home end gone (ADR 0039, D15)

The Jellyfin sidecar takes its library from a folder on the host where .env
names one (JELLYFIN_MEDIA, read-only), else from deploy/jellyfin/media as
before. On halkyon.li that is /data/media: the library moves out of the
developer's home, which Jellyfin's own user can't enter. The sidecar README
says how the home end's configuration, accounts and libraries move in, with
its network settings corrected for Caddy before it starts.

BREAKING CHANGE: compose.jellyfin-home.yaml, deploy/home-jellyfin/ and
jellyfin.caddy's JELLYFIN_UPSTREAM are gone with ADR 0032, whose server end
retires with learn.halkyon.eu's server; it was never brought up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 19:15:25 +01:00
commit 2218695dcf
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 113 additions and 496 deletions

View file

@ -1,168 +0,0 @@
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("dials the instance's server by its address, so that the tunnel "
'comes up before the network does (D11)', () {
final config = File('deploy/home-jellyfin/wg0.conf.example')
.readAsLinesSync();
final endpoint = config.singleWhere(
(line) => line.startsWith('Endpoint'),
);
expect(endpoint, 'Endpoint = 89.58.53.154:51820');
});
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',
);
});
});
}

View file

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';
/// The Jellyfin sidecar (decided 2026-10-14), which halkyon.li runs with the
/// library already on its host (ADR 0039, D15), in place of ADR 0032's home
/// end, which goes with it.
void main() {
YamlMap services(String path) =>
(loadYaml(File(path).readAsStringSync()) as YamlMap)['services']
as YamlMap;
group('its server', () {
final jellyfin =
services('deploy/compose.jellyfin.yaml')['jellyfin'] as YamlMap;
test('reads the library read-only, from the host folder .env names or '
'its own', () {
expect(
jellyfin['volumes'],
contains(r'${JELLYFIN_MEDIA:-./jellyfin/media}:/media:ro'),
);
});
test('runs as its own user ID, on edge alone, publishing no port', () {
expect(jellyfin['user'], '61096:61096');
expect((jellyfin['networks'] as YamlMap).keys, ['edge']);
expect(jellyfin['ports'], isNull);
});
test('names its public address, never a home one', () {
expect(
(jellyfin['environment'] as YamlMap)['JELLYFIN_PublishedServerUrl'],
r'https://${JELLYFIN_DOMAIN:?set JELLYFIN_DOMAIN in deploy/.env}',
);
});
});
group('its site', () {
final site = File('deploy/sidecars/jellyfin.caddy').readAsStringSync();
test('refuses changes to users and the setup wizard from outside', () {
expect(site, contains('respond @userChanges 403'));
expect(site, contains('respond /Startup/* 403'));
});
test('proxies to the sidecar alone', () {
expect(site, contains('reverse_proxy jellyfin:8096'));
expect(site, isNot(contains('JELLYFIN_UPSTREAM')));
});
});
test("ADR 0032's home end is gone, its tunnel with it", () {
expect(File('deploy/compose.jellyfin-home.yaml').existsSync(), isFalse);
expect(File('deploy/home-jellyfin/compose.yaml').existsSync(), isFalse);
expect(Directory('deploy/home-jellyfin/server').existsSync(), isFalse);
});
}