Commit c7a956d2fe77

Vincent Demeester <vincent@sbr.pm>
2026-06-10 14:38:30
feat: alpine diskless appliance for athena
Added frood-style Alpine Linux diskless build system for RPi 4 appliances. Athena (DNS server) is the first host, with full build, test (QEMU), deploy, and flash tooling. - Build via podman (aarch64 cross-compile) with U-Boot + RPi firmware - Nix derivations for DNS zones and WireGuard configs (flake.nix) - QEMU testing with linux-virt, SSH validation, and automated checks - Flash script with auto-fetch of secrets from running host - Persist partition for SSH host keys and WireGuard private key - age secrets decryption at boot via OpenRC init script
1 parent 236c3d0
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/root/home/vincent/.ssh/authorized_keys
@@ -0,0 +1,13 @@
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFT5Rx+4Wuvd8lMBkcHxb4oHdRhm/OTg+p5tvPzoIN9enSmgRw5Inm/SlS8ZzV87G1NESTgzDRi6hREvqDlKvxs=
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGHMa4rHuBbQQYv+8jvlkFCD2VYRGA4+5fnZAhLx8iDirzfEPqHB60UJWcDeixnJCUlpJjzFbS4crNOXhfCTCTE=
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBFzxC16VqwTgWDQfw2YCiOw2JzpH3z9XgHtKoHhBdHi2i9m9XUc7fIUeEIIf7P8ARRNd8q5bjvl8JY7LtPkNCU=
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIODTc5Exm59skgJdu6/rA3CpX4k4P1CFBqCFtelWGGmEAAAAC3NzaDpob21lbGFi homelab-servers
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAGh5p44LvQrWjAMyC/5LjUnViqFl3ddVfiFnoiLgJb7AAAAEnNzaDpjcml0aWNhbC1pbmZyYQ== infra-touch-required
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEefW7gStvkrO98v6UUawwa3yOu896Ei8USE/Sh2DjaUAAAABHNzaDo= vincent@okinawa
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqhe3iS2058Ro8jN0b5Sr1tb+fEyqwgEmEC7vCM0za4 vincent@kyushu-passage
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/CgISZ5XVyg8eYH7b56EWo4UGplOzzZKdtMKtkZqxc vincent@okinawa-passage
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILJmTdMKYdgqpbQWBif58VBuwX+GqMGsMfB1ey1TKrM3 vincent@aomi
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE3iD9Eaf5xglTyP+kIO9t8qQpF2H42rQ2AuUibs2hn7 vincent@aion
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGThdcaPfIaB7d+K5uODqEusLKGI5ZCye0aNOCaMoInO Kyushu's ssh key
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICa0SyAspL7PBPudCjb7oCBG17WRmYnDQF7/BYkFwqDi oksaka-termux
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0Q1oXzMMJG03n2VoxKKOGruyGBy8V8yanqgeeUYm+N suzu-termux
alpine/common/root/root/.ssh/authorized_keys
@@ -0,0 +1,13 @@
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFT5Rx+4Wuvd8lMBkcHxb4oHdRhm/OTg+p5tvPzoIN9enSmgRw5Inm/SlS8ZzV87G1NESTgzDRi6hREvqDlKvxs=
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGHMa4rHuBbQQYv+8jvlkFCD2VYRGA4+5fnZAhLx8iDirzfEPqHB60UJWcDeixnJCUlpJjzFbS4crNOXhfCTCTE=
+ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBFzxC16VqwTgWDQfw2YCiOw2JzpH3z9XgHtKoHhBdHi2i9m9XUc7fIUeEIIf7P8ARRNd8q5bjvl8JY7LtPkNCU=
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIODTc5Exm59skgJdu6/rA3CpX4k4P1CFBqCFtelWGGmEAAAAC3NzaDpob21lbGFi homelab-servers
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAGh5p44LvQrWjAMyC/5LjUnViqFl3ddVfiFnoiLgJb7AAAAEnNzaDpjcml0aWNhbC1pbmZyYQ== infra-touch-required
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIEefW7gStvkrO98v6UUawwa3yOu896Ei8USE/Sh2DjaUAAAABHNzaDo= vincent@okinawa
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBqhe3iS2058Ro8jN0b5Sr1tb+fEyqwgEmEC7vCM0za4 vincent@kyushu-passage
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/CgISZ5XVyg8eYH7b56EWo4UGplOzzZKdtMKtkZqxc vincent@okinawa-passage
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILJmTdMKYdgqpbQWBif58VBuwX+GqMGsMfB1ey1TKrM3 vincent@aomi
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE3iD9Eaf5xglTyP+kIO9t8qQpF2H42rQ2AuUibs2hn7 vincent@aion
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGThdcaPfIaB7d+K5uODqEusLKGI5ZCye0aNOCaMoInO Kyushu's ssh key
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICa0SyAspL7PBPudCjb7oCBG17WRmYnDQF7/BYkFwqDi oksaka-termux
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0Q1oXzMMJG03n2VoxKKOGruyGBy8V8yanqgeeUYm+N suzu-termux
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