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>
This commit is contained in:
parent
991af7af48
commit
d8c827eab6
1 changed files with 675 additions and 3 deletions
158
test/deploy/jellyfin_home_test.dart
Normal file
158
test/deploy/jellyfin_home_test.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue