halkyon/test/deploy/jellyfin_home_test.dart
Tizian 「ティツィアーン」 d8c827eab6
Some checks failed
CI / check (push) Failing after 41m12s
CI / deploy (push) Has been skipped
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>
2026-10-30 23:17:57 +01:00

158 lines
5 KiB
Dart

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',
);
});
});
}