312 lines
11 KiB
Dart
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;
|
||
|
|
}
|