Last active
May 26, 2026 09:29
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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