halkyon/test/deploy/halkyon_li_forward_test.dart

330 lines
11 KiB
Dart
Raw Normal View History

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;
}