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:
parent
fd9c2dfe76
commit
2218695dcf
2 changed files with 113 additions and 496 deletions
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
59
test/deploy/jellyfin_sidecar_test.dart
Normal file
59
test/deploy/jellyfin_sidecar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue