halkyon/test/deploy/halkyon_li_forward_test.dart
Tizian 「ティツィアーン」 fb5eda87e9
fix(deploy): ramiel's own names redirected to HTTPS by hand behind HAProxy (ADR 0039)
With https_port 8443, Caddy's automatic HTTP-to-HTTPS redirects would send
visitors of code.halkyon.app, halkyon.app and learn.halkyon.app to :8443,
which is closed from outside. A site of their own on http:// redirects them
to the default port instead. The README's step for home's key points at the
host's step 5, and checks that 8443 stays closed and the redirect carries no
port.

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

330 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')));
});
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',
]);
});
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;
}