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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-08 19:05:47 +01:00
commit 29844c7fa6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 871 additions and 4 deletions

132
deploy/host/README.md Normal file
View file

@ -0,0 +1,132 @@
# The instance's host at home
halkyon.li runs at home on balthasar (ADR 0039) and is published through ramiel
(`../forward/README.md`). This folder holds what the host needs beyond Docker: SSH for
melchior, the root disk unlocked over SSH at boot, Docker's data on the HDD, and home's
end of the tunnel.
| File | On the host | What for |
|---|---|---|
| `sshd-halkyon.conf` | `/etc/ssh/sshd_config.d/10-halkyon.conf` | keys alone, two users (D17) |
| `dropbear.conf` | `/etc/dropbear/initramfs/dropbear.conf` | the passphrase over SSH at boot (D20) |
| `initramfs-modules` | appended to `/etc/initramfs-tools/modules` | the USB network adapter at boot (D20) |
| `fstab-docker` | appended to `/etc/fstab` | Docker's and containerd's data on the HDD (D12) |
| `docker-after-data.conf` | `docker.service.d/` and `containerd.service.d/` | both wait for it (D12) |
| `wg-halkyon.conf` | `/etc/wireguard/wg-halkyon.conf` | home's end of the tunnel (D8) |
| `wg-halkyon.nft` | `/etc/wireguard/wg-halkyon.nft` | what the tunnel may carry (D9, D17) |
| `wg-quick-before-docker.conf` | `wg-quick@wg-halkyon.service.d/` | the tunnel before Docker (D12) |
`test/deploy/halkyon_li_forward_test.dart` holds these files to ADR 0039's rules.
## Steps
As the developer at the iMac (`sudo`), from this checkout (`~/project-halkyon/halkyon`),
in this order: each step leaves the machine working, and the early ones keep a way in
for the later.
1. **sshd for melchior** (D17). On balthasar, then from melchior, then on balthasar:
```sh
sudo apt install openssh-server
```
```sh
ssh-copy-id raisondetredev@192.168.178.53 # on melchior, with balthasar's password
```
```sh
sudo install -m 644 deploy/host/sshd-halkyon.conf /etc/ssh/sshd_config.d/10-halkyon.conf
sudo sshd -t && sudo systemctl reload ssh
```
Then, before closing anything, from melchior: `ssh raisondetredev@192.168.178.53`
must still get in, and `ssh -o PubkeyAuthentication=no raisondetredev@192.168.178.53`
must be refused. In the FRITZ!Box, give balthasar always the same address
(*Heimnetz › Netzwerk › balthasar › Diesem Netzwerkgerät immer die gleiche
IPv4-Adresse zuweisen*), so that 192.168.178.53 stays its address.
2. **The passphrase over SSH at boot** (D20):
```sh
sudo apt install dropbear-initramfs
sudo install -m 644 deploy/host/dropbear.conf /etc/dropbear/initramfs/dropbear.conf
# melchior's public key alone (from ~/.ssh/authorized_keys, step 1), restricted:
printf 'no-port-forwarding,no-agent-forwarding,no-X11-forwarding,command="/usr/bin/cryptroot-unlock" %s\n' \
"$(cat ~/.ssh/authorized_keys)" | sudo tee /etc/dropbear/initramfs/authorized_keys
grep -v '^#' deploy/host/initramfs-modules | sudo tee -a /etc/initramfs-tools/modules
echo 'IP=::::balthasar:enx0050b6bffcff:dhcp' | sudo tee -a /etc/initramfs-tools/initramfs.conf
sudo update-initramfs -u
```
At the next boot, at home: `ssh -p 2222 root@192.168.178.53` from melchior asks for the
root disk's passphrase, and the boot goes on. The keyboard at the iMac still works as
before, so a mistake here costs no more than the walk to it.
3. **No desktop** (D12): it stops starting at boot, and Docker Desktop, which runs a VM of
its own beside Docker, goes. Multipass only if it isn't used:
```sh
sudo systemctl set-default multi-user.target # back: graphical.target
sudo apt remove docker-desktop
sudo snap disable multipass
```
4. **Docker's data on the HDD** (D12). Everything Docker holds now (the home Jellyfin's
images and containers) moves with it. Its configuration and library are in
`deploy/home-jellyfin/`, untouched:
```sh
(cd deploy/home-jellyfin && sudo docker compose down)
sudo systemctl stop docker docker.socket containerd
sudo mkdir -p /data/docker /data/containerd
sudo rsync -aHAX --numeric-ids /var/lib/docker/ /data/docker/
sudo rsync -aHAX --numeric-ids /var/lib/containerd/ /data/containerd/
sudo mv /var/lib/docker /var/lib/docker.old
sudo mv /var/lib/containerd /var/lib/containerd.old
sudo mkdir /var/lib/docker /var/lib/containerd
grep -v '^#' deploy/host/fstab-docker | sudo tee -a /etc/fstab
for unit in docker containerd; do
sudo install -D -m 644 deploy/host/docker-after-data.conf \
/etc/systemd/system/$unit.service.d/after-data.conf
done
sudo systemctl daemon-reload
sudo mount /var/lib/docker && sudo mount /var/lib/containerd
sudo systemctl start containerd docker
sudo docker images # as before
```
Once Docker runs as before: `sudo rm -rf /var/lib/docker.old /var/lib/containerd.old`.
5. **Home's end of the tunnel** (D8, D9, D12). Home's key, made here and never leaving
but into Proton Pass (vault *Infra*, an item *balthasar wg-halkyon*):
```sh
sudo apt install wireguard-tools
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077; wg genkey > /etc/wireguard/wg-halkyon.key'
sudo cat /etc/wireguard/wg-halkyon.key # into Proton Pass
sudo wg pubkey < /etc/wireguard/wg-halkyon.key # home's public key: ramiel's config
```
Then the files, with ramiel's public key (`../forward/README.md`, step 3) in place of
`<ramiel's public key>`:
```sh
sudo install -m 600 deploy/host/wg-halkyon.conf deploy/host/wg-halkyon.nft /etc/wireguard/
sudoedit /etc/wireguard/wg-halkyon.conf
sudo nft -c -f /etc/wireguard/wg-halkyon.nft # checks the rules, loads nothing
sudo install -D -m 644 deploy/host/wg-quick-before-docker.conf \
/etc/systemd/system/wg-quick@wg-halkyon.service.d/before-docker.conf
sudo systemctl daemon-reload
sudo systemctl enable --now wg-quick@wg-halkyon
sudo wg show wg-halkyon # a handshake once ramiel is up too
ping -c 1 10.61.97.1 # refused by home's own rules: nothing new goes to ramiel
```
From melchior, away from home: `ssh -J ramiel raisondetredev@10.61.97.2`.
6. **Reboot**, at home, to see each step hold: the passphrase over SSH (step 2), no
desktop (step 3), Docker on the HDD (step 4), the tunnel up before Docker (step 5).
The instance itself follows (its users, checkout, `.env` and first deploy), once its
compose files for Meditate and Jellyfin are built.

View file

@ -0,0 +1,7 @@
# Docker's and containerd's data are bind mounts from the HDD (ADR 0039, D12:
# fstab-docker): both services start once they are in place. As
# /etc/systemd/system/docker.service.d/after-data.conf and
# /etc/systemd/system/containerd.service.d/after-data.conf on the instance's host at
# home.
[Unit]
RequiresMountsFor=/var/lib/docker /var/lib/containerd

11
deploy/host/dropbear.conf Normal file
View file

@ -0,0 +1,11 @@
# The SSH server of the initramfs, through which the root disk's passphrase is typed at
# boot (ADR 0039, D20), as /etc/dropbear/initramfs/dropbear.conf on the instance's host
# at home. It waits on the home network alone (the tunnel to ramiel isn't up that
# early), on 2222, so its host key never clashes with sshd's in melchior's known_hosts.
#
# -p 2222 its port
# -s no passwords: melchior's key alone
# -j -k no forwarding of any kind
# -I 300 a connection idle for 5 minutes is closed
# -c cryptroot-unlock runs nothing but the unlock, whatever the client asks
DROPBEAR_OPTIONS="-p 2222 -s -j -k -I 300 -c cryptroot-unlock"

6
deploy/host/fstab-docker Normal file
View file

@ -0,0 +1,6 @@
# Docker's and containerd's data on the HDD (ADR 0039, D12), as /home and snapd's folder
# are on this machine: bind mounts from /data, which its LUKS key file unlocks once the
# root disk is open. Added to /etc/fstab on the instance's host at home. containerd's
# too, as Docker 29 keeps images in containerd's store.
/data/docker /var/lib/docker none bind,x-systemd.requires-mounts-for=/data 0 0
/data/containerd /var/lib/containerd none bind,x-systemd.requires-mounts-for=/data 0 0

