halkyon/test/deploy/jellyfin_home_test.dart
Tizian 「ティツィアーン」 82fa7aec1b
All checks were successful
CI / check (push) Successful in 41m12s
CI / deploy (push) Successful in 56s
fix(deploy): the home end dials the instance's server by its address (ADR 0032, D11)
A tunnel that fails to come up takes Jellyfin's internet with it, as
linuxserver/wireguard's client mode deletes the default route. Named by
learn.halkyon.eu, the endpoint needed DNS to come up, which the home end
starting or waking hasn't always got yet; by its address the tunnel comes
up whatever the network does. Closes ADR 0032's open point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 15:16:05 +01:00

168 lines
5.4 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("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',
);
});
});
}