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 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 _chain(List rules, String name) { final start = rules.indexOf('chain $name {'); expect(start, isNot(-1), reason: 'no chain $name'); final chain = []; for (final line in rules.skip(start + 1)) { if (line == '}') { break; } if (!line.startsWith('type ')) { chain.add(line); } } return chain; }