halkyon/test/deploy/halkyon_li_forward_test.dart
Tizian 「ティツィアーン」 0144cea380
fix(deploy): dropbear at boot on initramfs-tools, with dracut's image as the way back (ADR 0039, D20)
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 22:07:21 +01:00

383 lines
13 KiB
Dart

import 'dart:io';
import 'package:test/test.dart';
/// halkyon.li at home, published through ramiel (ADR 0039): TLS passes
/// through ramiel by name, unopened (D3, D10, D11), and the tunnel between
/// them carries what each side needs and nothing else (D8, D9, D17). None of
/// it runs in CI, so its rules are held here, refusals first.
void main() {
String read(String path) => File(path).readAsStringSync();
/// The lines of [text] without comments and blank lines, trimmed.
List<String> lines(String text) => [
for (final line in text.split('\n'))
if (line.trim().isNotEmpty && !line.trim().startsWith('#')) line.trim(),
];
const names = [
'halkyon.li',
'learn.halkyon.li',
'todo.halkyon.li',
'train.halkyon.li',
'meditate.halkyon.li',
'jellyfin.halkyon.li',
];
group("ramiel's HAProxy (D3, D10)", () {
final config = lines(read('deploy/forward/haproxy.cfg'));
test('logs nothing, so no visitor of halkyon.li is kept on ramiel', () {
expect(config.where((line) => line.startsWith('log ')), isEmpty);
expect(config, isNot(contains('option tcplog')));
});
test('passes TLS on unopened: no certificate, no HTTP', () {
expect(config.where((line) => line.contains('crt ')), isEmpty);
expect(config, isNot(contains('mode http')));
expect(config, contains('mode tcp'));
});
test("sends halkyon.li's names alone through the tunnel", () {
final acl = config.singleWhere(
(line) => line.startsWith('acl halkyon_li '),
);
expect(acl, 'acl halkyon_li req.ssl_sni -i ${names.join(' ')}');
expect(config, contains('use_backend home if halkyon_li'));
});
test('sends everything else to the local Caddy', () {
expect(config, contains('default_backend local'));
expect(config, contains('server caddy 127.0.0.1:8443 send-proxy-v2'));
});
test("reaches home's Caddy at its tunnel address alone, with the "
"visitor's address", () {
expect(
config.where((line) => line.startsWith('server ')),
unorderedEquals([
'server caddy 127.0.0.1:8443 send-proxy-v2',
'server balthasar 10.61.97.2:443 send-proxy-v2',
]),
);
});
test('listens on 443 over IPv4 and IPv6', () {
expect(config, contains('bind :::443 v4v6'));
});
});
group("ramiel's Caddy (D3, D11)", () {
final caddy = read('deploy/forward/Caddyfile.halkyon-li');
test('leaves 443 to HAProxy, trusting its PROXY header from the loopback '
'alone', () {
expect(caddy, contains('https_port 8443'));
expect(caddy, contains('allow 127.0.0.1/32 ::1/128'));
});
test('speaks no HTTP/3, which HAProxy would not route', () {
expect(caddy, contains('protocols h1 h2'));
expect(caddy, isNot(contains('h3')));
});
test('sends plain HTTP for halkyon.li on to HTTPS, and serves none of '
'it itself', () {
expect(caddy, contains('http://halkyon.li, http://*.halkyon.li {'));
expect(caddy, contains('redir https://{host}{uri} 308'));
expect(caddy, isNot(contains('reverse_proxy')));
});
test("redirects its own names to HTTPS by hand, so Caddy's automatic "
'redirects never point at 8443', () {
expect(
caddy,
contains(
'http://code.halkyon.app, http://halkyon.app, '
'http://learn.halkyon.app {',
),
);
expect(
RegExp(
r'^\s*redir (\S+)',
multiLine: true,
).allMatches(caddy).map((match) => match[1]),
everyElement('https://{host}{uri}'),
);
});
});
group("ramiel's end of the tunnel (D8, D9)", () {
final config = read('deploy/forward/wg-halkyon.conf');
final rules = lines(read('deploy/forward/wg-halkyon.nft'));
test('lets home send from its own address alone', () {
expect(
RegExp(
r'^AllowedIPs = (.+)$',
multiLine: true,
).allMatches(config).map((match) => match[1]),
['10.61.97.2/32'],
);
});
test('holds no endpoint, so it never names the home address', () {
expect(config, isNot(contains('Endpoint')));
});
test('keeps its key out of the file', () {
expect(config, isNot(contains('PrivateKey')));
expect(
config,
contains(
'PostUp = wg set %i private-key /etc/wireguard/wg-halkyon.key',
),
);
});
test('sets its rules as it comes up and takes them away as it goes', () {
expect(config, contains('PostUp = nft -f /etc/wireguard/wg-halkyon.nft'));
expect(
config,
contains('PreDown = nft delete table inet halkyon_tunnel'),
);
});
test('accepts no new connection from home', () {
expect(_chain(rules, 'input'), [
'iifname "wg-halkyon" ct state established,related accept',
'iifname "wg-halkyon" drop',
]);
});
test("opens to home only HAProxy's 443 and the jump's 22 (D17)", () {
expect(_chain(rules, 'output'), [
'oifname "wg-halkyon" ct state established,related accept',
'oifname "wg-halkyon" ip daddr 10.61.97.2 tcp dport { 22, 443 } accept',
'oifname "wg-halkyon" drop',
]);
});
test('forwards nothing through the tunnel', () {
final forward = _chain(rules, 'forward');
expect(forward, contains('iifname "wg-halkyon" drop'));
expect(forward, contains('oifname "wg-halkyon" drop'));
expect(forward.where((line) => line.endsWith('accept')), isEmpty);
});
});
group("home's end of the tunnel (D8, D9, D12, D17)", () {
final config = read('deploy/host/wg-halkyon.conf');
final rules = lines(read('deploy/host/wg-halkyon.nft'));
test('lets ramiel send from its own address alone', () {
expect(
RegExp(
r'^AllowedIPs = (.+)$',
multiLine: true,
).allMatches(config).map((match) => match[1]),
['10.61.97.1/32'],
);
});
test('dials ramiel by its address and keeps the way open', () {
expect(config, contains('Endpoint = 45.129.181.128:51820'));
expect(config, contains('PersistentKeepalive = 25'));
});
test('keeps its key out of the file', () {
expect(config, isNot(contains('PrivateKey')));
expect(
config,
contains(
'PostUp = wg set %i private-key /etc/wireguard/wg-halkyon.key',
),
);
});
test('accepts from ramiel only the jump to sshd, nothing else on the '
'host', () {
expect(_chain(rules, 'input'), [
'iifname "wg-halkyon" ct state established,related accept',
'iifname "wg-halkyon" ip saddr 10.61.97.1 tcp dport 22 accept',
'iifname "wg-halkyon" drop',
]);
});
test("passes from ramiel only to Caddy's 443, nothing else of the home "
'network or the containers', () {
expect(_chain(rules, 'forward'), [
'iifname "wg-halkyon" ct state established,related accept',
_caddyFromRamiel,
'iifname "wg-halkyon" drop',
'oifname "wg-halkyon" ct state established,related accept',
'oifname "wg-halkyon" drop',
]);
});
test('opens nothing towards ramiel', () {
expect(_chain(rules, 'output'), [
'oifname "wg-halkyon" ct state established,related accept',
'oifname "wg-halkyon" drop',
]);
});
group("wg-quick's unit", () {
final dropIn = read('deploy/host/wg-quick-halkyon.conf');
test('comes up before Docker, which publishes on its address', () {
expect(dropIn, contains('Before=docker.service'));
});
test('loads the rules before the tunnel exists and deletes them after '
"it is gone, outside wg-quick's AppArmor profile, which lets nft "
'read nothing in /etc/wireguard (Ubuntu 26.04)', () {
expect(
dropIn,
contains(
'ExecStartPre=/usr/sbin/nft -f /etc/wireguard/wg-halkyon.nft',
),
);
expect(
dropIn,
contains(
'ExecStopPost=-/usr/sbin/nft delete table inet halkyon_tunnel',
),
);
expect(config, isNot(contains('nft')));
});
});
test('lets sshd take keys alone, for two users alone', () {
final sshd = lines(read('deploy/host/sshd-halkyon.conf'));
expect(sshd, contains('PasswordAuthentication no'));
expect(sshd, contains('KbdInteractiveAuthentication no'));
expect(sshd, contains('PermitRootLogin no'));
expect(sshd, contains('AllowUsers raisondetredev deploy'));
});
});
group('the host at home (D12, D20)', () {
test('unlocks its disk over SSH at boot, on a port of its own, and runs '
'nothing else there', () {
final dropbear = read('deploy/host/dropbear.conf');
final options = RegExp(
r'^DROPBEAR_OPTIONS="(.+)"$',
multiLine: true,
).firstMatch(dropbear)![1]!.split(' ');
// No password, no forwarding of any kind, nothing but the unlock.
expect(options, containsAll(['-p', '2222', '-s', '-j', '-k']));
expect(options, containsAll(['-c', 'cryptroot-unlock']));
});
test("loads the USB network adapter's driver at boot, and takes its "
'address by DHCP there', () {
expect(lines(read('deploy/host/initramfs-modules')), contains('cdc_ncm'));
expect(lines(read('deploy/host/initramfs-ip')), [
'IP=::::balthasar:enx0050b6bffcff:dhcp',
]);
});
group("keeps a way back to dracut's image, which initramfs-tools "
'replaces', () {
final fallback = read('deploy/host/grub-dracut-fallback');
test("as GRUB's entry for each kernel that has a copy of it", () {
expect(
fallback,
contains('for image in /boot/initrd.img-*.dracut; do'),
);
expect(
fallback,
contains(r'[ -e "/boot/vmlinuz-$version" ] || continue'),
);
expect(fallback, contains(r'initrd /initrd.img-$version.dracut'));
expect(
File('deploy/host/grub-dracut-fallback').statSync().mode & 0x49,
0x49,
reason: 'executable, as update-grub runs it',
);
});
test("in GRUB's menu, shown long enough to choose it at the iMac", () {
final grub = lines(read('deploy/host/grub-halkyon.cfg'));
expect(grub, contains('GRUB_TIMEOUT_STYLE=menu'));
expect(grub, contains('GRUB_TIMEOUT=5'));
});
});
test("keeps Docker's data on the HDD, which Docker waits for", () {
final fstab = lines(read('deploy/host/fstab-docker'))
.map((line) => line.split(RegExp(r'\s+')).take(2).join(' '));
expect(fstab, [
'/data/docker /var/lib/docker',
'/data/containerd /var/lib/containerd',
]);
expect(
read('deploy/host/docker-after-data.conf'),
contains('RequiresMountsFor=/var/lib/docker /var/lib/containerd'),
);
});
});
group("home's Caddy behind the forward (D10, D11, D12)", () {
final compose = read('deploy/compose.forwarded.yaml');
final global = read('deploy/forwarded.caddy');
test('publishes 443 on the tunnel address alone, and neither 80 nor '
'HTTP/3', () {
final ports = RegExp(r'ports: !override\n((?:\s+- .+\n)+)')
.firstMatch(compose)![1]!
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty);
expect(ports, ['- "10.61.97.2:443:443"']);
});
test("trusts the PROXY header from ramiel's tunnel address alone", () {
expect(global, contains('allow 10.61.97.1/32'));
expect(RegExp('allow (.+)').allMatches(global).map((match) => match[1]), [
'10.61.97.1/32',
]);
});
test('gets its certificates through TLS alone, and speaks no HTTP/3', () {
expect(global, contains('disable_http_challenge'));
expect(global, contains('protocols h1 h2'));
});
test("is mounted into Caddy's global options", () {
expect(
compose,
contains('./forwarded.caddy:/etc/caddy/global/forwarded.caddy:ro'),
);
expect(read('deploy/Caddyfile'), contains('import global/*.caddy'));
});
test('recreates Caddy when it changes, as a sidecar site does', () {
expect(read('deploy/redeploy.sh'), contains('forwarded.caddy'));
});
});
}
/// home's one rule passing anything from ramiel on: to Caddy's 443.
const _caddyFromRamiel =
'iifname "wg-halkyon" ip saddr 10.61.97.1 ip daddr 172.30.80.2 '
'tcp dport 443 accept';
/// The rules of [rules]' chain [name], from its first rule to its closing
/// brace, without its `type … hook …` line.
List<String> _chain(List<String> rules, String name) {
final start = rules.indexOf('chain $name {');
expect(start, isNot(-1), reason: 'no chain $name');
final chain = <String>[];
for (final line in rules.skip(start + 1)) {
if (line == '}') {
break;
}
if (!line.startsWith('type ')) {
chain.add(line);
}
}
return chain;
}