View file

@ -0,0 +1,4 @@
# Added to /etc/initramfs-tools/modules on the instance's host at home (ADR 0039, D20):
# its network adapter is a USB one, whose driver the initramfs must load for dropbear
# to be reached at boot.
cdc_ncm

View file

@ -0,0 +1,8 @@
# sshd on the instance's host at home (ADR 0039, D17), reached over the home network
# and, from away, through ramiel's tunnel: keys alone, for the operator and deploy
# alone. As /etc/ssh/sshd_config.d/10-halkyon.conf (Ubuntu's sshd_config includes that
# folder first, so these win over its defaults). Check with `sudo sshd -t`.
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers raisondetredev deploy

View file

@ -0,0 +1,20 @@
# Home's end of the tunnel through which ramiel publishes halkyon.li (ADR 0039, D8), as
# /etc/wireguard/wg-halkyon.conf on the instance's host at home (root, 0600), brought
# up by wg-quick@wg-halkyon before Docker (wg-quick-before-docker.conf). Its private key
# is a file of its own beside it, never here. Setting it up: host/README.md.
[Interface]
Address = 10.61.97.2/30
PostUp = wg set %i private-key /etc/wireguard/wg-halkyon.key
# From ramiel only Caddy's 443 and sshd's 22, nothing towards it (D9, D17).
PostUp = nft -f /etc/wireguard/wg-halkyon.nft
PreDown = nft delete table inet halkyon_tunnel
# ramiel, dialled by its address: home opens nothing on the FRITZ!Box, and a new home
# address needs no change anywhere. The keepalive keeps the way back open through the
# router's NAT.
[Peer]
PublicKey = <ramiel's public key>
Endpoint = 45.129.181.128:51820
AllowedIPs = 10.61.97.1/32
PersistentKeepalive = 25

View file

@ -0,0 +1,45 @@
# The rules around home's end of the tunnel from ramiel (ADR 0039, D9, D17), loaded by
# wg-quick as the tunnel comes up and deleted as it goes (wg-halkyon.conf), so they live
# exactly as long as it. As /etc/wireguard/wg-halkyon.nft on the instance's host at home
# (root, 0600).
#
# A table of its own, apart from Docker's and ufw's, at a priority before theirs: what it
# drops is dropped whatever they say, and what it accepts still meets their rules after
# it.
#
# - To the host: new connections from ramiel to sshd alone (22, the jump from away),
# then replies.
# - Through the host: new connections from ramiel to Caddy's 443 alone (172.30.80.2 on
# edge, where Docker has sent the tunnel address's 443 by then), then replies;
# nothing to the home network, the other containers or anywhere else.
# - Towards ramiel: replies alone.
#
# IPv4 alone: the tunnel carries no IPv6 (D8).
# Made and deleted first, so loading this again replaces it whole.
table inet halkyon_tunnel {}
delete table inet halkyon_tunnel
table inet halkyon_tunnel {
chain input {
type filter hook input priority filter - 1; policy accept;
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
}
chain forward {
type filter hook forward priority filter - 1; policy accept;
iifname "wg-halkyon" ct state established,related accept
iifname "wg-halkyon" ip saddr 10.61.97.1 ip daddr 172.30.80.2 tcp dport 443 accept
iifname "wg-halkyon" drop
oifname "wg-halkyon" ct state established,related accept
oifname "wg-halkyon" drop
}
chain output {
type filter hook output priority filter - 1; policy accept;
oifname "wg-halkyon" ct state established,related accept
oifname "wg-halkyon" drop
}
}

View file

@ -0,0 +1,6 @@
# The tunnel comes up before Docker (ADR 0039, D12): Caddy publishes 443 on the
# tunnel's address alone (compose.forwarded.yaml), which must exist when Docker starts
# the container. As /etc/systemd/system/wg-quick@wg-halkyon.service.d/before-docker.conf
# on the instance's host at home.
[Unit]
Before=docker.service