halkyon/test/deploy/halkyon_li_forward_test.dart
Tizian 「ティツィアーン」 29844c7fa6
feat(deploy): halkyon.li's forward through ramiel and its host at home (ADR 0039)
ramiel's HAProxy passes halkyon.li's TLS connections, unopened, through a
WireGuard tunnel to the instance at home, with the visitor's address in a
PROXY header, and everything else to ramiel's own Caddy, moved to the
loopback. Each end's nftables table lets the tunnel carry HAProxy's 443 and
the SSH jump's 22 alone, and nothing from home to ramiel.

The host at home: sshd with keys alone, the root disk unlocked over SSH at
boot (dropbear-initramfs, D20), Docker's and containerd's data bind-mounted
from the HDD, and home's end of the tunnel before Docker. An instance behind
the forward (compose.forwarded.yaml) publishes 443 on the tunnel's address
alone, trusts the PROXY header from ramiel alone, and gets its certificates
through TLS-ALPN, without HTTP/3.

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

312 lines
11 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')));
});
});
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',
]);
});
test('comes up before Docker, which publishes on its address', () {
final dropIn = read('deploy/host/wg-quick-before-docker.conf');
expect(dropIn, contains('Before=docker.service'));
});
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", () {
expect(lines(read('deploy/host/initramfs-modules')), contains('cdc_ncm'));
});
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;
}