Commit c7a956d2fe77
Changed files (25)
alpine
common
root
etc
home
vincent
.ssh
root
.ssh
hosts
athena
root
etc
bind
network
wireguard
alpine/common/root/etc/chrony/chrony.conf
@@ -0,0 +1,4 @@
+pool pool.ntp.org iburst
+driftfile /var/lib/chrony/chrony.drift
+rtcsync
+makestep 1 3
alpine/common/root/etc/init.d/persist-keys
@@ -0,0 +1,27 @@
+#!/sbin/openrc-run
+description="Restore SSH host keys and WireGuard key from persist partition"
+
+depend() {
+ after localmount
+ before sshd secrets wg-quick.wg0
+}
+
+start() {
+ if [ ! -d /persist ]; then
+ einfo "No persist partition — using generated keys"
+ return 0
+ fi
+
+ # SSH host key
+ if [ -f /persist/ssh/ssh_host_ed25519_key ]; then
+ ebegin "Restoring SSH host key from persist"
+ cp /persist/ssh/ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key
+ [ -f /persist/ssh/ssh_host_ed25519_key.pub ] && \
+ cp /persist/ssh/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub
+ chmod 600 /etc/ssh/ssh_host_ed25519_key
+ chmod 644 /etc/ssh/ssh_host_ed25519_key.pub 2>/dev/null
+ eend 0
+ else
+ ewarn "No SSH host key on persist — using generated key"
+ fi
+}
alpine/common/root/etc/init.d/secrets
@@ -0,0 +1,46 @@
+#!/sbin/openrc-run
+description="Decrypt age-encrypted secrets at boot"
+
+depend() {
+ after localmount
+ before named mosquitto
+}
+
+start() {
+ if [ ! -d /etc/secrets ] || [ -z "$(ls /etc/secrets/*.age 2>/dev/null)" ]; then
+ einfo "No secrets to decrypt"
+ return 0
+ fi
+
+ ebegin "Decrypting secrets"
+ mkdir -p /run/secrets
+ chmod 700 /run/secrets
+
+ local key="/persist/ssh/ssh_host_ed25519_key"
+ if [ ! -f "$key" ]; then
+ ewarn "Host key not found at $key (no persist partition?)"
+ ewarn "Secrets will not be decrypted"
+ eend 0
+ return 0
+ fi
+
+ local failed=0
+ for secret in /etc/secrets/*.age; do
+ [ -f "$secret" ] || continue
+ local name
+ name=$(basename "$secret" .age)
+ if age -d -i "$key" "$secret" > "/run/secrets/$name" 2>/dev/null; then
+ if [ -f "/etc/secrets/${name}.meta" ]; then
+ . "/etc/secrets/${name}.meta"
+ chown "${OWNER:-root}:${GROUP:-root}" "/run/secrets/$name"
+ chmod "${MODE:-400}" "/run/secrets/$name"
+ else
+ chmod 400 "/run/secrets/$name"
+ fi
+ else
+ ewarn "Failed to decrypt: $name"
+ failed=1
+ fi
+ done
+ eend $failed
+}
alpine/common/root/etc/ssh/sshd_config
@@ -0,0 +1,7 @@
+PasswordAuthentication no
+KbdInteractiveAuthentication no
+PermitRootLogin without-password
+PermitUserEnvironment no
+MaxAuthTries 20
+StreamLocalBindUnlink yes
+PubkeyAcceptedKeyTypes ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256
alpine/common/root/etc/sysctl.d/local.conf
@@ -0,0 +1,2 @@
+# Enable IP forwarding for WireGuard
+net.ipv4.ip_forward = 1
alpine/common/packages
@@ -0,0 +1,21 @@
+# Base system
+alpine-base
+linux-rpi
+linux-firmware-none
+
+# Networking
+openssh
+wireguard-tools
+chrony
+
+# Secrets
+age
+
+# Monitoring
+prometheus-node-exporter
+
+# Tools
+curl
+htop
+rsync
+vim
alpine/common/setup.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+# Common setup for all Alpine appliances
+# (sourced into the combined setup script, runs in chroot)
+
+# --- Runlevels ---
+# sysinit
+rc-update add devfs sysinit
+rc-update add dmesg sysinit
+rc-update add mdev sysinit
+rc-update add hwdrivers sysinit
+
+# boot
+rc-update add hwclock boot
+rc-update add modules boot
+rc-update add sysctl boot
+rc-update add hostname boot
+rc-update add bootmisc boot
+rc-update add syslog boot
+rc-update add networking boot
+rc-update add seedrng boot
+
+# shutdown
+rc-update add mount-ro shutdown
+rc-update add killprocs shutdown
+
+# default
+rc-update add crond default
+rc-update add chronyd default
+rc-update add sshd default
+rc-update add node-exporter default
+rc-update add persist-keys default
+rc-update add secrets default
+
+# WireGuard: create symlink for wg0 interface
+ln -sf /etc/init.d/wg-quick /etc/init.d/wg-quick.wg0
+# Add to boot but allow failure (no private key in QEMU, no route to endpoint)
+rc-update add wg-quick.wg0 default
+
+# --- Users ---
+adduser -D -s /bin/ash -h /home/vincent vincent
+adduser vincent wheel
+
+# --- Root password disabled, SSH only ---
+# Set a known password for console access (SSH is key-only)
+echo 'root:alpine' | chpasswd
+
+# --- Generate SSH host keys for testing ---
+# These get overridden at boot by /etc/init.d/persist-keys if /persist exists.
+# Generating them here ensures sshd works in QEMU without a persist partition.
+ssh-keygen -A
+
+# --- Serial console for QEMU/debug ---
+# Add getty on ttyAMA0 (RPi UART / QEMU serial)
+echo 'ttyAMA0::respawn:/sbin/getty -L ttyAMA0 115200 vt100' >> /etc/inittab
alpine/hosts/athena/root/etc/bind/named.conf
@@ -0,0 +1,41 @@
+options {
+ directory "/var/bind";
+ listen-on { any; };
+ listen-on-v6 { any; };
+ allow-query { any; };
+ forwarders {
+ 8.8.8.8;
+ 8.8.4.4;
+ };
+ dnssec-validation no;
+};
+
+zone "sbr.pm" {
+ type master;
+ file "/etc/bind/zones/db.sbr.pm";
+};
+
+zone "home" {
+ type master;
+ file "/etc/bind/zones/db.home";
+};
+
+zone "192.168.1.in-addr.arpa" {
+ type master;
+ file "/etc/bind/zones/db.192.168.1";
+};
+
+zone "demeester.fr" {
+ type master;
+ file "/etc/bind/zones/db.demeester.fr";
+};
+
+zone "vpn" {
+ type master;
+ file "/etc/bind/zones/db.vpn";
+};
+
+zone "10.100.0.in-addr.arpa" {
+ type master;
+ file "/etc/bind/zones/db.10.100.0";
+};
alpine/hosts/athena/root/etc/network/interfaces
@@ -0,0 +1,8 @@
+auto lo
+iface lo inet loopback
+
+auto eth0
+iface eth0 inet static
+ address 192.168.1.183
+ netmask 255.255.255.0
+ gateway 192.168.1.1
alpine/hosts/athena/root/etc/secrets/icloud-vdemeester-password.age
@@ -0,0 +1,9 @@
+age-encryption.org/v1
+-> piv-p256 ItIHHA AqOHYheFEX4RR4bHmrrCZNFeiCxLWnO5Kr/+XTKynOeb
+zaai7ZI4fM4ze2gSLKfexfIu6NolNLVNPxM2cPFzZ+M
+-> piv-p256 cUinNw AowUNQltwPPrlJwWc8y+3prjdtZP/i3zRrRR8spwIrE+
+wQSp14g3s5QC8ZNry3r9nQppzrXjaAdGri8AIZH153U
+-> ssh-ed25519 i5EGzg QKIKFzqcJn4h+KkZbS74/5gPG9IxuVxOkO/u1RpruQU
+lTL//Bg3amyLT9HeMofXDPYGBaOZLw/gYELBXJR7PNU
+--- /nS888LIH0xNT3rPGBd4lC+xFWIrVrcGx/vGHtXVcT4
+�d��_O�e@�l�Ď�Yq�m�7�M�If@P/̔&��6���<�"o�.�
\ No newline at end of file
alpine/hosts/athena/root/etc/secrets/icloud-vdemeester-password.meta
@@ -0,0 +1,3 @@
+OWNER=vincent
+GROUP=users
+MODE=400
alpine/hosts/athena/root/etc/wireguard/wg0.conf
@@ -0,0 +1,9 @@
+[Interface]
+Address = 10.100.0.83/32
+PostUp = test -f /persist/wireguard/private.key && wg set %i private-key /persist/wireguard/private.key || true
+
+[Peer]
+PublicKey = PQD1dtxhy9NMbmfy2OQPeLiQg0Alcfa1Mo4HVN5WqgA=
+Endpoint = 46.224.100.116:51820
+AllowedIPs = 10.100.0.0/24
+PersistentKeepalive = 25
alpine/hosts/athena/root/etc/fstab
@@ -0,0 +1,2 @@
+# Persistent partition for SSH host key, WireGuard key, seedrng
+LABEL=persist /persist ext4 noatime,ro 0 0
alpine/hosts/athena/root/etc/hostname
@@ -0,0 +1,1 @@
+athena
alpine/hosts/athena/packages
@@ -0,0 +1,4 @@
+# DNS
+bind
+bind-tools
+# TODO: prometheus-bind-exporter not in Alpine repos — need static binary
alpine/hosts/athena/setup.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Athena-specific setup
+
+# Enable DNS services
+rc-update add named default
alpine/.gitignore
@@ -0,0 +1,2 @@
+images/
+generated/
alpine/build-inner.sh
@@ -0,0 +1,147 @@
+#!/bin/sh
+# Runs inside the Alpine container (aarch64 via qemu-user).
+# Usage: /build/build-inner.sh <hostname>
+set -e
+
+HOST="${1:?Usage: $0 <hostname>}"
+BUILD_DIR="/build"
+COMMON_DIR="$BUILD_DIR/common"
+HOST_DIR="$BUILD_DIR/hosts/$HOST"
+OUTPUT_DIR="$BUILD_DIR/images/$HOST"
+
+log() { printf "\n\033[1;32m==> %s\033[0m\n" "$1"; }
+
+log "Installing build dependencies"
+apk add --no-cache cpio gzip findutils
+
+log "Preparing rootfs skeleton"
+ROOTFS=$(mktemp -d)
+
+# Copy common root skeleton
+if [ -d "$COMMON_DIR/root" ]; then
+ cp -a "$COMMON_DIR/root/." "$ROOTFS/"
+fi
+# Overlay host-specific skeleton (overrides common)
+if [ -d "$HOST_DIR/root" ]; then
+ cp -a "$HOST_DIR/root/." "$ROOTFS/"
+fi
+
+# Copy generated DNS zones if available
+if [ -d "$BUILD_DIR/generated/zones" ]; then
+ mkdir -p "$ROOTFS/etc/bind/zones"
+ cp -aL "$BUILD_DIR/generated/zones/." "$ROOTFS/etc/bind/zones/"
+fi
+
+# Copy generated WireGuard config for this host
+if [ -f "$BUILD_DIR/generated/wireguard/wg0-${HOST}.conf" ]; then
+ mkdir -p "$ROOTFS/etc/wireguard"
+ cp -L "$BUILD_DIR/generated/wireguard/wg0-${HOST}.conf" "$ROOTFS/etc/wireguard/wg0.conf"
+fi
+
+log "Merging package lists"
+PACKAGES=""
+[ -f "$COMMON_DIR/packages" ] && PACKAGES="$(grep -v '^#' "$COMMON_DIR/packages" | grep -v '^$' | tr '\n' ' ')"
+[ -f "$HOST_DIR/packages" ] && PACKAGES="$PACKAGES $(grep -v '^#' "$HOST_DIR/packages" | grep -v '^$' | tr '\n' ' ')"
+
+# Always include RPi boot firmware and u-boot
+PACKAGES="$PACKAGES raspberrypi-bootloader u-boot-raspberrypi"
+
+log "Installing packages: $PACKAGES"
+# Set up APK repositories and keys for the rootfs
+mkdir -p "$ROOTFS/etc/apk" "$ROOTFS/etc/apk/keys"
+echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/main" > "$ROOTFS/etc/apk/repositories"
+echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/community" >> "$ROOTFS/etc/apk/repositories"
+# Copy host APK keys so signatures are trusted
+cp /etc/apk/keys/* "$ROOTFS/etc/apk/keys/"
+
+# Initialize and install packages into rootfs
+# shellcheck disable=SC2086 # intentional word splitting
+apk add --root "$ROOTFS" --initdb --no-cache $PACKAGES
+
+log "Running setup scripts"
+# Concatenate and run setup scripts in chroot
+SETUP_SCRIPT=$(mktemp)
+cat > "$SETUP_SCRIPT" << 'HEADER'
+#!/bin/sh
+set -e
+HEADER
+[ -f "$COMMON_DIR/setup.sh" ] && cat "$COMMON_DIR/setup.sh" >> "$SETUP_SCRIPT"
+[ -f "$HOST_DIR/setup.sh" ] && cat "$HOST_DIR/setup.sh" >> "$SETUP_SCRIPT"
+chmod +x "$SETUP_SCRIPT"
+cp "$SETUP_SCRIPT" "$ROOTFS/tmp/setup.sh"
+
+# Ensure init scripts are executable
+find "$ROOTFS/etc/init.d" -type f -exec chmod +x {} \; 2>/dev/null || true
+
+# Run setup in chroot
+chroot "$ROOTFS" /tmp/setup.sh
+rm -f "$ROOTFS/tmp/setup.sh"
+
+log "Assembling boot partition"
+mkdir -p "$OUTPUT_DIR/boot"
+
+# RPi firmware files (from raspberrypi-bootloader)
+for f in bootcode.bin fixup.dat fixup4.dat start.elf start4.elf; do
+ [ -f "$ROOTFS/boot/$f" ] && cp "$ROOTFS/boot/$f" "$OUTPUT_DIR/boot/"
+done
+
+# U-Boot binary
+if [ -f "$ROOTFS/usr/share/u-boot/rpi_arm64/u-boot.bin" ]; then
+ cp "$ROOTFS/usr/share/u-boot/rpi_arm64/u-boot.bin" "$OUTPUT_DIR/boot/"
+fi
+
+# DTBs (RPi 4 specific + overlays)
+for dtb in "$ROOTFS"/boot/bcm2711*.dtb "$ROOTFS"/boot/bcm2712*.dtb; do
+ [ -f "$dtb" ] && cp "$dtb" "$OUTPUT_DIR/boot/"
+done
+if [ -d "$ROOTFS/boot/overlays" ]; then
+ cp -r "$ROOTFS/boot/overlays" "$OUTPUT_DIR/boot/"
+fi
+
+# Kernel
+[ -f "$ROOTFS/boot/vmlinuz-rpi" ] && cp "$ROOTFS/boot/vmlinuz-rpi" "$OUTPUT_DIR/boot/"
+
+# config.txt — use Alpine's default as base, add our customizations
+cat > "$OUTPUT_DIR/boot/config.txt" << 'EOF'
+[pi4]
+kernel=u-boot.bin
+enable_gic=1
+
+[pi5]
+kernel=u-boot.bin
+
+[all]
+arm_64bit=1
+enable_uart=1
+disable_overscan=1
+avoid_warnings=1
+
+# Boot kernel + initramfs via U-Boot
+# U-Boot reads extlinux/extlinux.conf automatically
+EOF
+
+# extlinux.conf for U-Boot
+mkdir -p "$OUTPUT_DIR/boot/extlinux"
+cat > "$OUTPUT_DIR/boot/extlinux/extlinux.conf" << EOF
+TIMEOUT 20
+DEFAULT alpine
+
+LABEL alpine
+ MENU LABEL Alpine Linux (diskless)
+ LINUX /vmlinuz-rpi
+ INITRD /initramfs
+ FDTDIR /
+ APPEND console=ttyS1,115200 console=tty1
+EOF
+
+log "Creating initramfs"
+# Remove boot files from rootfs (they go on the boot partition)
+rm -rf "${ROOTFS:?}/boot" "${ROOTFS:?}/usr/share/u-boot"
+
+cd "$ROOTFS"
+find . -print | cpio -o -H newc | gzip > "$OUTPUT_DIR/boot/initramfs"
+
+log "Build complete for $HOST"
+ls -lh "$OUTPUT_DIR/boot/"
+echo ""
+echo "Boot partition contents ready at: images/$HOST/boot/"
alpine/build.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Build an Alpine initramfs appliance for a given host.
+# Usage: ./alpine/build.sh <hostname>
+#
+# Runs inside a container (podman/docker). Merges common/ and hosts/<hostname>/
+# into a rootfs, installs packages, runs setup scripts, and produces
+# kernel + initramfs for RPi 4 boot.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+HOST="${1:?Usage: $0 <hostname>}"
+HOST_DIR="$SCRIPT_DIR/hosts/$HOST"
+OUTPUT_DIR="$SCRIPT_DIR/images/$HOST"
+CONTAINER_ENGINE="${CONTAINER_ENGINE:-podman}"
+
+if [ ! -d "$HOST_DIR" ]; then
+ echo "Error: host directory '$HOST_DIR' not found"
+ echo "Available hosts:"
+ ls "$SCRIPT_DIR/hosts/"
+ exit 1
+fi
+
+echo "==> Building Alpine appliance for $HOST (using $CONTAINER_ENGINE)"
+
+mkdir -p "$OUTPUT_DIR"
+
+# Resolve Nix store symlinks for generated files
+GENERATED_MOUNTS=""
+if [ -L "$SCRIPT_DIR/generated/zones" ]; then
+ REAL_ZONES=$(readlink -f "$SCRIPT_DIR/generated/zones")
+ GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_ZONES:$REAL_ZONES:ro"
+fi
+if [ -L "$SCRIPT_DIR/generated/wireguard" ]; then
+ REAL_WG=$(readlink -f "$SCRIPT_DIR/generated/wireguard")
+ GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_WG:$REAL_WG:ro"
+fi
+
+# Build inside Alpine container for aarch64
+# shellcheck disable=SC2086 # GENERATED_MOUNTS needs word splitting
+$CONTAINER_ENGINE run --rm \
+ --platform linux/arm64 \
+ -v "$SCRIPT_DIR":/build:Z \
+ $GENERATED_MOUNTS \
+ -w /tmp \
+ docker.io/library/alpine:3.21 \
+ /build/build-inner.sh "$HOST"
alpine/deploy.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Deploy an Alpine appliance image to a host.
+# Usage: ./alpine/deploy.sh <hostname> [update|flash]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+HOST="${1:?Usage: $0 <hostname> [update|flash]}"
+ACTION="${2:-update}"
+IMAGE_DIR="$SCRIPT_DIR/images/$HOST"
+
+if [ ! -d "$IMAGE_DIR" ]; then
+ echo "Error: no image found for $HOST. Run build.sh first."
+ exit 1
+fi
+
+case "$ACTION" in
+ update)
+ echo "==> Deploying to $HOST via rsync"
+ echo "WARNING: This will update the boot partition. The host will need a reboot."
+ read -p "Continue? [y/N] " -n 1 -r
+ echo
+ [[ $REPLY =~ ^[Yy]$ ]] || exit 0
+
+ # Backup current files (A/B rollback)
+ ssh "root@${HOST}.vpn" "cp /boot/initramfs /boot/initramfs.prev 2>/dev/null || true"
+ ssh "root@${HOST}.vpn" "cp /boot/vmlinuz-rpi /boot/vmlinuz-rpi.prev 2>/dev/null || true"
+
+ # Deploy new files
+ rsync -Pv "$IMAGE_DIR/initramfs" "root@${HOST}.vpn:/boot/initramfs"
+ rsync -Pv "$IMAGE_DIR/vmlinuz-rpi" "root@${HOST}.vpn:/boot/vmlinuz-rpi" 2>/dev/null || true
+
+ echo "==> Deployed. Reboot $HOST to activate:"
+ echo " ssh root@${HOST}.vpn reboot"
+ echo ""
+ echo " To rollback: ssh root@${HOST}.vpn"
+ echo " mount -o remount,rw /boot"
+ echo " mv /boot/initramfs.prev /boot/initramfs"
+ echo " reboot"
+ ;;
+ flash)
+ echo "==> SD card flashing"
+ echo "Manual steps:"
+ echo " 1. Partition SD: 256MB FAT32 (boot, label=ALPINE) + 512MB ext4 (label=persist)"
+ echo " 2. Copy boot files from $IMAGE_DIR/ to FAT32 partition"
+ echo " 3. Copy SSH host key to persist: persist/ssh/ssh_host_ed25519_key"
+ echo " 4. Copy WireGuard key to persist: persist/wireguard/private.key"
+ ;;
+ *)
+ echo "Unknown action: $ACTION"
+ echo "Usage: $0 <hostname> [update|flash]"
+ exit 1
+ ;;
+esac
alpine/flash.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+# Flash an Alpine appliance to an SD card for Raspberry Pi.
+# Usage: ./alpine/flash.sh <hostname> <device>
+#
+# Example: ./alpine/flash.sh athena /dev/sda
+#
+# This will:
+# 1. Partition the SD card (256MB FAT32 boot + 512MB ext4 persist)
+# 2. Copy boot files (kernel, initramfs, firmware, DTBs, U-Boot)
+# 3. Optionally copy secrets from the running host
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+HOST="${1:?Usage: $0 <hostname> <device>}"
+DEVICE="${2:?Usage: $0 <hostname> <device>}"
+BOOT_DIR="$SCRIPT_DIR/images/$HOST/boot"
+
+if [ ! -d "$BOOT_DIR" ]; then
+ echo "Error: no boot image found for $HOST at $BOOT_DIR"
+ echo "Run ./alpine/build.sh $HOST first."
+ exit 1
+fi
+
+if [ ! -b "$DEVICE" ]; then
+ echo "Error: $DEVICE is not a block device"
+ exit 1
+fi
+
+# Safety check
+echo "========================================"
+echo " WARNING: This will ERASE $DEVICE"
+echo "========================================"
+echo ""
+echo " Host: $HOST"
+echo " Device: $DEVICE"
+lsblk "$DEVICE" 2>/dev/null || true
+echo ""
+read -rp "Are you sure? Type 'yes' to continue: " CONFIRM
+[ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; }
+
+echo ""
+echo "==> Unmounting existing partitions on $DEVICE..."
+umount "${DEVICE}"* 2>/dev/null || true
+
+echo "==> Partitioning $DEVICE..."
+parted -s "$DEVICE" mklabel msdos
+parted -s "$DEVICE" mkpart primary fat32 1MiB 257MiB
+parted -s "$DEVICE" mkpart primary ext4 257MiB 769MiB
+parted -s "$DEVICE" set 1 boot on
+
+# Detect partition naming (sda1 vs mmcblk0p1)
+if [[ "$DEVICE" == *"mmcblk"* ]] || [[ "$DEVICE" == *"nvme"* ]]; then
+ PART1="${DEVICE}p1"
+ PART2="${DEVICE}p2"
+else
+ PART1="${DEVICE}1"
+ PART2="${DEVICE}2"
+fi
+
+echo "==> Formatting partitions..."
+mkfs.vfat -n ALPINE "$PART1"
+mkfs.ext4 -L persist -q "$PART2"
+
+# Mount partitions
+BOOT_MNT=$(mktemp -d)
+PERSIST_MNT=$(mktemp -d)
+trap 'umount "$BOOT_MNT" 2>/dev/null; umount "$PERSIST_MNT" 2>/dev/null; rmdir "$BOOT_MNT" "$PERSIST_MNT" 2>/dev/null' EXIT
+
+echo "==> Mounting partitions..."
+mount "$PART1" "$BOOT_MNT"
+mount "$PART2" "$PERSIST_MNT"
+
+echo "==> Copying boot files..."
+cp -v "$BOOT_DIR"/*.bin "$BOOT_MNT/" 2>/dev/null || true
+cp -v "$BOOT_DIR"/*.elf "$BOOT_MNT/" 2>/dev/null || true
+cp -v "$BOOT_DIR"/*.dat "$BOOT_MNT/" 2>/dev/null || true
+cp -v "$BOOT_DIR"/*.dtb "$BOOT_MNT/" 2>/dev/null || true
+cp -v "$BOOT_DIR"/vmlinuz-rpi "$BOOT_MNT/" 2>/dev/null || true
+cp -v "$BOOT_DIR"/initramfs "$BOOT_MNT/"
+cp -v "$BOOT_DIR"/config.txt "$BOOT_MNT/"
+[ -d "$BOOT_DIR/overlays" ] && cp -rv "$BOOT_DIR/overlays" "$BOOT_MNT/"
+[ -d "$BOOT_DIR/extlinux" ] && cp -rv "$BOOT_DIR/extlinux" "$BOOT_MNT/"
+
+echo "==> Preparing persist partition..."
+mkdir -p "$PERSIST_MNT/ssh"
+mkdir -p "$PERSIST_MNT/wireguard"
+
+# Try to copy secrets from the running host
+echo ""
+echo "==> Fetching secrets from running $HOST..."
+FETCHED_SECRETS=0
+
+if ssh -o ConnectTimeout=5 "root@${HOST}.vpn" true 2>/dev/null; then
+ echo " Connected to ${HOST}.vpn"
+
+ # SSH host key
+ if ssh "root@${HOST}.vpn" "cat /etc/ssh/ssh_host_ed25519_key" > "$PERSIST_MNT/ssh/ssh_host_ed25519_key" 2>/dev/null; then
+ ssh "root@${HOST}.vpn" "cat /etc/ssh/ssh_host_ed25519_key.pub" > "$PERSIST_MNT/ssh/ssh_host_ed25519_key.pub" 2>/dev/null
+ chmod 600 "$PERSIST_MNT/ssh/ssh_host_ed25519_key"
+ echo " ✓ SSH host key copied"
+ FETCHED_SECRETS=1
+ else
+ echo " ✗ Could not fetch SSH host key"
+ fi
+
+ # WireGuard private key
+ if ssh "root@${HOST}.vpn" "cat /etc/wireguard/private.key" > "$PERSIST_MNT/wireguard/private.key" 2>/dev/null; then
+ chmod 600 "$PERSIST_MNT/wireguard/private.key"
+ echo " ✓ WireGuard private key copied"
+ FETCHED_SECRETS=1
+ else
+ echo " ✗ Could not fetch WireGuard private key"
+ fi
+else
+ echo " Could not connect to ${HOST}.vpn"
+fi
+
+if [ "$FETCHED_SECRETS" -eq 0 ]; then
+ echo ""
+ echo " ⚠ No secrets fetched. You'll need to manually copy:"
+ echo " - SSH host key → $PERSIST_MNT/ssh/ssh_host_ed25519_key"
+ echo " - WireGuard key → $PERSIST_MNT/wireguard/private.key"
+fi
+
+echo ""
+echo "==> Syncing and unmounting..."
+sync
+
+echo ""
+echo "========================================"
+echo " ✓ SD card flashed for $HOST"
+echo "========================================"
+echo ""
+echo " Boot partition: $PART1 (FAT32, label=ALPINE)"
+echo " Persist partition: $PART2 (ext4, label=persist)"
+echo ""
+echo " Next steps:"
+echo " 1. Insert SD card into $HOST RPi"
+echo " 2. Power on"
+echo " 3. Verify: ssh root@${HOST}.vpn (or ssh root@<lan-ip>)"
+echo ""
+echo " Keep the old NixOS SD card as rollback!"
alpine/test.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env bash
+# Test an Alpine appliance image in QEMU.
+# Usage: ./alpine/test.sh <hostname> [stop|validate|build-qemu]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+HOST="${1:?Usage: $0 <hostname> [stop|validate]}"
+ACTION="${2:-start}"
+IMAGE_DIR="$SCRIPT_DIR/images/$HOST"
+QEMU_DIR="$IMAGE_DIR/qemu"
+MONITOR_SOCK="/tmp/qemu-alpine-${HOST}.sock"
+SSH_PORT="${QEMU_SSH_PORT:-2222}"
+CONTAINER_ENGINE="${CONTAINER_ENGINE:-podman}"
+
+if [ "$ACTION" = "stop" ]; then
+ if [ -S "$MONITOR_SOCK" ]; then
+ echo "quit" | socat - UNIX-CONNECT:"$MONITOR_SOCK" 2>/dev/null
+ echo "==> QEMU for $HOST stopped"
+ else
+ echo "No running QEMU found for $HOST, trying pkill..."
+ pkill -f "qemu.*alpine-${HOST}" 2>/dev/null || echo "Nothing to kill"
+ fi
+ exit 0
+fi
+
+if [ "$ACTION" = "validate" ]; then
+ echo "==> Validating $HOST (via SSH on localhost:$SSH_PORT)"
+ echo ""
+ SSH_CMD="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $SSH_PORT root@localhost"
+
+ run_check() {
+ local desc="$1"
+ shift
+ printf " %-45s" "$desc"
+ if output=$($SSH_CMD "$@" 2>/dev/null); then
+ echo "✓"
+ # shellcheck disable=SC2001
+ [ -n "${VERBOSE:-}" ] && echo "$output" | sed 's/^/ /'
+ return 0
+ else
+ echo "✗"
+ # shellcheck disable=SC2001
+ [ -n "${VERBOSE:-}" ] && echo "$output" | sed 's/^/ /'
+ return 1
+ fi
+ }
+
+ FAILURES=0
+
+ # Basic system checks
+ run_check "SSH access" "echo ok" || FAILURES=$((FAILURES + 1))
+ run_check "Hostname is $HOST" "test \$(hostname) = $HOST" || FAILURES=$((FAILURES + 1))
+ run_check "OpenRC running" "rc-status --nocolor" || FAILURES=$((FAILURES + 1))
+
+ # Service checks
+ run_check "sshd running" "rc-service sshd status" || FAILURES=$((FAILURES + 1))
+ run_check "chronyd running" "rc-service chronyd status" || FAILURES=$((FAILURES + 1))
+ run_check "crond running" "rc-service crond status" || FAILURES=$((FAILURES + 1))
+ run_check "node-exporter running" "rc-service node-exporter status" || FAILURES=$((FAILURES + 1))
+
+ # Host-specific checks
+ case "$HOST" in
+ athena|demeter)
+ run_check "named running" "rc-service named status" || FAILURES=$((FAILURES + 1))
+ run_check "BIND config valid" "named-checkconf /etc/bind/named.conf" || FAILURES=$((FAILURES + 1))
+ if [ -d "$SCRIPT_DIR/generated/zones" ]; then
+ run_check "DNS zones present" "ls /etc/bind/zones/db.* >/dev/null" || FAILURES=$((FAILURES + 1))
+ run_check "DNS resolves 'home'" "dig @127.0.0.1 home SOA +short" || FAILURES=$((FAILURES + 1))
+ else
+ echo " DNS zones not generated (skipping zone checks)"
+ fi
+ ;;&
+ demeter)
+ run_check "mosquitto running" "rc-service mosquitto status" || FAILURES=$((FAILURES + 1))
+ ;;
+ aix)
+ run_check "samba running" "rc-service samba status" || FAILURES=$((FAILURES + 1))
+ run_check "dnsmasq running" "rc-service dnsmasq status" || FAILURES=$((FAILURES + 1))
+ ;;
+ esac
+
+ # Secrets init script (will fail without persist partition, that's OK)
+ run_check "secrets init script exists" "test -x /etc/init.d/secrets" || FAILURES=$((FAILURES + 1))
+
+ # Auth
+ run_check "root authorized_keys exists" "test -s /root/.ssh/authorized_keys" || FAILURES=$((FAILURES + 1))
+ run_check "vincent authorized_keys exists" "test -s /home/vincent/.ssh/authorized_keys" || FAILURES=$((FAILURES + 1))
+
+ # Network config
+ run_check "WireGuard config exists" "test -f /etc/wireguard/wg0.conf" || FAILURES=$((FAILURES + 1))
+
+ # Prometheus endpoints
+ run_check "node-exporter responds" "wget -qO- http://127.0.0.1:9100/metrics | head -1" || FAILURES=$((FAILURES + 1))
+
+ echo ""
+ if [ "$FAILURES" -eq 0 ]; then
+ echo " ✓ All checks passed!"
+ else
+ echo " ✗ $FAILURES check(s) failed"
+ fi
+ exit "$FAILURES"
+fi
+
+# --- Build QEMU-specific initramfs ---
+# The production image uses linux-rpi (no virtio drivers).
+# For QEMU we need linux-virt + DHCP networking.
+build_qemu_image() {
+ echo "==> Building QEMU test image for $HOST..."
+ mkdir -p "$QEMU_DIR"
+
+ # Resolve Nix store symlinks
+ GENERATED_MOUNTS=""
+ if [ -L "$SCRIPT_DIR/generated/zones" ]; then
+ REAL_ZONES=$(readlink -f "$SCRIPT_DIR/generated/zones")
+ GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_ZONES:$REAL_ZONES:ro"
+ fi
+ if [ -L "$SCRIPT_DIR/generated/wireguard" ]; then
+ REAL_WG=$(readlink -f "$SCRIPT_DIR/generated/wireguard")
+ GENERATED_MOUNTS="$GENERATED_MOUNTS -v $REAL_WG:$REAL_WG:ro"
+ fi
+
+ # shellcheck disable=SC2086,SC2016
+ $CONTAINER_ENGINE run --rm \
+ --platform linux/arm64 \
+ -v "$SCRIPT_DIR":/build:Z \
+ $GENERATED_MOUNTS \
+ -e HOST="$HOST" \
+ -w /tmp \
+ docker.io/library/alpine:3.21 \
+ sh -c '
+set -e
+HOST="$1"
+BUILD_DIR="/build"
+COMMON_DIR="$BUILD_DIR/common"
+HOST_DIR="$BUILD_DIR/hosts/$HOST"
+QEMU_DIR="$BUILD_DIR/images/$HOST/qemu"
+
+apk add --no-cache cpio gzip findutils >/dev/null
+
+ROOTFS=$(mktemp -d)
+
+# Copy skeletons (same as production)
+[ -d "$COMMON_DIR/root" ] && cp -a "$COMMON_DIR/root/." "$ROOTFS/"
+[ -d "$HOST_DIR/root" ] && cp -a "$HOST_DIR/root/." "$ROOTFS/"
+
+# DNS zones
+if [ -d "$BUILD_DIR/generated/zones" ]; then
+ mkdir -p "$ROOTFS/etc/bind/zones"
+ cp -aL "$BUILD_DIR/generated/zones/." "$ROOTFS/etc/bind/zones/"
+fi
+
+# Override network config for QEMU (DHCP)
+cat > "$ROOTFS/etc/network/interfaces" << IFACES
+auto lo
+iface lo inet loopback
+
+auto eth0
+iface eth0 inet dhcp
+IFACES
+
+# Merge packages — replace linux-rpi with linux-virt
+PACKAGES=""
+[ -f "$COMMON_DIR/packages" ] && PACKAGES="$(grep -v "^#" "$COMMON_DIR/packages" | grep -v "^$" | grep -v "linux-rpi" | tr "\n" " ")"
+[ -f "$HOST_DIR/packages" ] && PACKAGES="$PACKAGES $(grep -v "^#" "$HOST_DIR/packages" | grep -v "^$" | tr "\n" " ")"
+PACKAGES="$PACKAGES linux-virt"
+
+# Install
+mkdir -p "$ROOTFS/etc/apk" "$ROOTFS/etc/apk/keys"
+echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/main" > "$ROOTFS/etc/apk/repositories"
+echo "https://dl-cdn.alpinelinux.org/alpine/v3.21/community" >> "$ROOTFS/etc/apk/repositories"
+cp /etc/apk/keys/* "$ROOTFS/etc/apk/keys/"
+apk add --root "$ROOTFS" --initdb --no-cache $PACKAGES
+
+# Run setup
+SETUP=$(mktemp)
+echo "#!/bin/sh" > "$SETUP"
+echo "set -e" >> "$SETUP"
+[ -f "$COMMON_DIR/setup.sh" ] && cat "$COMMON_DIR/setup.sh" >> "$SETUP"
+[ -f "$HOST_DIR/setup.sh" ] && cat "$HOST_DIR/setup.sh" >> "$SETUP"
+chmod +x "$SETUP"
+cp "$SETUP" "$ROOTFS/tmp/setup.sh"
+find "$ROOTFS/etc/init.d" -type f -exec chmod +x {} \; 2>/dev/null || true
+chroot "$ROOTFS" /tmp/setup.sh
+rm -f "$ROOTFS/tmp/setup.sh"
+
+# Extract kernel
+cp "$ROOTFS/boot/vmlinuz-virt" "$QEMU_DIR/"
+rm -rf "$ROOTFS/boot" "$ROOTFS/usr/share/u-boot"
+
+# Create initramfs
+cd "$ROOTFS"
+find . -print | cpio -o -H newc | gzip > "$QEMU_DIR/initramfs"
+
+echo "QEMU image ready"
+ls -lh "$QEMU_DIR/"
+' -- "$HOST"
+}
+
+# --- Start QEMU ---
+if [ ! -f "$QEMU_DIR/initramfs" ] || [ ! -f "$QEMU_DIR/vmlinuz-virt" ]; then
+ build_qemu_image
+fi
+
+# Rebuild if production image is newer
+if [ "$IMAGE_DIR/boot/initramfs" -nt "$QEMU_DIR/initramfs" ] 2>/dev/null; then
+ echo "==> Production image is newer, rebuilding QEMU image..."
+ build_qemu_image
+fi
+
+echo "==> Testing $HOST in QEMU"
+echo " SSH: ssh -p $SSH_PORT -o StrictHostKeyChecking=no root@localhost"
+echo " Console: root / alpine"
+echo " Stop: ./alpine/test.sh $HOST stop"
+echo " Validate: ./alpine/test.sh $HOST validate (in another terminal)"
+echo ""
+qemu-system-aarch64 -M virt -cpu cortex-a72 -m 1G -nographic \
+ -monitor unix:"$MONITOR_SOCK",server,nowait \
+ -kernel "$QEMU_DIR/vmlinuz-virt" \
+ -initrd "$QEMU_DIR/initramfs" \
+ -netdev user,id=net0,hostfwd=tcp::"$SSH_PORT"-:22 \
+ -device virtio-net-pci,netdev=net0 \
+ -append "console=ttyAMA0 rdinit=/sbin/init"
Makefile
@@ -146,6 +146,34 @@ dns-update-gandi: ## Update Gandi DNS records
dns-update-gandi-dry-run: ## Dry-run Gandi DNS update
@bash tools/update-gandi-dns.sh --dry-run
+##@ Alpine Diskless Appliances
+
+.PHONY: alpine/generate
+alpine/generate: ## Generate DNS zones and WireGuard configs from Nix
+ nix build .#dns-zones -o alpine/generated/zones
+ nix build .#wg-configs -o alpine/generated/wireguard
+
+alpine/build/%: alpine/generate ## Build Alpine appliance (e.g. alpine/build/athena)
+ ./alpine/build.sh $*
+
+alpine/deploy/%: ## Deploy Alpine appliance to host (e.g. alpine/deploy/athena)
+ ./alpine/deploy.sh $* update
+
+alpine/test/%: ## Test Alpine appliance in QEMU (e.g. alpine/test/athena)
+ ./alpine/test.sh $*
+
+alpine/stop/%: ## Stop QEMU test for a host (e.g. alpine/stop/athena)
+ ./alpine/test.sh $* stop
+
+alpine/validate/%: ## Validate running QEMU test (e.g. alpine/validate/athena)
+ ./alpine/test.sh $* validate
+
+alpine/flash/%: ## Flash SD card for a host (e.g. alpine/flash/athena DEVICE=/dev/sda)
+ ./alpine/flash.sh $* $(DEVICE)
+
+.PHONY: alpine/all
+alpine/all: alpine/build/athena alpine/build/demeter alpine/build/aix ## Build all Alpine appliances
+
##@ Maintenance
.PHONY: clean