Skip to content

Instantly share code, notes, and snippets.

@JeffreyVdb
Last active May 26, 2026 09:29
Show Gist options
  • Select an option

  • Save JeffreyVdb/a10a5de14b5e6d22a5ab1043dd2eff5e to your computer and use it in GitHub Desktop.

Select an option

Save JeffreyVdb/a10a5de14b5e6d22a5ab1043dd2eff5e to your computer and use it in GitHub Desktop.
Ubuntu 26.04 LTS ARM64 BTRFS-on-LUKS2 remote installer for netcup/VPS — debootstrap + dropbear-initramfs remote LUKS unlock on :2222, snapper, BTRFS subvols. Run inside GRML rescue.
#!/usr/bin/env bash
# install.sh — Ubuntu 26.04 LTS ARM64 BTRFS-on-LUKS2 remote installer
# See docs/superpowers/specs/2026-05-25-ubuntu-26.04-btrfs-luks-install-design.md
#
# Run inside the GRML rescue on your target host (netcup ARM64 VPS or similar).
# All example IPs/hostnames in this script are RFC 5737/3849 documentation
# placeholders — replace with real values for your environment, or rely on the
# auto-detection in phase_pre_chroot.
set -Eeuo pipefail
shopt -s inherit_errexit
IFS=$'\n\t'
umask 077
ulimit -c 0 # no core dumps — LUKS_PASS lives in process memory
# --- globals ----------------------------------------------------------------
DRY_RUN=0
TARGET_DISK="/dev/vda"
SWAP_SIZE="8G"
LOG_FILE=""
# Populated by phase_prompts.
# SECURITY: LUKS_PASS must never flow through `log`, `run`, or `run_sh` —
# those echo their args. Pipe it directly to cryptsetup via `printf '%s' |`
# instead. Confirm any new code path before adding `set -x` for debugging.
HOSTNAME_VAL=""
TIMEZONE_VAL="Europe/Amsterdam"
USERNAME_VAL=""
USER_SHELL=""
SSH_PUBKEY=""
LUKS_PASS=""
# --- helpers ----------------------------------------------------------------
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
err() {
printf '[%s] ERROR: %s\n' "$(date +%H:%M:%S)" "$*" >&2
}
run() {
if [[ $DRY_RUN -eq 1 ]]; then
printf '[DRY] %s\n' "$*"
return 0
fi
log "+ $*"
"$@"
}
# Like `run` but for a whole shell pipeline given as a single string.
run_sh() {
if [[ $DRY_RUN -eq 1 ]]; then
printf '[DRY] sh -c %q\n' "$*"
return 0
fi
log "+ sh -c $*"
bash -c "$*"
}
# Write file contents to a path (or just preview in dry-run).
write_file() {
local path="$1" mode="${2:-0644}"
if [[ $DRY_RUN -eq 1 ]]; then
printf '[DRY] write %s (mode %s):\n' "$path" "$mode"
sed 's/^/[DRY] | /'
return 0
fi
install -D -m "$mode" /dev/stdin "$path"
}
# Run a command string inside the target chroot with a clean env.
chroot_run() {
if [[ $DRY_RUN -eq 1 ]]; then
printf '[DRY] chroot /mnt sh -c %q\n' "$*"
return 0
fi
log "+ chroot /mnt $*"
chroot /mnt /usr/bin/env -i \
HOME=/root TERM="${TERM:-xterm-256color}" \
PATH=/usr/sbin:/usr/bin:/sbin:/bin \
DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
/bin/bash -c "$*"
}
bind_chroot_mounts() {
run mount --bind /dev /mnt/dev
run mount --bind /dev/pts /mnt/dev/pts
run mount -t proc proc /mnt/proc
run mount -t sysfs sys /mnt/sys
run mount --bind /run /mnt/run
}
unbind_chroot_mounts() {
for m in /mnt/run /mnt/sys /mnt/proc /mnt/dev/pts /mnt/dev; do
if mountpoint -q "$m" 2>/dev/null; then
run umount -l "$m"
fi
done
}
# --- usage ------------------------------------------------------------------
usage() {
cat <<'EOF'
install.sh — Ubuntu 26.04 ARM64 BTRFS-on-LUKS2 installer
USAGE
install.sh [--dry-run] [--swap-size SIZE] [--target-disk DEV] [--help]
OPTIONS
--dry-run Skip every destructive operation. Prompts run, config
files are previewed via stdout. Safe to run anywhere.
--swap-size SIZE Override default swapfile size. Default: 8G.
Accepts any fallocate(1) -l size (e.g. 1G, 16G).
--target-disk DEV Override default target. Default: /dev/vda.
--help Show this help.
The script must run as root inside the GRML rescue on this host. It refuses
to run anywhere else. See README.md for the full deploy procedure.
EOF
}
parse_args() {
while (($#)); do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--swap-size) shift; SWAP_SIZE="$1" ;;
--target-disk) shift; TARGET_DISK="$1" ;;
-h|--help) usage; exit 0 ;;
*) err "unknown arg: $1"; usage; exit 2 ;;
esac
shift
done
}
# --- traps ------------------------------------------------------------------
on_error() {
local rc=$? line=$1
err "failed at line $line (exit $rc)"
[[ -n $LOG_FILE ]] && err "log: $LOG_FILE"
}
cleanup() {
# Best-effort. Each step ignores errors silently.
if [[ $DRY_RUN -eq 0 ]]; then
swapoff -a 2>/dev/null || true
if mountpoint -q /mnt 2>/dev/null; then
umount -R /mnt 2>/dev/null || true
fi
if [[ -b /dev/mapper/cryptroot ]]; then
cryptsetup close cryptroot 2>/dev/null || true
fi
fi
}
trap 'on_error ${LINENO}' ERR
trap cleanup EXIT
# --- phase stubs (filled in by later tasks) ---------------------------------
phase_preflight() {
log "preflight: checking environment"
[[ $EUID -eq 0 ]] || { err "must run as root"; exit 1; }
local arch; arch=$(uname -m)
[[ $arch == aarch64 ]] || { err "ARM64 required, got: $arch"; exit 1; }
[[ -d /sys/firmware/efi ]] || {
err "UEFI required (no /sys/firmware/efi). BIOS not supported."
exit 1
}
[[ -b $TARGET_DISK ]] || { err "target disk not found: $TARGET_DISK"; exit 1; }
if ! grep -q "live-media-path=/live/grml" /proc/cmdline; then
err "this script must run inside the GRML rescue (not on a real install)"
err "/proc/cmdline does not contain live-media-path=/live/grml"
exit 1
fi
if ! ping -c1 -W3 ports.ubuntu.com >/dev/null 2>&1; then
err "cannot reach ports.ubuntu.com — fix network first"
exit 1
fi
log "preflight: ok (root, aarch64, UEFI, $TARGET_DISK present, GRML rescue, net up)"
}
phase_deps() {
log "deps: installing rescue-side packages"
local pkgs=(
debootstrap ubuntu-keyring
cryptsetup-bin
btrfs-progs
gdisk dosfstools parted
arch-test qemu-user-static
curl ca-certificates
)
run apt-get update -qq
run apt-get install -y --no-install-recommends "${pkgs[@]}"
if ! command -v gum >/dev/null 2>&1; then
log "deps: fetching gum (charmbracelet) static arm64 binary"
local gum_ver="0.14.5"
# SHA256 from the upstream checksums.txt for v0.14.5. Pinned so a
# compromised GitHub release (or MITM with a custom CA) cannot ship
# a malicious gum that would later read the prompted LUKS pass.
local gum_sha256="d062b4b2934f26ccb4c2ed31c6db19fa3f011d969e366020b39bc0934cdd00e2"
local url="https://github.com/charmbracelet/gum/releases/download/v${gum_ver}/gum_${gum_ver}_Linux_arm64.tar.gz"
local tmp; tmp=$(mktemp -d)
run curl -fsSL -o "$tmp/gum.tgz" "$url"
if [[ $DRY_RUN -eq 0 ]]; then
echo "${gum_sha256} ${tmp}/gum.tgz" | sha256sum -c - >/dev/null || {
err "gum tarball SHA256 mismatch — refusing to install"
exit 1
}
fi
run tar -C "$tmp" -xzf "$tmp/gum.tgz"
run install -m 0755 "$tmp"/gum_*/gum /usr/local/bin/gum
run rm -rf "$tmp"
fi
[[ $DRY_RUN -eq 1 ]] || gum --version >/dev/null
log "deps: ok"
}
phase_prompts() {
log "prompts: collecting install parameters"
local default_hostname; default_hostname=$(hostname -f 2>/dev/null || hostname)
[[ -n $default_hostname ]] || default_hostname="ubuntu"
if [[ $DRY_RUN -eq 1 ]]; then
HOSTNAME_VAL="$default_hostname"
TIMEZONE_VAL="Europe/Amsterdam"
USERNAME_VAL="installer"
USER_SHELL="fish"
SSH_PUBKEY="ssh-ed25519 AAAA...DRYRUN dryrun@example"
LUKS_PASS="dryrun-passphrase"
log "prompts: dry-run defaults applied"
return 0
fi
HOSTNAME_VAL=$(gum input --prompt "hostname > " --value "$default_hostname")
[[ -n $HOSTNAME_VAL ]] || { err "hostname required"; exit 1; }
TIMEZONE_VAL=$(gum input --prompt "timezone > " --value "Europe/Amsterdam")
# Reject anything that is not a real zoneinfo entry. Without this, the
# value lands inside `chroot_run "ln -sf .../$TIMEZONE_VAL ..."` which
# is a bash -c string — a stray quote or `;` would execute as shell.
[[ -n $TIMEZONE_VAL && -e "/usr/share/zoneinfo/$TIMEZONE_VAL" ]] || {
err "invalid timezone: $TIMEZONE_VAL (must exist under /usr/share/zoneinfo)"
exit 1
}
USERNAME_VAL=$(gum input --prompt "username > " --placeholder "lowercase, letters/digits/_-")
[[ $USERNAME_VAL =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || {
err "invalid username: $USERNAME_VAL"
exit 1
}
USER_SHELL=$(gum choose --header "Login shell:" bash zsh fish)
gum style --foreground 212 "Paste the SSH public key for $USERNAME_VAL (used for both LUKS unlock and login):"
SSH_PUBKEY=$(gum input --placeholder "ssh-ed25519 AAAA... user@host" --width 120)
# Strip CR (Windows paste) + leading/trailing whitespace. dropbear-initramfs
# validates the file with a strict regex anchored at line start.
SSH_PUBKEY=$(printf '%s' "$SSH_PUBKEY" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if ! ssh-keygen -l -f /dev/stdin <<<"$SSH_PUBKEY" >/dev/null 2>&1; then
err "invalid SSH public key (ssh-keygen could not parse)"
exit 1
fi
# dropbear-initramfs only accepts these key types. Reject early.
local dropbear_re='^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-(ed25519|ecdsa-sha2-nistp256)@openssh\.com) '
if ! printf '%s\n' "$SSH_PUBKEY" | grep -qE "$dropbear_re"; then
err "pubkey type not accepted by dropbear-initramfs"
err "supported: ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp{256,384,521}, sk-ssh-* (FIDO)"
exit 1
fi
local p1 p2
while :; do
p1=$(gum input --password --prompt "LUKS passphrase > ")
p2=$(gum input --password --prompt "LUKS passphrase (again) > ")
if [[ -z $p1 ]]; then
err "passphrase cannot be empty"
continue
fi
if [[ $p1 != "$p2" ]]; then
err "passphrases do not match, try again"
continue
fi
if (( ${#p1} < 12 )); then
err "passphrase too short (< 12 chars)"
continue
fi
LUKS_PASS="$p1"
unset p1 p2
break
done
log "prompts: summary"
log " host=$HOSTNAME_VAL tz=$TIMEZONE_VAL"
log " user=$USERNAME_VAL shell=$USER_SHELL disk=$TARGET_DISK swap=$SWAP_SIZE"
log " pubkey=$(ssh-keygen -l -f /dev/stdin <<<"$SSH_PUBKEY")"
gum style --border double --padding "1 2" --foreground 196 \
"ABOUT TO WIPE $TARGET_DISK (all data destroyed)"
local confirm
confirm=$(gum input --prompt "type WIPE to continue > ")
[[ $confirm == "WIPE" ]] || { err "aborted by operator"; exit 1; }
}
phase_partition() {
log "partition: laying out GPT on $TARGET_DISK"
run sgdisk --zap-all "$TARGET_DISK"
run wipefs -a "$TARGET_DISK"
run sgdisk -n1:0:+1G -t1:ef00 -c1:ESP "$TARGET_DISK"
run sgdisk -n2:0:+1G -t2:8300 -c2:boot "$TARGET_DISK"
run sgdisk -n3:0:0 -t3:8309 -c3:cryptroot "$TARGET_DISK"
run partprobe "$TARGET_DISK"
run udevadm settle
log "partition: ok"
}
phase_luks() {
log "luks: formatting /dev/vda3 as LUKS2 (argon2id)"
local luks_dev="${TARGET_DISK}3"
if [[ $DRY_RUN -eq 1 ]]; then
# shellcheck disable=SC2016 # intentional: literal '$LUKS_PASS' in dry-run output
printf '[DRY] cryptsetup luksFormat --type luks2 --cipher aes-xts-plain64 --key-size 512 --hash sha512 --pbkdf argon2id --batch-mode --key-file=- %s <<< "$LUKS_PASS"\n' "$luks_dev"
# shellcheck disable=SC2016 # intentional: literal '$LUKS_PASS' in dry-run output
printf '[DRY] cryptsetup open --key-file=- %s cryptroot <<< "$LUKS_PASS"\n' "$luks_dev"
return 0
fi
printf '%s' "$LUKS_PASS" | cryptsetup luksFormat \
--type luks2 \
--cipher aes-xts-plain64 \
--key-size 512 \
--hash sha512 \
--pbkdf argon2id \
--batch-mode \
--key-file=- \
"$luks_dev"
printf '%s' "$LUKS_PASS" | cryptsetup open \
--key-file=- \
"$luks_dev" cryptroot
log "luks: /dev/mapper/cryptroot opened"
}
phase_filesystems() {
log "filesystems: creating ESP/boot/root filesystems"
run mkfs.fat -F32 -n ESP "${TARGET_DISK}1"
run mkfs.ext4 -F -L boot "${TARGET_DISK}2"
run mkfs.btrfs -f -L root /dev/mapper/cryptroot
local top; top=$(mktemp -d /tmp/btrfs-top.XXXXXX)
run mount /dev/mapper/cryptroot "$top"
run btrfs subvolume create "$top/@"
run btrfs subvolume create "$top/@home"
run btrfs subvolume create "$top/@snapshots"
run btrfs subvolume create "$top/@swap"
# Make @ the default subvolume so the kernel mounts it even when
# rootflags=subvol=@ is absent from the cmdline (belt + suspenders).
# Without this, the initramfs mount lands on the top-level (id 5)
# which only contains the four subvols as directories — no /sbin/init,
# and run-init aborts with "Target filesystem doesn't have /sbin/init".
if [[ $DRY_RUN -eq 0 ]]; then
local at_id
at_id=$(btrfs subvolume list "$top" | awk '$NF=="@"{print $2}')
run btrfs subvolume set-default "$at_id" "$top"
else
printf '[DRY] btrfs subvolume set-default <@-id> %s\n' "$top"
fi
run umount "$top"
run rmdir "$top"
log "filesystems: ok"
}
phase_mount() {
log "mount: assembling /mnt"
local opts="defaults,compress=zstd:3,ssd,noatime,space_cache=v2"
local swapopts="noatime"
run mount -o "${opts},subvol=@" /dev/mapper/cryptroot /mnt
run mkdir -p /mnt/{home,.snapshots,swap,boot}
run mount -o "${opts},subvol=@home" /dev/mapper/cryptroot /mnt/home
run mount -o "${opts},subvol=@snapshots" /dev/mapper/cryptroot /mnt/.snapshots
run mount -o "${swapopts},subvol=@swap" /dev/mapper/cryptroot /mnt/swap
run mount "${TARGET_DISK}2" /mnt/boot
run mkdir -p /mnt/boot/efi
run mount "${TARGET_DISK}1" /mnt/boot/efi
log "mount: ok"
}
phase_debootstrap() {
log "debootstrap: installing resolute (Ubuntu 26.04) arm64 base"
run debootstrap \
--arch=arm64 \
--variant=minbase \
--include=ca-certificates,locales,tzdata \
resolute /mnt http://ports.ubuntu.com/ubuntu-ports
log "debootstrap: ok"
}
phase_pre_chroot() {
log "pre-chroot: generating config files in /mnt"
local esp_uuid boot_uuid root_uuid
if [[ $DRY_RUN -eq 0 ]]; then
esp_uuid=$(blkid -s UUID -o value "${TARGET_DISK}1")
boot_uuid=$(blkid -s UUID -o value "${TARGET_DISK}2")
root_uuid=$(blkid -s UUID -o value "${TARGET_DISK}3")
else
esp_uuid="DRY-ESP-UUID"
boot_uuid="DRY-BOOT-UUID"
root_uuid="DRY-ROOT-UUID"
fi
# Detect current static net config to bake into target
local v4_addr v4_gw v4_mask v6_addr v4_cidr
if [[ $DRY_RUN -eq 0 ]]; then
v4_addr=$(ip -4 -o addr show dev eth0 | awk '{print $4}' | head -1) # x.x.x.x/NN
v4_gw=$(ip -4 route show default | awk '{print $3}' | head -1)
v6_addr=$(ip -6 -o addr show dev eth0 scope global | awk '{print $4}' | head -1)
else
# RFC 5737 (v4) / RFC 3849 (v6) documentation placeholders.
v4_addr="192.0.2.10/24"
v4_gw="192.0.2.1"
v6_addr="2001:db8::10/64"
fi
local v4_ip="${v4_addr%/*}"
v4_cidr="${v4_addr##*/}"
# CIDR -> dotted-quad netmask in pure bash (no ipcalc/python required)
local _m=$(( 0xffffffff << (32 - v4_cidr) & 0xffffffff ))
v4_mask=$(printf '%d.%d.%d.%d' \
$(( _m >> 24 & 0xff )) $(( _m >> 16 & 0xff )) \
$(( _m >> 8 & 0xff )) $(( _m & 0xff )))
# /etc/hostname
write_file /mnt/etc/hostname 0644 <<<"$HOSTNAME_VAL"
# /etc/hosts
write_file /mnt/etc/hosts 0644 <<EOF
127.0.0.1 localhost
127.0.1.1 $HOSTNAME_VAL
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
EOF
# /etc/fstab
write_file /mnt/etc/fstab 0644 <<EOF
# <file system> <mount> <type> <options> <dump> <pass>
/dev/mapper/cryptroot / btrfs defaults,compress=zstd:3,ssd,noatime,space_cache=v2,subvol=@ 0 0
/dev/mapper/cryptroot /home btrfs defaults,compress=zstd:3,ssd,noatime,space_cache=v2,subvol=@home 0 0
/dev/mapper/cryptroot /.snapshots btrfs defaults,compress=zstd:3,ssd,noatime,space_cache=v2,subvol=@snapshots 0 0
/dev/mapper/cryptroot /swap btrfs noatime,subvol=@swap 0 0
UUID=$boot_uuid /boot ext4 defaults,noatime 0 2
UUID=$esp_uuid /boot/efi vfat umask=0077 0 2
/swap/swapfile none swap defaults 0 0
EOF
# /etc/crypttab — initramfs flag ensures early unlock
write_file /mnt/etc/crypttab 0644 <<EOF
cryptroot UUID=$root_uuid none luks,discard,initramfs
EOF
# /etc/default/locale
write_file /mnt/etc/default/locale 0644 <<<"LANG=en_US.UTF-8"
# /etc/locale.gen — uncomment en_US.UTF-8 only
write_file /mnt/etc/locale.gen 0644 <<EOF
en_US.UTF-8 UTF-8
EOF
# /etc/timezone
write_file /mnt/etc/timezone 0644 <<<"$TIMEZONE_VAL"
# /etc/netplan/01-static.yaml — mode 0600 (netplan refuses world-readable)
write_file /mnt/etc/netplan/01-static.yaml 0600 <<EOF
network:
version: 2
renderer: networkd
ethernets:
eth0:
addresses:
- $v4_addr
- $v6_addr
routes:
- to: default
via: $v4_gw
nameservers:
addresses: [1.1.1.1, 9.9.9.9, 2606:4700:4700::1111, 2620:fe::fe]
EOF
# /etc/apt/sources.list.d/ubuntu.sources (deb822)
write_file /mnt/etc/apt/sources.list.d/ubuntu.sources 0644 <<'EOF'
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports
Suites: resolute resolute-updates resolute-security resolute-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
# Clear the one-line sources.list that debootstrap wrote (avoid duplicate
# repo entries with our deb822 file above).
write_file /mnt/etc/apt/sources.list 0644 <<'EOF'
# Repositories defined in /etc/apt/sources.list.d/ubuntu.sources (deb822).
EOF
# Stash net mask + gw for the initramfs config in T14
write_file /mnt/etc/initramfs-tools/.install-net.env 0644 <<EOF
INSTALL_NET_V4_IP=$v4_ip
INSTALL_NET_V4_GW=$v4_gw
INSTALL_NET_V4_MASK=$v4_mask
INSTALL_NET_HOSTNAME=$HOSTNAME_VAL
EOF
log "pre-chroot: ok"
}
phase_chroot_apt() {
log "chroot_apt: binding mounts + installing packages"
bind_chroot_mounts
# policy-rc.d: prevent daemons starting inside chroot
write_file /mnt/usr/sbin/policy-rc.d 0755 <<'EOF'
#!/bin/sh
exit 101
EOF
# Temporary resolv.conf so apt can resolve names
run cp -f /etc/resolv.conf /mnt/etc/resolv.conf
chroot_run "apt-get update -qq"
chroot_run "apt-get install -y --no-install-recommends \
linux-image-generic linux-headers-generic \
grub-efi-arm64 grub-efi-arm64-signed shim-signed efibootmgr \
cryptsetup cryptsetup-initramfs dropbear-initramfs \
btrfs-progs \
openssh-server sudo \
snapper \
chrony unattended-upgrades qemu-guest-agent ufw \
bash zsh fish neovim curl ca-certificates tmux htop \
zstd \
locales tzdata console-setup keyboard-configuration \
systemd-resolved netplan.io"
chroot_run "locale-gen"
chroot_run "update-locale LANG=en_US.UTF-8"
chroot_run "ln -sf /usr/share/zoneinfo/$TIMEZONE_VAL /etc/localtime"
log "chroot_apt: ok"
}
phase_user() {
log "user: creating $USERNAME_VAL (shell=$USER_SHELL)"
local shell_path
case "$USER_SHELL" in
bash) shell_path=/bin/bash ;;
zsh) shell_path=/usr/bin/zsh ;;
fish) shell_path=/usr/bin/fish ;;
*) err "unknown shell: $USER_SHELL"; exit 1 ;;
esac
chroot_run "useradd -m -s $shell_path -G sudo,adm,systemd-journal '$USERNAME_VAL'"
chroot_run "passwd -l '$USERNAME_VAL'"
write_file "/mnt/home/$USERNAME_VAL/.ssh/authorized_keys" 0600 <<<"$SSH_PUBKEY"
chroot_run "chown -R $USERNAME_VAL:$USERNAME_VAL /home/$USERNAME_VAL/.ssh"
chroot_run "chmod 700 /home/$USERNAME_VAL/.ssh"
write_file "/mnt/etc/sudoers.d/90-$USERNAME_VAL" 0440 <<EOF
$USERNAME_VAL ALL=(ALL) NOPASSWD:ALL
EOF
chroot_run "passwd -l root"
chroot_run "rm -f /root/.ssh/authorized_keys"
write_file /mnt/etc/ssh/sshd_config.d/00-hardening.conf 0644 <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
Port 22
EOF
log "user: ok"
}
phase_initramfs() {
log "initramfs: configuring dropbear remote unlock on :2222"
# Load net config stashed in pre-chroot
local INSTALL_NET_V4_IP INSTALL_NET_V4_GW INSTALL_NET_V4_MASK INSTALL_NET_HOSTNAME
if [[ $DRY_RUN -eq 0 ]]; then
# shellcheck disable=SC1091
. /mnt/etc/initramfs-tools/.install-net.env
else
# RFC 5737 documentation placeholders for dry-run.
INSTALL_NET_V4_IP="192.0.2.10"
INSTALL_NET_V4_GW="192.0.2.1"
INSTALL_NET_V4_MASK="255.255.255.0"
INSTALL_NET_HOSTNAME="$HOSTNAME_VAL"
fi
write_file /mnt/etc/dropbear/initramfs/authorized_keys 0600 <<<"$SSH_PUBKEY"
write_file /mnt/etc/dropbear/initramfs/dropbear.conf 0644 <<'EOF'
# Remote LUKS unlock via SSH
# -p 2222 listen port
# -s disable password auth (key only)
# -j -k no local/remote port forwarding
# -I 180 180s idle timeout
DROPBEAR_OPTIONS="-p 2222 -s -j -k -I 180"
EOF
# Static IPv4 in initramfs: client_ip:server_ip:gw:netmask:hostname:device:autoconf:dns0:dns1
# Drop-in location for initramfs-tools is /etc/initramfs-tools/conf.d/
write_file /mnt/etc/initramfs-tools/conf.d/static-ip 0644 <<EOF
IP=${INSTALL_NET_V4_IP}::${INSTALL_NET_V4_GW}:${INSTALL_NET_V4_MASK}:${INSTALL_NET_HOSTNAME}:eth0:off:1.1.1.1:9.9.9.9
EOF
write_file /mnt/etc/initramfs-tools/conf.d/cryptroot-options 0644 <<'EOF'
CRYPTSETUP=y
EOF
# Banner for dropbear ssh login
write_file /mnt/etc/initramfs-tools/hooks/zz-unlock-banner 0755 <<'EOF'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
. /usr/share/initramfs-tools/hook-functions
mkdir -p "${DESTDIR}/etc"
cat > "${DESTDIR}/etc/motd" <<'BANNER'
=========================================
Remote LUKS unlock. Run: cryptroot-unlock
=========================================
BANNER
EOF
chroot_run "update-initramfs -u -k all"
log "initramfs: ok"
}
phase_grub() {
log "grub: installing arm64-efi bootloader"
write_file /mnt/etc/default/grub 0644 <<'EOF'
GRUB_DEFAULT=0
GRUB_TIMEOUT=3
GRUB_TIMEOUT_STYLE=menu
GRUB_DISTRIBUTOR="Ubuntu"
GRUB_CMDLINE_LINUX_DEFAULT="console=tty1 console=ttyS0,115200"
# net.ifnames=0 + biosdevname=0 force traditional eth0 naming; netplan +
# the initramfs IP= line both target eth0 literally. Without these, Ubuntu
# uses predictable names (enp1s0 / ens3) and neither netplan nor dropbear-
# initramfs networking can bring up the link — silent unreachability.
GRUB_CMDLINE_LINUX="rootflags=subvol=@ net.ifnames=0 biosdevname=0"
GRUB_TERMINAL="console serial"
GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200"
# /boot is unencrypted; GRUB does not need to unlock LUKS itself.
# GRUB_ENABLE_CRYPTODISK is intentionally not set.
GRUB_DISABLE_OS_PROBER=true
EOF
# First install: standard NVRAM-aware path at /EFI/ubuntu/.
chroot_run "grub-install --target=arm64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu --recheck"
# Second install: --removable copies to the firmware-fallback path
# /EFI/BOOT/BOOTAA64.EFI so VPS firmwares that cannot persist NVRAM
# entries (netcup KVM warns 'EFI variables cannot be set') still boot.
chroot_run "grub-install --target=arm64-efi --efi-directory=/boot/efi --removable --recheck"
chroot_run "update-grub"
log "grub: ok"
}
phase_snapper() {
log "snapper: configuring root + home configs and apt hooks"
# snapper create-config tries to create its own .snapshots subvol.
# Our fstab already mounts @snapshots at /.snapshots, so we let
# snapper create the config, then remove its subvol and ensure the
# mount matches our subvol layout.
# Our fstab pre-mounts @snapshots at /.snapshots. snapper create-config
# tries to create a NEW .snapshots subvol there, which fails when the
# path is already a mounted subvol. Unmount first, let snapper create
# its own subvol, then delete it and remount our @snapshots.
# --no-dbus required: no DBus daemon inside chroot.
# umount the @snapshots mount, then rmdir the empty mountpoint
# (created in phase_mount); snapper insists on creating its own subvol
# at this exact path and refuses if anything (file or dir) exists there.
chroot_run "umount /.snapshots && rmdir /.snapshots"
chroot_run "snapper --no-dbus -c root create-config /"
chroot_run "btrfs subvolume delete /.snapshots; \
mkdir -p /.snapshots; \
mount /.snapshots"
# /home/.snapshots is NOT pre-mounted, so snapper creates its subvol
# cleanly; we just delete it afterwards (we do not maintain a separate
# @home_snapshots subvol).
chroot_run "snapper --no-dbus -c home create-config /home"
chroot_run "btrfs subvolume delete /home/.snapshots 2>/dev/null || true"
# Conservative retention: hourly off, save space
chroot_run "snapper --no-dbus -c root set-config \
TIMELINE_CREATE=yes TIMELINE_LIMIT_HOURLY=0 \
TIMELINE_LIMIT_DAILY=7 TIMELINE_LIMIT_WEEKLY=4 \
TIMELINE_LIMIT_MONTHLY=6 TIMELINE_LIMIT_YEARLY=0 \
ALLOW_USERS='$USERNAME_VAL'"
# apt pre/post snapshot hook
write_file /mnt/etc/apt/apt.conf.d/80snapper-hooks 0644 <<'EOF'
DPkg::Pre-Invoke { "if [ -x /usr/bin/snapper ]; then /usr/bin/snapper --no-dbus -c root create -t pre --print-number --description 'apt-pre' >/var/lib/snapper-apt-pre-num 2>/dev/null || true; fi"; };
DPkg::Post-Invoke { "if [ -x /usr/bin/snapper ] && [ -s /var/lib/snapper-apt-pre-num ]; then PRE=$(cat /var/lib/snapper-apt-pre-num); /usr/bin/snapper --no-dbus -c root create -t post --pre-number=$PRE --description 'apt-post' >/dev/null 2>&1 || true; rm -f /var/lib/snapper-apt-pre-num; fi"; };
EOF
# grub-btrfs not available in Ubuntu 26.04 archive; snapshot boot
# menu integration deferred to a post-install task (build from source).
log "snapper: ok"
}
phase_swap() {
log "swap: creating $SWAP_SIZE swapfile on @swap"
run chattr +C /mnt/swap || true
run truncate -s 0 /mnt/swap/swapfile
run chattr +C /mnt/swap/swapfile
run fallocate -l "$SWAP_SIZE" /mnt/swap/swapfile
run chmod 600 /mnt/swap/swapfile
run mkswap /mnt/swap/swapfile
log "swap: ok"
}
phase_finalize() {
log "finalize: enabling services, ufw, purging cloud-init"
chroot_run "systemctl enable ssh chrony systemd-resolved systemd-networkd systemd-networkd-wait-online qemu-guest-agent unattended-upgrades ufw"
chroot_run "ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf"
chroot_run "ufw --force reset >/dev/null"
chroot_run "ufw default deny incoming"
chroot_run "ufw default allow outgoing"
chroot_run "ufw allow 22/tcp"
chroot_run "ufw allow 2222/tcp"
chroot_run "ufw --force enable"
chroot_run "apt-get purge -y cloud-init 2>/dev/null || true"
chroot_run "rm -rf /etc/cloud /var/lib/cloud"
# Remove the temporary host resolv.conf, policy-rc.d, and the install-time net stash
run rm -f /mnt/usr/sbin/policy-rc.d
run rm -f /mnt/etc/initramfs-tools/.install-net.env
run rm -f /mnt/etc/resolv.conf
run ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf
log "finalize: ok"
}
phase_teardown() {
log "teardown: unmounting target tree"
unbind_chroot_mounts
# Unmount in reverse order
for m in /mnt/boot/efi /mnt/boot /mnt/swap /mnt/.snapshots /mnt/home /mnt; do
if mountpoint -q "$m" 2>/dev/null; then
run umount "$m"
fi
done
if [[ -b /dev/mapper/cryptroot ]]; then
run cryptsetup close cryptroot
fi
log "teardown: ok"
}
phase_report() {
log "report: collecting host key fingerprints"
local dropbear_fps="(dry-run placeholder)"
local sshd_fps="(dry-run placeholder)"
if [[ $DRY_RUN -eq 0 ]]; then
# Re-open just to read host keys
printf '%s' "$LUKS_PASS" | cryptsetup open --key-file=- "${TARGET_DISK}3" cryptroot
local opts="defaults,compress=zstd:3,ssd,noatime,space_cache=v2"
mount -o "${opts},subvol=@" /dev/mapper/cryptroot /mnt
# dropbearkey is provided by dropbear-bin on the rescue host. On a
# rescue without dropbear-bin installed, the inner command silently
# fails and dropbear_fps stays empty; collect fingerprints post-boot
# with: ssh-keyscan -p 2222 <host>
dropbear_fps=$(for k in /mnt/etc/dropbear/initramfs/dropbear_*_host_key; do
[[ -f $k ]] && dropbearkey -y -f "$k" 2>/dev/null | grep -E '^Fingerprint:'
done || true)
sshd_fps=$(for k in /mnt/etc/ssh/ssh_host_*_key.pub; do
[[ -f $k ]] && ssh-keygen -l -f "$k"
done || true)
umount /mnt
cryptsetup close cryptroot
fi
cat <<EOF
╔══════════════════════════════════════════════════════════════════╗
║ INSTALL COMPLETE ║
╠══════════════════════════════════════════════════════════════════╣
║ Host: $HOSTNAME_VAL
║ User: $USERNAME_VAL ($USER_SHELL)
║ Disk: $TARGET_DISK
║ Log: $LOG_FILE
╚══════════════════════════════════════════════════════════════════╝
Initramfs dropbear (port 2222) host key fingerprints:
$dropbear_fps
Booted OpenSSH (port 22) host key fingerprints:
$sshd_fps
Add this to your ~/.ssh/config:
Host unlock-$HOSTNAME_VAL
HostName $HOSTNAME_VAL
Port 2222
User root
UserKnownHostsFile ~/.ssh/known_hosts.unlock
IdentityFile ~/.ssh/<your-key>
Host $HOSTNAME_VAL
HostName $HOSTNAME_VAL
Port 22
User $USERNAME_VAL
IdentityFile ~/.ssh/<your-key>
Next steps:
1. Reboot the rescue (run: reboot)
2. ssh unlock-$HOSTNAME_VAL → run: cryptroot-unlock → paste passphrase
3. ssh $HOSTNAME_VAL → verify uname -a, lsblk, snapper -c root list
Acceptance checklist (run on the booted system):
[ ] cryptsetup status cryptroot → active
[ ] lsblk → btrfs subvols on /, /home, /.snapshots, /swap
[ ] efibootmgr -v → ubuntu boot entry present
[ ] journalctl -b -p3 → no priority<=3 errors
[ ] snapper -c root list → at least 1 snapshot
[ ] sudo -n true → success (no password)
EOF
# Securely clear the passphrase before exit
unset LUKS_PASS
log "report: ok"
}
# --- main -------------------------------------------------------------------
main() {
parse_args "$@"
LOG_FILE="/var/log/ubuntu-install-$(date +%Y%m%dT%H%M%S).log"
if [[ $DRY_RUN -eq 0 ]]; then
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
fi
log "ubuntu 26.04 installer starting (dry_run=$DRY_RUN, disk=$TARGET_DISK, swap=$SWAP_SIZE)"
phase_preflight
phase_deps
phase_prompts
phase_partition
phase_luks
phase_filesystems
phase_mount
phase_debootstrap
phase_pre_chroot
phase_chroot_apt
phase_user
phase_initramfs
phase_grub
phase_snapper
phase_swap
phase_finalize
phase_teardown
phase_report
log "done"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment