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:05:47 +01:00
|
|
|
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')));
|
|
|
|
|
});
|
2026-11-08 20:22:13 +01:00
|
|
|
|
|
|
|
|
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}'),
|
|
|
|
|
);
|
|
|
|
|
});
|
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:05:47 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-11-08 20:45:46 +01:00
|
|
|
group("wg-quick's unit", () {
|
|
|
|
|
final dropIn = read('deploy/host/wg-quick-halkyon.conf');
|
|
|
|
|
|
|
|
|
|
test('comes up before Docker, which publishes on its address', () {
|
|
|
|
|
expect(dropIn, contains('Before=docker.service'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('loads the rules before the tunnel exists and deletes them after '
|
|
|
|
|
"it is gone, outside wg-quick's AppArmor profile, which lets nft "
|
|
|
|
|
'read nothing in /etc/wireguard (Ubuntu 26.04)', () {
|
|
|
|
|
expect(
|
|
|
|
|
dropIn,
|
|
|
|
|
contains(
|
|
|
|
|
'ExecStartPre=/usr/sbin/nft -f /etc/wireguard/wg-halkyon.nft',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
dropIn,
|
|
|
|
|
contains(
|
|
|
|
|
'ExecStopPost=-/usr/sbin/nft delete table inet halkyon_tunnel',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(config, isNot(contains('nft')));
|
|
|
|
|
});
|
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:05:47 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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']));
|
|
|
|
|
});
|
|
|
|
|
|
fix(deploy): dropbear at boot on initramfs-tools, with dracut's image as the way back (ADR 0039, D20)
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 21:10:22 +01:00
|
|
|
test("loads the USB network adapter's driver at boot, and takes its "
|
|
|
|
|
'address by DHCP there', () {
|
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:05:47 +01:00
|
|
|
expect(lines(read('deploy/host/initramfs-modules')), contains('cdc_ncm'));
|
fix(deploy): dropbear at boot on initramfs-tools, with dracut's image as the way back (ADR 0039, D20)
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 21:10:22 +01:00
|
|
|
expect(lines(read('deploy/host/initramfs-ip')), [
|
|
|
|
|
'IP=::::balthasar:enx0050b6bffcff:dhcp',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group("keeps a way back to dracut's image, which initramfs-tools "
|
|
|
|
|
'replaces', () {
|
|
|
|
|
final fallback = read('deploy/host/grub-dracut-fallback');
|
|
|
|
|
|
|
|
|
|
test("as GRUB's entry for each kernel that has a copy of it", () {
|
|
|
|
|
expect(
|
|
|
|
|
fallback,
|
|
|
|
|
contains('for image in /boot/initrd.img-*.dracut; do'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
fallback,
|
|
|
|
|
contains(r'[ -e "/boot/vmlinuz-$version" ] || continue'),
|
|
|
|
|
);
|
|
|
|
|
expect(fallback, contains(r'initrd /initrd.img-$version.dracut'));
|
2026-11-12 14:15:52 +01:00
|
|
|
// As git keeps it, which is what the host checks out: the working
|
|
|
|
|
// tree's mode follows the umask of whoever checked it out, and CI's
|
|
|
|
|
// runner checks out under 0077.
|
|
|
|
|
final mode =
|
|
|
|
|
Process.runSync('git', [
|
|
|
|
|
'ls-files',
|
|
|
|
|
'--stage',
|
|
|
|
|
'--',
|
|
|
|
|
'deploy/host/grub-dracut-fallback',
|
|
|
|
|
]).stdout
|
|
|
|
|
as String;
|
fix(deploy): dropbear at boot on initramfs-tools, with dracut's image as the way back (ADR 0039, D20)
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 21:10:22 +01:00
|
|
|
expect(
|
2026-11-12 14:15:52 +01:00
|
|
|
mode,
|
|
|
|
|
startsWith('100755 '),
|
fix(deploy): dropbear at boot on initramfs-tools, with dracut's image as the way back (ADR 0039, D20)
Ubuntu 26.04 builds its boot images with dracut, and dropbear-initramfs needs
initramfs-tools, which replaces it. The host's step 2 now installs
initramfs-tools with cryptsetup-initramfs beside dropbear, sets the boot
image's address in a conf.d file of its own, and checks every new image for
what opens the disk, dropbear, its key and the adapter's driver before any
reboot. Before switching, dracut's image of the running kernel is kept as an
entry in GRUB's menu (shown 5 s, the boot in text), so a failed image is
undone at the iMac without a USB stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-08 21:10:22 +01:00
|
|
|
reason: 'executable, as update-grub runs it',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("in GRUB's menu, shown long enough to choose it at the iMac", () {
|
|
|
|
|
final grub = lines(read('deploy/host/grub-halkyon.cfg'));
|
|
|
|
|
expect(grub, contains('GRUB_TIMEOUT_STYLE=menu'));
|
|
|
|
|
expect(grub, contains('GRUB_TIMEOUT=5'));
|
|
|
|
|
});
|
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:05:47 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|