diff --git a/debian/13-trixie-luks/files/90-initial-login-setup.sh b/debian/13-trixie-luks/files/90-initial-login-setup.sh new file mode 100644 index 0000000..2419845 --- /dev/null +++ b/debian/13-trixie-luks/files/90-initial-login-setup.sh @@ -0,0 +1,26 @@ +#! /bin/sed 2,5!d;s/^#.// +# This script must be sourced from within a shell +# and not executed. For instance with: +# +# . /usr/local/bin/initial-setup.sh + +# Only run in interactive shells +case $- in + *i*) ;; + *) return ;; +esac + +if [ "$EUID" -ne 0 ]; then + if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then + echo "Error: must be root or have sudo privileges to run initial login setup." >&2 + return + fi +fi + +SENTINEL="/var/lib/initial-login-setup.done" + +if [ ! -f "$SENTINEL" ] && [ -x /usr/local/bin/initial-setup.sh ]; then + #DEBUG touch SENTINEL before running the setup script to prevent infinite loops during development + sudo /usr/local/bin/initial-setup.sh + sudo touch "$SENTINEL" +fi diff --git a/debian/13-trixie-luks/files/initial-setup.sh b/debian/13-trixie-luks/files/initial-setup.sh new file mode 100644 index 0000000..bc7fc2f --- /dev/null +++ b/debian/13-trixie-luks/files/initial-setup.sh @@ -0,0 +1,740 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +SCRIPT_NAME="$(basename "$0")" +TASK_INDEX=0 +TASK_TOTAL=0 +TEMP_FILES=() +STORAGE_DETECTED=0 + +ROOT_SOURCE="" +VG_NAME="" +LV_NAME="" +PV_NAME="" +LUKS_DEV="" +LUKS_NAME="" +LUKS_PART="" +DISK_DEV="" +PART_NUM="" + +if [ -t 1 ]; then + BOLD="$(tput bold 2>/dev/null || true)" + DIM="$(tput dim 2>/dev/null || true)" + RED="$(tput setaf 1 2>/dev/null || true)" + GREEN="$(tput setaf 2 2>/dev/null || true)" + YELLOW="$(tput setaf 3 2>/dev/null || true)" + BLUE="$(tput setaf 4 2>/dev/null || true)" + RESET="$(tput sgr0 2>/dev/null || true)" +else + BOLD="" + DIM="" + RED="" + GREEN="" + YELLOW="" + BLUE="" + RESET="" +fi + +die() { + echo "${RED}Error:${RESET} $*" >&2 + exit 1 +} + +log_info() { + echo "${BLUE}INFO:${RESET} $*" +} + +log_ok() { + echo "${GREEN}OK:${RESET} $*" +} + +log_warn() { + echo "${YELLOW}WARN:${RESET} $*" +} + +section() { + local title="$1" + echo + echo "${BOLD}${title}${RESET}" + echo "${DIM}------------------------------------------------------------${RESET}" +} + +add_temp_file() { + TEMP_FILES+=("$1") +} + +cleanup() { + local file + for file in "${TEMP_FILES[@]:-}"; do + [ -f "$file" ] && rm -f "$file" + done +} +trap cleanup EXIT + +ensure_tty() { + if [ ! -t 0 ] || [ ! -t 1 ]; then + die "This setup must run interactively in a TTY." + fi +} + +ensure_root() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then + log_info "Re-running with sudo..." + exec sudo -E "$0" "$@" + fi + die "Must be root or have sudo privileges to run this setup." + fi +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log_warn "Missing command: $cmd" + return 1 + fi + return 0 +} + +prompt_input() { + local label="$1" + local default="${2:-}" + local value="" + if [ -n "$default" ]; then + read -r -p "${label} [${default}]: " value /dev/tty + if [ -n "$value" ]; then + printf '%s' "$value" + return 0 + fi + log_warn "Value cannot be empty." + done +} + +prompt_secret_confirm() { + local label="$1" + local confirm_label="$2" + local a="" + local b="" + while true; do + read -r -s -p "${label}: " a /dev/tty + read -r -s -p "${confirm_label}: " b /dev/tty + if [ -z "$a" ]; then + log_warn "Value cannot be empty." + continue + fi + if [ "$a" != "$b" ]; then + log_warn "Values do not match. Please try again." + continue + fi + printf '%s' "$a" + return 0 + done +} + +confirm() { + local label="$1" + local default="${2:-yes}" + local prompt="" + local answer="" + + if [ "$default" = "yes" ]; then + prompt="[Y/n]" + else + prompt="[y/N]" + fi + + while true; do + read -r -p "${label} ${prompt} " answer /dev/null 2>&1; then + numfmt --to=iec --suffix=B "$bytes" + else + awk -v b="$bytes" 'BEGIN { + split("B KiB MiB GiB TiB", u, " "); + i=1; + while (b>=1024 && i<5) { b/=1024; i++; } + printf "%.1f %s", b, u[i]; + }' + fi +} + +mib_to_human() { + local mib="$1" + awk -v m="$mib" 'BEGIN { printf "%.1f GiB", m/1024 }' +} + +add_task() { + TASK_TITLES+=("$1") + TASK_FUNCS+=("$2") + TASK_TOTAL=$((TASK_TOTAL + 1)) +} + +run_tasks() { + local i + local title + local func + for i in "${!TASK_FUNCS[@]}"; do + TASK_INDEX=$((TASK_INDEX + 1)) + title="${TASK_TITLES[$i]}" + func="${TASK_FUNCS[$i]}" + section "Task ${TASK_INDEX}/${TASK_TOTAL}: ${title}" + "$func" + done +} + +lsblk_attr() { + local path="$1" + local attr="$2" + lsblk -dn -o "$attr" "$path" 2>/dev/null | head -n1 | xargs || true +} + +infer_disk_part_from_partition() { + local part_path="$1" + local resolved + local base + + resolved="$(readlink -f "$part_path" 2>/dev/null || printf '%s' "$part_path")" + base="$(basename "$resolved")" + + if [[ "$base" =~ ^(nvme[0-9]+n[0-9]+)p([0-9]+)$ ]]; then + printf '/dev/%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + if [[ "$base" =~ ^(mmcblk[0-9]+)p([0-9]+)$ ]]; then + printf '/dev/%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + if [[ "$base" =~ ^(md[0-9]+)p([0-9]+)$ ]]; then + printf '/dev/%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + if [[ "$base" =~ ^((sd|vd|xvd|hd)[a-z]+)([0-9]+)$ ]]; then + printf '/dev/%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" + return 0 + fi + return 1 +} + +resolve_luks_backing_partition() { + local mapper_path="$1" + local mapper_name="$2" + local mapper_name_alt="$3" + local part="" + + if command -v cryptsetup >/dev/null 2>&1; then + part="$(cryptsetup status "$mapper_name" 2>/dev/null | awk '/^[[:space:]]*device:/ {print $2; exit}' | xargs || true)" + if [ -z "$part" ] && [ -n "$mapper_name_alt" ] && [ "$mapper_name_alt" != "$mapper_name" ]; then + part="$(cryptsetup status "$mapper_name_alt" 2>/dev/null | awk '/^[[:space:]]*device:/ {print $2; exit}' | xargs || true)" + fi + if [ -z "$part" ]; then + part="$(cryptsetup status "$mapper_path" 2>/dev/null | awk '/^[[:space:]]*device:/ {print $2; exit}' | xargs || true)" + fi + fi + + if [ -z "$part" ]; then + part="$(lsblk -nro PATH,TYPE -s "$mapper_path" 2>/dev/null | awk '$2=="part" {print $1; exit}' | xargs || true)" + fi + + printf '%s' "$part" +} + +refresh_partition_table() { + local disk="$1" + if command -v partprobe >/dev/null 2>&1; then + partprobe "$disk" || true + fi + if command -v partx >/dev/null 2>&1; then + partx -u "$disk" || true + fi + if command -v udevadm >/dev/null 2>&1; then + udevadm settle || true + fi +} + +wait_for_partition_growth() { + local part="$1" + local old_bytes="$2" + local new_bytes=0 + local i=0 + + for ((i = 0; i < 12; i++)); do + new_bytes="$(blockdev --getsize64 "$part" 2>/dev/null || echo 0)" + if [ "$new_bytes" -gt "$old_bytes" ]; then + return 0 + fi + sleep 1 + done + return 1 +} + +is_last_partition_on_disk() { + local disk="$1" + local part_num="$2" + local last_part + + last_part="$(parted -ms "$disk" unit s print 2>/dev/null | awk -F: '$1 ~ /^[0-9]+$/ {last=$1} END {print last}')" + [ -n "$last_part" ] && [ "$part_num" = "$last_part" ] +} + +get_trailing_free_bytes() { + local disk="$1" + local part_num="$2" + local disk_bytes + local part_end_bytes + local free_bytes + + disk_bytes="$(blockdev --getsize64 "$disk" 2>/dev/null || true)" + part_end_bytes="$(parted -ms "$disk" unit B print 2>/dev/null | awk -F: -v p="$part_num" '$1==p {gsub("B","",$3); print $3; exit}')" + if [ -z "$disk_bytes" ] || [ -z "$part_end_bytes" ]; then + return 1 + fi + + free_bytes=$((disk_bytes - part_end_bytes - 1)) + if [ "$free_bytes" -lt 0 ]; then + free_bytes=0 + fi + echo "$free_bytes" +} + +resize_open_luks_mapping() { + if [ -n "$LUKS_NAME" ] && cryptsetup resize "$LUKS_NAME" >/dev/null 2>&1; then + return 0 + fi + cryptsetup resize "$LUKS_DEV" >/dev/null 2>&1 +} + +detect_storage_stack() { + STORAGE_DETECTED=0 + LUKS_NAME="" + LUKS_PART="" + DISK_DEV="" + PART_NUM="" + + require_cmd findmnt || return 1 + require_cmd lvs || return 1 + require_cmd pvs || return 1 + require_cmd lsblk || return 1 + require_cmd cryptsetup || return 1 + + ROOT_SOURCE="$(findmnt -n -o SOURCE / || true)" + if [ -z "$ROOT_SOURCE" ] || [ ! -e "$ROOT_SOURCE" ]; then + log_warn "Unable to detect root device." + return 1 + fi + + VG_NAME="$(lvs --noheadings -o vg_name "$ROOT_SOURCE" 2>/dev/null | xargs || true)" + LV_NAME="$(lvs --noheadings -o lv_name "$ROOT_SOURCE" 2>/dev/null | xargs || true)" + if [ -z "$VG_NAME" ]; then + log_warn "Root does not appear to be on LVM." + return 1 + fi + + PV_NAME="$(pvs --noheadings -o pv_name --select "vg_name=${VG_NAME}" 2>/dev/null | head -n1 | xargs || true)" + if [ -z "$PV_NAME" ]; then + log_warn "Unable to detect LVM physical volume." + return 1 + fi + + LUKS_DEV="$PV_NAME" + LUKS_NAME="$(basename "$LUKS_DEV")" + local mapper_name_alt + mapper_name_alt="$(lsblk_attr "$LUKS_DEV" NAME)" + + local luks_type + luks_type="$(lsblk_attr "$LUKS_DEV" TYPE)" + if [ "$luks_type" != "crypt" ]; then + log_warn "LVM PV is not on a LUKS device (type: ${luks_type:-unknown}). LUKS resize will be skipped." + return 1 + fi + + LUKS_PART="$(resolve_luks_backing_partition "$LUKS_DEV" "$LUKS_NAME" "$mapper_name_alt")" + if [ -z "$LUKS_PART" ] || [ ! -b "$LUKS_PART" ]; then + log_warn "Unable to detect LUKS backing partition." + return 1 + fi + + local inferred + local inferred_disk="" + local inferred_part="" + inferred="$(infer_disk_part_from_partition "$LUKS_PART" || true)" + if [ -n "$inferred" ]; then + inferred_disk="$(printf '%s\n' "$inferred" | sed -n '1p')" + inferred_part="$(printf '%s\n' "$inferred" | sed -n '2p')" + fi + + DISK_DEV="$inferred_disk" + PART_NUM="$inferred_part" + + if [ -z "$DISK_DEV" ]; then + local disk_parent + disk_parent="$(lsblk_attr "$LUKS_PART" PKNAME)" + if [ -n "$disk_parent" ]; then + DISK_DEV="/dev/${disk_parent}" + else + DISK_DEV="$(lsblk -nro PATH,TYPE -s "$LUKS_PART" 2>/dev/null | awk '$2=="disk" {print $1; exit}' | xargs || true)" + fi + fi + + if [ -z "$PART_NUM" ]; then + PART_NUM="$(lsblk_attr "$LUKS_PART" PARTNUM)" + fi + + if [ -z "$DISK_DEV" ] || [ ! -b "$DISK_DEV" ] || [ -z "$PART_NUM" ] || ! [[ "$PART_NUM" =~ ^[0-9]+$ ]]; then + log_warn "Unable to detect disk device or partition number." + log_warn "Detected values: LUKS_PART=${LUKS_PART:-} DISK_DEV=${DISK_DEV:-} PART_NUM=${PART_NUM:-}" + return 1 + fi + + STORAGE_DETECTED=1 + return 0 +} + +resize_lvm_on_luks() { + if [ "$STORAGE_DETECTED" -ne 1 ]; then + log_warn "Storage layout not detected; skipping resize." + return 0 + fi + + require_cmd parted || return 0 + require_cmd blockdev || return 0 + require_cmd cryptsetup || return 0 + require_cmd pvresize || return 0 + require_cmd lvextend || return 0 + + log_info "Root LV: ${ROOT_SOURCE}" + log_info "VG: ${VG_NAME} | LV: ${LV_NAME}" + log_info "LUKS device: ${LUKS_DEV}" + log_info "LUKS partition: ${LUKS_PART}" + log_info "Disk: ${DISK_DEV} | Partition number: ${PART_NUM}" + [ -n "$LUKS_NAME" ] && log_info "LUKS mapper name: ${LUKS_NAME}" + + if ! is_last_partition_on_disk "$DISK_DEV" "$PART_NUM"; then + log_warn "Partition ${PART_NUM} is not the last partition on ${DISK_DEV}. Automatic growth is skipped." + return 0 + fi + + local part_size_before + local free_bytes + + part_size_before="$(blockdev --getsize64 "$LUKS_PART" 2>/dev/null || echo 0)" + free_bytes="$(get_trailing_free_bytes "$DISK_DEV" "$PART_NUM" || true)" + if [ -z "$free_bytes" ]; then + log_warn "Unable to determine free disk space." + return 0 + fi + + if [ "$free_bytes" -lt $((1024 * 1024)) ]; then + log_ok "No significant free space detected after the root partition." + return 0 + fi + + log_info "Unallocated space available: $(human_bytes "$free_bytes")" + if ! confirm "Extend partition, LUKS device, VG, and root LV now?" "yes"; then + log_warn "Skipped resize." + return 0 + fi + + log_info "Extending partition ${LUKS_PART} to 100% of disk..." + if ! parted -s "$DISK_DEV" resizepart "$PART_NUM" 100%; then + log_warn "Partition resize failed." + return 0 + fi + + refresh_partition_table "$DISK_DEV" + if [ "$part_size_before" -gt 0 ] && ! wait_for_partition_growth "$LUKS_PART" "$part_size_before"; then + log_warn "Kernel has not reported the new partition size yet; continuing anyway." + fi + + log_info "Resizing LUKS device..." + if ! resize_open_luks_mapping; then + log_warn "LUKS resize failed." + return 0 + fi + + log_info "Resizing LVM physical volume..." + if ! pvresize "$LUKS_DEV"; then + log_warn "pvresize failed." + return 0 + fi + + log_info "Extending root LV to use all free space..." + if ! lvextend -l +100%FREE -r "$ROOT_SOURCE"; then + log_warn "lvextend failed." + return 0 + fi + + log_ok "Resize complete." +} + +parse_size_to_mib() { + local input="$1" + local normalized + normalized="$(echo "$input" | tr '[:upper:]' '[:lower:]' | xargs)" + if [[ "$normalized" =~ ^[0-9]+$ ]]; then + echo "$normalized" + return 0 + fi + if [[ "$normalized" =~ ^([0-9]+)(g|gb)$ ]]; then + echo $((BASH_REMATCH[1] * 1024)) + return 0 + fi + if [[ "$normalized" =~ ^([0-9]+)(m|mb)$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +setup_swap() { + require_cmd awk || return 0 + require_cmd fallocate || return 0 + require_cmd mkswap || return 0 + require_cmd swapon || return 0 + + local mem_kib + local mem_mib + local swap_mib + + mem_kib="$(awk '/MemTotal/ {print $2}' /proc/meminfo)" + mem_mib=$((mem_kib / 1024)) + + if [ "$mem_mib" -lt 2048 ]; then + swap_mib=$((mem_mib * 2)) + elif [ "$mem_mib" -lt 4096 ]; then + swap_mib=$mem_mib + else + swap_mib=$((mem_mib / 5)) + fi + + log_info "Detected RAM: $(mib_to_human "$mem_mib")" + log_info "Recommended swap size: $(mib_to_human "$swap_mib")" + + if ! confirm "Use recommended swap size?" "yes"; then + local custom + while true; do + custom="$(prompt_input "Enter custom swap size (MiB or GiB, e.g. 2048 or 4G)")" + if swap_mib="$(parse_size_to_mib "$custom")"; then + if [ "$swap_mib" -gt 0 ]; then + break + fi + fi + log_warn "Invalid size. Try again." + done + fi + + local swapfile="/swapfile" + if [ -e "$swapfile" ]; then + log_warn "Swap file already exists at ${swapfile}." + if ! confirm "Replace existing swap file?" "no"; then + log_warn "Skipped swap setup." + return 0 + fi + if swapon --show=NAME --noheadings | grep -qx "$swapfile"; then + swapoff "$swapfile" || true + fi + rm -f "$swapfile" + fi + + log_info "Creating swap file (${swap_mib} MiB) at ${swapfile}..." + fallocate -l "${swap_mib}M" "$swapfile" + chmod 600 "$swapfile" + mkswap "$swapfile" >/dev/null + swapon "$swapfile" + + if ! grep -qE "^[[:space:]]*${swapfile}[[:space:]]" /etc/fstab; then + echo "${swapfile} none swap sw 0 0" >> /etc/fstab + fi + + log_ok "Swap enabled." +} + +change_luks_passphrase() { + if [ "$STORAGE_DETECTED" -ne 1 ]; then + log_warn "Storage layout not detected; skipping LUKS passphrase change." + return 0 + fi + if [ -z "$LUKS_PART" ] || [ ! -e "$LUKS_PART" ]; then + log_warn "LUKS partition not found; skipping." + return 0 + fi + require_cmd cryptsetup || return 0 + + if ! confirm "Change LUKS passphrase in slot 0 now?" "yes"; then + log_warn "Skipped LUKS passphrase change." + return 0 + fi + + local old_pass + local new_pass + local tmp_old + local tmp_new + + old_pass="$(prompt_secret "Enter current LUKS passphrase")" + new_pass="$(prompt_secret_confirm "Enter new LUKS passphrase" "Confirm new LUKS passphrase")" + + tmp_old="$(mktemp)" + tmp_new="$(mktemp)" + add_temp_file "$tmp_old" + add_temp_file "$tmp_new" + + printf '%s' "$old_pass" >"$tmp_old" + printf '%s' "$new_pass" >"$tmp_new" + + log_info "Updating LUKS passphrase in slot 0..." + if cryptsetup luksChangeKey --batch-mode --key-slot 0 --key-file "$tmp_old" "$LUKS_PART" "$tmp_new"; then + log_ok "LUKS passphrase updated." + else + log_warn "Failed to update LUKS passphrase." + fi +} + +setup_clevis() { + log_info "Clevis/Tang setup is not implemented in this template yet." + if confirm "Would you like to configure Clevis with a Tang server now? (will be skipped)" "no"; then + local tang + tang="$(prompt_input "Tang server URL" "http://tang.int.r3w.de")" + log_warn "Clevis setup for ${tang} is not implemented yet. Skipping." + else + log_info "Skipping Clevis setup." + fi +} + +setup_tailscale() { + if ! require_cmd tailscale; then + log_warn "Tailscale is not installed; skipping." + return 0 + fi + + if ! confirm "Set up Tailscale now?" "yes"; then + log_warn "Skipped Tailscale setup." + return 0 + fi + + local server + local tags + local key + local tag_list="" + local t + + server="$(prompt_input "Tailscale/Headscale server URL" "https://vpn.s1q.dev")" + tags="$(prompt_input "Client tags (comma-separated)" "server")" + key="$(prompt_secret "Pre-authentication key")" + + if [ -n "$tags" ]; then + IFS=',' read -r -a tag_array <<<"$tags" + for t in "${tag_array[@]:-}"; do + t="$(echo "$t" | xargs)" + if [ -n "$t" ]; then + if [ -n "$tag_list" ]; then + tag_list+="," + fi + tag_list+="tag:${t}" + fi + done + fi + + log_info "Bringing up Tailscale..." + if [ -n "$tag_list" ]; then + tailscale up --login-server "$server" --authkey "$key" --ssh --advertise-tags "$tag_list" + else + tailscale up --login-server "$server" --authkey "$key" --ssh + fi + + log_ok "Tailscale setup complete." +} + +setup_crowdsec() { + if ! require_cmd cscli; then + log_warn "CrowdSec (cscli) not installed; skipping." + return 0 + fi + + if ! confirm "Set up CrowdSec now?" "yes"; then + log_warn "Skipped CrowdSec setup." + return 0 + fi + + local key + key="$(prompt_secret "Enrollment key")" + + log_info "Enrolling CrowdSec..." + if cscli console enroll -e "$key"; then + log_info "Restarting CrowdSec service..." + systemctl restart crowdsec || log_warn "Failed to restart crowdsec service." + log_ok "CrowdSec enrollment complete." + else + log_warn "CrowdSec enrollment failed." + fi +} + +prompt_reboot() { + log_warn "A reboot is strongly recommended after partition or swap changes." + if confirm "Reboot now?" "yes"; then + log_info "Rebooting..." + reboot + else + log_info "Please reboot later to ensure changes take effect." + fi +} + +welcome() { + section "Initial VM Setup" + log_info "This setup runs once and is fully interactive." + log_info "Hostname: $(hostname)" +} + +main() { + ensure_tty + ensure_root "$@" + welcome + + if detect_storage_stack; then + log_ok "Detected LVM on LUKS storage layout." + else + log_warn "Storage layout detection incomplete; some steps may be skipped." + fi + + TASK_TITLES=() + TASK_FUNCS=() + + add_task "Resize LVM on LUKS (if free space exists)" resize_lvm_on_luks + add_task "Configure swap file" setup_swap + add_task "Change LUKS passphrase (slot 0)" change_luks_passphrase + add_task "Clevis/Tang setup (placeholder)" setup_clevis + add_task "Configure Tailscale" setup_tailscale + add_task "Configure CrowdSec" setup_crowdsec + add_task "Reboot recommendation" prompt_reboot + + run_tasks + log_ok "Initial setup finished." +} + +main "$@"