veilor-os/overlay/usr/local/bin/veilor-installer
veilor-org 767fe71a3a feat(installer): confirm-twice for LUKS passphrase + admin password
A typo in the LUKS passphrase is unrecoverable — the disk is
unmountable without it and we don't escrow the key. Re-prompting
until the two reads match catches keyboard-layout surprises (the
US/UK quote-key position is the most common one) before they brick
the install.

Admin password gets the same treatment for consistency. Less
catastrophic (resettable from a recovery shell) but a mismatch
still locks the user out of their fresh install on first boot.

Loop bails on cancel/ESC and re-prompts on validate_pw failure.
2026-05-06 10:31:21 +01:00

1118 lines
46 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# veilor-os installer — TUI wrapper around anaconda kickstart install.
# Runs on tty1 in place of getty (live ISO boot path).
#
# Flow:
# 1. ASCII banner (assets/installer/banner.txt)
# 2. Menu: Install / Live desktop / Live shell / Reboot / Power off
# 3. If Install: collect answers via gum (disk, hostname, LUKS pw,
# admin pw, locale)
# 4. Generate /run/install/veilor-generated.ks from template + answers
# (full veilor-os install: package list + overlay + scripts + harden)
# 5. Exec anaconda --kickstart=/run/install/veilor-generated.ks
# 6. On finish: reboot into installed system
#
# v0.5.1 — gum (charm.sh) replaces whiptail; whiptail kept as fallback.
# Generated kickstart now installs full veilor-os (matches live ks).
set -uo pipefail
export TERM="${TERM:-linux}"
# Log to /run (pure tmpfs) — /var/log overlays squashfs on the live ISO, so a
# bad sector on the USB medium turns `tee -a` into "input/output error" and
# kills the installer before the menu can render.
LOG=/run/veilor-installer.log
# require_tty MUST run before the tee redirect — process substitution
# replaces fd1 with a pipe, breaking `[[ -t 1 ]]`.
require_tty() {
if ! [[ -t 0 && -t 1 ]]; then
echo "[ERR] veilor-installer must run on a real tty" >&2
exit 1
fi
}
require_tty
# Tee output for log persistence, but only if LOG is writable. On a flaky USB
# the squashfs/overlay can throw I/O errors mid-write — tolerate that and
# keep the installer running without persistence rather than aborting.
if : >> "$LOG" 2>/dev/null; then
exec > >(tee -a "$LOG") 2>&1
fi
# ── Branded styling for gum ─────────────────────────────────────────────
# colors.gum sets GUM_* env vars — pure-black palette from veilor-black KDE.
# Sourced at top so every prompt below picks up branded colors.
COLORS=/usr/share/veilor-os/assets/installer/colors.gum
[[ -r $COLORS ]] && source "$COLORS"
BANNER_FILE=/usr/share/veilor-os/assets/installer/banner.txt
# Detect TUI backend once. gum is preferred; whiptail is the fallback so the
# installer keeps working on minimal images or if /usr/local/bin/gum is
# missing (e.g. broken vendored binary, /usr remount issues).
if command -v gum >/dev/null 2>&1; then
TUI=gum
elif command -v whiptail >/dev/null 2>&1; then
TUI=whiptail
else
echo "[ERR] neither gum nor whiptail available — cannot run TUI" >&2
exit 1
fi
banner() {
clear
# Read version + date for version line. /etc/os-release has VERSION_ID.
local ver=""
[[ -r /etc/os-release ]] && ver=$(. /etc/os-release; echo "${VERSION_ID:-0.0}")
local d
d=$(date +%Y-%m-%d)
local vline="veilor-os ${ver} · ${d} · live"
if [[ -r $BANNER_FILE ]]; then
# v0.6: staged line-by-line reveal of the banner before the
# gum-style border draws around it. 40ms/line gives a subtle
# "typewriter" feel — 5 lines × 40ms = 200ms total, fast enough
# not to feel laggy but slow enough to land an aesthetic on the
# very first frame the user sees. Once the reveal finishes we
# clear and re-draw with the bordered gum-style version so the
# operator never sees both stacked on top of each other.
local line
while IFS= read -r line; do
printf ' %s\n' "$line"
sleep 0.04
done < "$BANNER_FILE"
sleep 0.08
clear
if [[ $TUI == gum ]]; then
# gum style: rounded border, banner + blank line + version line.
gum style --border rounded --margin "0 2" --padding "1 3" \
--border-foreground "${VEILOR_DIM:-240}" \
--foreground "${VEILOR_FG:-15}" \
"$(cat "$BANNER_FILE")" \
"" \
"$vline"
else
cat "$BANNER_FILE"
echo
echo " $vline"
echo
fi
else
# Fallback ASCII if banner.txt missing (older overlay).
cat << EOF
veilor-os installer
$vline
EOF
fi
}
# ── TUI wrapper functions ───────────────────────────────────────────────
# Each prompt_* call abstracts gum / whiptail. Always emit the chosen value
# on stdout; non-zero exit on cancel/ESC. Callers use `||` to propagate.
# prompt_choose <header> <opt1> [opt2 ...]
# Single-select menu. Returns the selected option literal on stdout.
prompt_choose() {
local header=$1; shift
if [[ $TUI == gum ]]; then
gum choose --header "$header" "$@"
else
# whiptail menu needs tag/desc pairs. Use the option as both.
local args=()
local opt
for opt in "$@"; do args+=("$opt" "$opt"); done
whiptail --title "veilor-os" --menu "$header" 18 70 8 \
"${args[@]}" 3>&1 1>&2 2>&3
fi
}
# prompt_choose_pairs <header> <tag1> <desc1> [tag2 desc2 ...]
# Tag/description menu. Returns the chosen tag.
# Used when display label differs from machine-readable value (e.g. disk
# path /dev/nvme0n1 vs description "476G WDC PC SN740").
prompt_choose_pairs() {
local header=$1; shift
if [[ $TUI == gum ]]; then
# gum has no tag/desc concept — render "tag — desc" lines, parse.
local lines=() i=1
while (( i <= $# )); do
local tag=${!i}; ((i++))
local desc=${!i}; ((i++))
lines+=("$tag$desc")
done
local picked
picked=$(printf '%s\n' "${lines[@]}" | gum choose --header "$header") || return 1
# Strip " — desc" suffix to recover the tag.
echo "${picked%% — *}"
else
whiptail --title "veilor-os" --menu "$header" 18 70 8 \
"$@" 3>&1 1>&2 2>&3
fi
}
# prompt_input <header> [default]
prompt_input() {
local header=$1 default=${2:-}
if [[ $TUI == gum ]]; then
gum input --header "$header" --value "$default"
else
whiptail --title "veilor-os" --inputbox "$header" 10 60 "$default" \
3>&1 1>&2 2>&3
fi
}
# prompt_password <header>
#
# v0.6: gum-path replaced with bash `read -srp` because `gum input
# --password` rendered as a duplicate-"Install" + stray-T artefact on
# the linux fbcon since v0.5.27 (Agent 7 of the v0.6 polish research
# wave traced this to gum's bubbletea screen-restore writing back the
# previous menu buffer when the framebuffer terminfo lacked
# `civis/cnorm` cursor-hide sequences). bash `read -srp` is a single
# write to stdout + termios echo-off — no redraw, no glitch. Header
# rendered separately via gum style for visual parity with the rest
# of the installer.
prompt_password() {
local header=$1
if [[ $TUI == gum ]]; then
# Render the prompt header as a styled box so it looks at home
# next to the other gum prompts, then collect the password via
# plain bash read on the next line. `read -s` disables echo,
# `read -p` writes the prompt to stderr (so command-substitution
# callers still get the password on stdout cleanly).
gum style --foreground "${VEILOR_FG:-15}" --border rounded \
--border-foreground "${VEILOR_DIM:-240}" --padding "0 2" -- "$header"
local pw
read -srp " password: " pw
echo >&2 # newline after silent read so next prompt isn't on same line
printf '%s' "$pw"
else
whiptail --title "veilor-os" --passwordbox "$header" 10 60 \
3>&1 1>&2 2>&3
fi
}
# prompt_confirm <message>
# Exits 0 on yes, 1 on no — matches whiptail --yesno semantics.
prompt_confirm() {
local msg=$1
if [[ $TUI == gum ]]; then
gum confirm "$msg"
else
whiptail --title "Confirm" --yesno "$msg" 16 60
fi
}
# prompt_message <message>
# Non-blocking notice. gum has no msgbox; print styled + sleep.
prompt_message() {
local msg=$1
if [[ $TUI == gum ]]; then
gum style --foreground "${VEILOR_FG:-15}" --border rounded \
--border-foreground "${VEILOR_DIM:-240}" --padding "1 2" -- "$msg"
sleep 2
else
whiptail --title "veilor-os" --msgbox "$msg" 10 60
fi
}
# prompt_error <message>
# Same as message but with a red foreground for visibility.
prompt_error() {
local msg=$1
if [[ $TUI == gum ]]; then
gum style --foreground 1 --border rounded --border-foreground 1 \
--padding "1 2" -- "$msg"
sleep 2
else
whiptail --title "Error" --msgbox "$msg" 10 60
fi
}
main_menu() {
# Empty header — banner already provides context. `──────` line splits
# primary actions (top) from session controls (bottom). Cursor `` set
# via GUM_CHOOSE_CURSOR in colors.gum.
prompt_choose "" \
"Install" \
"live · KDE" \
"live · shell" \
"──────" \
"Reboot" \
"Power off"
}
collect_answers() {
local disk hostname luks_pw admin_pw locale
local disks_pairs
# ── Disk ──
# Build whiptail-style "tag desc" pairs. prompt_choose_pairs reshapes
# for gum if needed.
# shellcheck disable=SC2207
disks_pairs=($(lsblk -dpno NAME,SIZE,MODEL | grep -E '^/dev/(sd|nvme|vd|mmcblk)' | \
awk '{name=$1; size=$2; $1=""; $2=""; sub(/^ +/,""); gsub(/ /,"_"); model=$0; if(model=="")model="unknown"; print name, size"_"model}'))
if [[ ${#disks_pairs[@]} -eq 0 ]]; then
prompt_error "No installable disks found."
return 1
fi
disk=$(prompt_choose_pairs "[1/3] Select install disk · WILL BE ERASED" "${disks_pairs[@]}") || return 1
# ── Hostname ──
# Hardcoded for branded consistency. Post-install: `hostnamectl set-hostname`.
hostname="veilor"
# Reject shell-special and sed-special chars in passwords. Generated
# kickstart writes them via heredoc + sed substitution; bare $, ", \, `
# would corrupt the ks line or partially expand at heredoc time.
# &, |, /, newline are sed-special: & expands to the matched pattern
# (so `aA1!@#%^&*()` becomes `aA1!@#%^__ADMIN_PW__*()`), | is our
# delimiter, / would match if delimiter changes, newline breaks the
# sed expression. sed_escape() below adds defense-in-depth, but we
# also reject these at input so the user sees an immediate error
# rather than a corrupted ks file. 8-char min for entropy.
validate_pw() {
local pw=$1 label=$2
if [[ ${#pw} -lt 8 ]]; then
prompt_error "Weak $label — minimum 8 characters."
return 1
fi
if [[ $pw =~ [\"\$\\\`\&\|/$'\n'] ]]; then
prompt_error "Invalid $label — cannot contain: \" \$ \\ \` & | / newline"
return 1
fi
return 0
}
# ── LUKS passphrase ──
# v0.6: prompt twice + string-compare. A typo in the LUKS passphrase
# is unrecoverable — the disk is unmountable without it and we
# don't escrow the key. Re-prompting until the two reads match
# catches keyboard-layout surprises (US vs UK quote position is
# the most common one) before they brick the install.
local luks_pw_confirm
while true; do
luks_pw=$(prompt_password "[2/3] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || continue
luks_pw_confirm=$(prompt_password "[2/3] Confirm LUKS2 passphrase") || return 1
if [[ $luks_pw == "$luks_pw_confirm" ]]; then
break
fi
prompt_error "Passphrases do not match — try again."
done
# ── Admin password ──
# Same confirm-twice pattern. Less catastrophic than LUKS (admin
# password can be reset from a recovery shell) but a mismatch here
# still locks the user out of their fresh install on first boot.
local admin_pw_confirm
while true; do
admin_pw=$(prompt_password "[3/3] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || continue
admin_pw_confirm=$(prompt_password "[3/3] Confirm admin password") || return 1
if [[ $admin_pw == "$admin_pw_confirm" ]]; then
break
fi
prompt_error "Passwords do not match — try again."
done
# ── Locale ──
# Hardcoded en_US.UTF-8 for branded consistency. The picker that
# used to live here (en_GB / en_US) only added confusion — both
# locales install identically, the user couldn't notice the
# difference, and the post-install `localectl set-locale` works for
# any locale anyway. v0.7 post-install menu will offer locale + kb
# layout switch with live preview. For the install flow, fixed.
locale="en_US.UTF-8"
# ── Confirmation ──
# Render summary box + danger lines via gum style, then gum confirm.
# Whiptail fallback: simple yesno.
if [[ $TUI == gum ]]; then
clear
gum style --border rounded --margin "1 2" --padding "1 3" \
--border-foreground "${VEILOR_DIM:-240}" \
--foreground "${VEILOR_FG:-15}" \
"Confirm install" \
"" \
" Disk $disk $(gum style --foreground 1 'WILL BE ERASED')" \
" Locale $locale" \
" LUKS ✓ set" \
" Admin ✓ set" \
"" \
"$(gum style --foreground 3 'This action is irreversible.')"
gum confirm --affirmative "Yes, install" --negative "Cancel" "Proceed?" || return 1
else
whiptail --title "Confirm install" --yesno \
"About to install veilor-os:
Disk: $disk (WILL BE ERASED)
Locale: $locale
LUKS: set
Admin pw: set
This action is irreversible.
Proceed?" 16 60 || return 1
fi
# Export to caller via globals
SEL_DISK=$disk
SEL_HOSTNAME=$hostname
SEL_LUKS_PW=$luks_pw
SEL_ADMIN_PW=$admin_pw
SEL_LOCALE=$locale
return 0
}
# sed_escape — escape sed special chars in a replacement string.
# Replacement-side metacharacters: & (matched pattern), \ (escape),
# | (our chosen delimiter), / (alternate delimiter — escape too in case
# delimiter ever changes). Newline is rejected in validate_pw because
# escaping it portably across BSD/GNU sed is fiddly.
# Order matters: \ must be escaped FIRST so we don't double-escape the
# backslashes we're about to emit for &, |, /.
sed_escape() {
printf '%s' "$1" | sed -e 's/[\\&|/]/\\&/g'
}
# detect_seed_pubkey — search attached cdroms for a NoCloud cidata seed
# with an ssh_authorized_keys entry. Returns the first key on stdout, or
# empty string if none found. Used by both auto-install.sh (cloud-init
# seed pre-built with host pubkey) and humans (drop a seed iso next to
# the install media).
detect_seed_pubkey() {
local dev label tmpmnt key=""
for dev in /dev/sr0 /dev/sr1 /dev/sr2 /dev/sr3; do
[ -b "$dev" ] || continue
label=$(blkid -o value -s LABEL "$dev" 2>/dev/null)
if [[ $label == "cidata" || $label == "CIDATA" ]]; then
tmpmnt=$(mktemp -d)
if mount -o ro "$dev" "$tmpmnt" 2>/dev/null; then
# NoCloud user-data format:
# ssh_authorized_keys:
# - ssh-ed25519 AAAA... user@host
# Extract first ssh-* line, strip leading '- '.
key=$(grep -E '^\s*-\s+ssh-' "$tmpmnt/user-data" 2>/dev/null \
| head -1 | sed -e 's/^\s*-\s*//' -e 's/[[:space:]]*$//')
umount "$tmpmnt" 2>/dev/null
fi
rmdir "$tmpmnt" 2>/dev/null
[[ -n $key ]] && { printf '%s' "$key"; return 0; }
fi
done
return 1
}
generate_ks() {
# Build kickstart for actual disk install.
# NOTE: passwords go in via --plaintext to avoid storing crypted hash
# collisions; anaconda hashes per /etc/login.defs at install time.
#
# %packages mirrors live ks lines 63-141 (full hardening pkg list,
# minus livesys/anaconda-live which are live-only).
# %post --nochroot copies overlay + scripts + assets from boot ISO.
# %post (chroot) runs the same hardening scripts the live build runs.
local out=/run/install/veilor-generated.ks
local disk_basename
disk_basename=$(basename "$SEL_DISK")
mkdir -p /run/install
# Single-quoted heredoc → no shell expansion. Substitute placeholders
# via sed afterwards. Bulletproof against $/`/" in passwords.
cat > "$out" << 'KSEOF' || return 1
# veilor-os installer-generated kickstart
# DO NOT commit this file — secrets inline.
url --mirrorlist="https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-43&arch=x86_64"
repo --name=fedora --baseurl="https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/" --install
# `updates` repo intentionally NOT added. Fedora's update stream pushes
# package versions whose %posttrans scriptlets sometimes fail under
# anaconda's `--cmdline` mode — most reliably reproduced as `man-db`
# failing during "Configuring man-db.x86_64", which dumps the whole
# transaction with no recoverable error message and prints "An error
# occurred during the transaction: The transaction process has ended
# with errors..". Reproduced in v0.5.26 + v0.5.27 VM tests against
# fresh installs days apart, so it is not a Fedora-mirrors-down blip.
# CI build kickstart already strips this line for the same reason
# (build-iso.yml line ~109). Users who want to update post-install run
# `dnf upgrade` or the v0.6 `veilor-update` wrapper.
keyboard --xlayouts='us'
lang __LOCALE__
timezone Europe/London --utc
firstboot --disable
eula --agreed
selinux --enforcing
services --enabled=sshd,fail2ban,usbguard,tuned,auditd,firewalld,chronyd,sddm
network --bootproto=dhcp --device=link --activate --hostname=__HOSTNAME__
firewall --enabled --service=ssh
rootpw --lock
user --name=admin --groups=wheel --gecos="veilor admin" --password=__ADMIN_PW__ --plaintext
__SSHKEY_DIRECTIVE__
# Full hardening cmdline (installed system, not live):
# - `lockdown=integrity` — kernel lockdown, integrity mode (signed module enforce)
# - `slab_nomerge` — refuse SLAB merging; harder heap-spray attacks
# - `init_on_alloc=1 init_on_free=1` — zero pages on alloc + free; defends
# uninit-read class; ~5% perf hit acceptable on hardened workstation
# - `randomize_kstack_offset=on` — KASLR for kernel stack, per-syscall
# - `vsyscall=none` — kill legacy vsyscall page (Position-Independent
# ROP-gadget surface)
# - `fbcon=nodefer` — keep linux framebuffer console alive through KMS
# handoff so plymouth LUKS prompt remains visible on real GPUs.
#
# NOTE on --location: v0.5.30 used --location=none to skip anaconda's
# bootloader install (sidestep gen_grub_cfgstub). Side effect: anaconda
# also skipped CollectKernelArgumentsTask (installation.py:149-151), so
# `--append=` args were NEVER COLLECTED. kernel-install then wrote BLS
# entries with empty /etc/kernel/cmdline, falling through to the live
# ISO's /proc/cmdline — no rd.luks.uuid, no fbcon=nodefer, no hardening.
# Result: dracut emergency shell on first boot.
#
# v0.5.31 lets anaconda install the bootloader (default behavior, no
# --location flag). With our broad transaction_progress patch in the
# live ks, anaconda's gen_grub_cfgstub still runs, but if grub2-efi-x64's
# posttrans had a non-fatal scriptlet failure the patch swallows it
# without aborting. The %post chroot below STILL does belt-and-braces
# fixup (dnf reinstall, grub2-install, etc.) in case anaconda's path
# left something incomplete.
#
# Critically v0.5.31 also writes /etc/kernel/cmdline FIRST in %post then
# re-runs kernel-install per kernel. That's the canonical Fedora 43 path
# for landing args in BLS entries — kernel-install reads /etc/kernel/cmdline
# (90-loaderentry.install:84-95) when generating BLS option lines.
bootloader --append="lockdown=integrity module.sig_enforce=1 slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1 rd.vconsole.keymap=us"
# Disk: zero, LUKS2 (argon2id), btrfs subvolumes (no LVM intermediary).
# Native btrfs-on-LUKS matches Fedora KDE Spin defaults; LVM+btrfs combo
# triggered "mount failed: wrong fs type, bad option, bad superblock"
# under anaconda --cmdline. btrfs subvols give us the snapshot/rollback
# story without LVM's added complexity.
zerombr
clearpart --all --initlabel --drives=__DISK_BASENAME__
part /boot/efi --fstype=efi --size=600
part /boot --fstype=ext4 --size=1024
part btrfs.veilor --grow --encrypted --luks-version=luks2 --pbkdf=argon2id --passphrase=__LUKS_PW__
btrfs none --label=veilor btrfs.veilor
btrfs / --subvol --name=root LABEL=veilor
btrfs /home --subvol --name=home LABEL=veilor
# ── Packages — mirrors live ks (kickstart/veilor-os.ks 63-141), minus
# live-only entries (livesys-scripts, anaconda-live, @anaconda-tools,
# dracut-live, isomd5sum, xorriso) which are pointless on a real disk.
%packages --excludedocs
@^kde-desktop-environment
@kde-apps
@core
@hardware-support
@standard
kernel-modules
kernel-modules-extra
glibc-all-langpacks
dracut-config-generic
kernel
grub2-efi-x64
grub2-efi-x64-modules
grub2-pc
grub2-pc-modules
grub2-tools
grub2-tools-extra
shim-x64
efibootmgr
syslinux
# veilor-installer dependencies (kept on installed system so
# `sudo veilor-installer` from a recovery shell still works).
newt
parted
cryptsetup
lvm2
btrfs-progs
# core hardening tools
fail2ban
fail2ban-firewalld
usbguard
usbguard-tools
audit
policycoreutils-python-utils
tuned
chrony
firewalld
plymouth
# admin essentials
git
vim-enhanced
tmux
htop
podman
skopeo
NetworkManager
NetworkManager-wifi
# fonts
fontconfig
freetype
fira-code-fonts
# zram (no swap-on-disk)
zram-generator
# remove fluff
# Note: KDE Plasma 6 hard-deps on cups/geoclue2/ModemManager/PackageKit
# transitively; daemons disabled at runtime via 20-harden-kernel.sh.
-abrt*
-snapd
-kde-connect
-open-vm-tools-desktop
-mlocate
# Belt-and-braces with the kickstart/veilor-os.ks transaction_progress
# patch: even with the patch, man-db's transfiletriggerin in the F43
# RPM 6.0 toolchain dispatches a systemd-run that anaconda's chroot
# can race-with on exit. Excluding the package entirely guarantees the
# trigger never fires during install. Veilor users who want man pages
# install them post-firstboot via \`dnf install man-db man-pages\` or
# via the v0.6 \`veilor-postinstall\` welcome menu.
-man-db
-man-pages
-man-pages-overrides
%end
# ── Post-install (nochroot): copy overlay + scripts + assets from boot ISO.
# Mirror of live ks 144-196 trimmed to single-source: ISO mounted at
# /run/install/repo, veilor/ subtree contains overlay/, scripts/, assets/.
# (See build/Containerfile — ISO build copies the repo into veilor/.)
%post --nochroot --erroronfail
set -uo pipefail
DEST="${INSTALL_ROOT:-/mnt/sysimage}"
[[ -d $DEST ]] || { echo "[ERR] DEST=$DEST does not exist" >&2; exit 1; }
SRC=/run/install/repo/veilor
if [[ ! -d $SRC/overlay ]]; then
echo "[ERR] $SRC/overlay missing — boot ISO did not include veilor/ tree" >&2
exit 1
fi
echo "[INFO] using SRC=$SRC DEST=$DEST"
set -x
cp -a "$SRC/overlay/." "$DEST/" || echo "[ERR] overlay cp failed: $?"
mkdir -p "$DEST/usr/share/veilor-os" || echo "[ERR] mkdir failed: $?"
cp -a "$SRC/assets" "$DEST/usr/share/veilor-os/" || echo "[ERR] assets cp failed: $?"
cp -a "$SRC/scripts" "$DEST/usr/share/veilor-os/" || echo "[ERR] scripts cp failed: $?"
ls -la "$DEST/usr/share/veilor-os/" 2>&1 || echo "[ERR] dest dir missing post-cp"
# Force root ownership on everything we copied — `cp -a` preserves
# CI runner uid (1001), which makes sudo refuse to read /etc/sudoers.d.
chown -R 0:0 "$DEST/etc" "$DEST/usr/share/veilor-os" "$DEST/usr/local/bin" 2>&1 || echo "[WARN] chown failed"
set +x
{
echo "=== %post --nochroot trace ==="
date
echo "SRC=$SRC DEST=$DEST"
ls -la "$DEST/usr/share/veilor-os/" 2>&1
ls -la "$DEST/usr/local/bin/" 2>&1
} > "$DEST/var/log/veilor-nochroot.log" 2>&1 || true
%end
# ── Post-install (chroot): apply hardening, theme, branding ────────────
%post
set -uo pipefail
exec > >(tee -a /var/log/veilor-install.log) 2>&1
echo "════════════════════════════════════════════════════════"
echo " veilor-os install — %post (real install)"
echo "════════════════════════════════════════════════════════"
REPO=/usr/share/veilor-os
chmod +x $REPO/scripts/*.sh $REPO/scripts/selinux/*.sh \
/usr/local/bin/veilor-power /usr/local/bin/veilor-firstboot \
/usr/local/bin/veilor-installer 2>/dev/null || true
# /etc/machine-id reset on first boot
> /etc/machine-id
# Apply hardening
bash $REPO/scripts/10-harden-base.sh
bash $REPO/scripts/20-harden-kernel.sh
# Build SELinux module
bash $REPO/scripts/selinux/build-policy.sh || echo "[WARN] SELinux build failed; load on first boot"
# Apply KDE theme + DuckSans + os-release branding
bash $REPO/scripts/kde-theme-apply.sh
# Disable plymouth at TWO layers:
#
# 1. Initramfs (the boot stage where LUKS unlock happens). Plymouth is
# a dracut module; masking units in /etc/systemd/ has zero effect
# here because dracut bundles its own copies into initramfs/.
# Solution: omit_dracutmodules in dracut.conf.d, then regenerate
# initramfs so the new config takes effect.
#
# 2. Real root (post-pivot, before SDDM). /dev/null symlinks mask all
# plymouth services + the path-activated ask-password unit so they
# never start when systemd is up.
#
# After both, LUKS prompt falls back to systemd-tty-ask-password-agent
# on tty1 — text "Please enter passphrase for disk... :" — works in
# QEMU sendkey AND on real hardware.
# Manual bootloader install (anaconda told to skip via --location=none)
#
# Anaconda's gen_grub_cfgstub script-runner (efi.py:194-201) is
# brittle on F43 RPM 6.0 cmdline mode — grub2-efi-x64's posttrans
# scriptlet may emit non-fatal errors that anaconda treats as
# transaction-fatal even with our error suppression patch. Doing
# the work in %post chroot bypasses that whole code path and gives
# us linear, debuggable steps.
#
# Order:
# 1. Re-run grub2-efi-x64 + shim-x64 scriptlets cleanly (dnf
# reinstall in chroot has full PID 1 systemd context, so the
# systemd-run inside man-db-style triggers actually runs). Re-
# installing repopulates /boot/efi/EFI/fedora/ if it was empty.
# 2. grub2-install — generic + UEFI. UEFI path is the meaningful one
# on virtio-vga and real hardware.
# 3. grub2-mkconfig — write /boot/grub2/grub.cfg + /boot/efi/EFI/fedora/grub.cfg.
# 4. efibootmgr — register the boot entry in NVRAM.
#
# Failure of any individual step is logged but does NOT abort the
# %post (set +e bracket). On a real failure the user sees the
# diagnostic text and can fix manually post-firstboot.
set +e
echo "════════════════════════════════════════════════════════"
echo " bootloader install (manual; anaconda skipped via --location=none)"
echo "════════════════════════════════════════════════════════"
# Disk we're targeting — anaconda already wrote /boot/efi mount, so
# the disk is whatever holds /boot/efi.
EFI_DISK=$(findmnt -n -o SOURCE /boot/efi 2>/dev/null | sed -E 's/p?[0-9]+$//')
[ -z "$EFI_DISK" ] && EFI_DISK="/dev/$(basename "$(realpath /sys/class/block/$(findmnt -n -o SOURCE /boot/efi | sed 's|/dev/||' | sed 's|p\?[0-9]\+$||') 2>/dev/null)")"
echo "[INFO] target disk for grub: ${EFI_DISK:-<unknown>}"
# Step 1: re-run grub2 + shim scriptlets in clean chroot
dnf reinstall -y grub2-efi-x64 grub2-efi-x64-modules grub2-pc grub2-pc-modules grub2-tools grub2-tools-extra shim-x64 efibootmgr 2>&1 | tail -10 || \
echo "[WARN] dnf reinstall of grub stack failed"
# Step 2: install grub to ESP (UEFI path, primary)
mkdir -p /boot/efi/EFI/fedora
grub2-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=fedora --no-nvram 2>&1 | tail -5 || \
echo "[WARN] grub2-install (efi) failed"
# Step 3: write the EFI grub.cfg stub (what gen_grub_cfgstub would have done)
if command -v gen_grub_cfgstub >/dev/null 2>&1; then
gen_grub_cfgstub /boot/grub2 /boot/efi/EFI/fedora 2>&1 | tail -5 || \
echo "[WARN] gen_grub_cfgstub failed (will fall back to grub2-mkconfig)"
fi
# Always also write a full grub.cfg in /boot/grub2 (BLS source) and
# regenerate the EFI cfg via grub2-mkconfig for redundancy
grub2-mkconfig -o /boot/grub2/grub.cfg 2>&1 | tail -3
[ -f /boot/efi/EFI/fedora/grub.cfg ] || \
grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg 2>&1 | tail -3
# Step 4: register NVRAM entry
if [ -n "$EFI_DISK" ] && [ -e "$EFI_DISK" ]; then
EFI_PART_NUM=$(findmnt -n -o SOURCE /boot/efi | grep -oE '[0-9]+$')
[ -n "$EFI_PART_NUM" ] && \
efibootmgr -c -d "$EFI_DISK" -p "$EFI_PART_NUM" -L "veilor-os" -l '\EFI\fedora\shimx64.efi' 2>&1 | tail -3 || \
echo "[WARN] efibootmgr failed (NVRAM may already be set)"
fi
echo "[INFO] bootloader install: see above for any [WARN] lines"
# NOTE: deliberately NOT `set -e` here. The block above opened with
# `set +e` and the rest of %post is a sequence of best-effort hardening
# steps that have local `|| true` guards on the operations that may
# legitimately fail. Re-enabling errexit would cause `set -e` to abort
# the whole %post on the first non-guarded command (e.g. a `grep -q`
# returning 1). v0.5.30 had this bug and it silently truncated
# the LUKS args injection.
# GRUB branding: replace fedora distributor with veilor-os in menu titles.
# Drop rhgb quiet from default cmdline → all kernel/systemd messages
# visible. Plymouth `details` theme shows scrolling text (no splash, no
# logo). LUKS prompt rendered as text. Onyx-style boot.
sed -i \
-e 's|^GRUB_DISTRIBUTOR=.*|GRUB_DISTRIBUTOR="veilor-os"|' \
-e 's|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=""|' \
/etc/default/grub 2>/dev/null || true
# Ensure rd.luks.uuid + rd.luks.name in cmdline. Anaconda --cmdline mode
# does not auto-add the LUKS args when partitioning is custom + encrypted
# manually; without rd.luks.uuid in cmdline, dracut's cryptsetup-generator
# never spawns the unlock unit, plymouth has no password to ask for, and
# dracut-initqueue loops on dev-disk-by-uuid for ~3min before dropping
# to emergency shell. Detect the LUKS partition + inject explicitly.
#
# Two write paths because Fedora 43 uses BLS (Boot Loader Specification):
# 1. /etc/default/grub — read by `grub2-mkconfig` for the legacy
# grub.cfg menu, AND used as the source of
# truth for `kernel-install` when adding new
# kernels later.
# 2. /boot/loader/entries/*.conf — the actual per-kernel BLS entries
# that grub reads for cmdline. These
# are NOT regenerated by
# `grub2-mkconfig`; they are managed
# by `grubby` (or `kernel-install`).
# Without both, the running kernel boots without rd.luks.uuid and the
# user lands in emergency shell on first boot.
LUKS_UUID=$(blkid -t TYPE=crypto_LUKS -o value -s UUID 2>/dev/null | head -1)
if [ -n "$LUKS_UUID" ]; then
LUKS_ARGS="rd.luks.uuid=luks-${LUKS_UUID} rd.luks.options=luks-${LUKS_UUID}=tries=5,timeout=0"
HARDEN_ARGS="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer"
# Find the running root UUID (the btrfs filesystem holding the root
# subvol). At this point in %post chroot, `/` is the target root;
# findmnt -o UUID resolves to the btrfs UUID anaconda chose.
ROOT_UUID=$(findmnt -n -o UUID /)
[ -z "$ROOT_UUID" ] && ROOT_UUID=$(blkid -s UUID -o value /dev/mapper/luks-${LUKS_UUID} 2>/dev/null)
# Three write paths, in priority order:
#
# Path A: /etc/kernel/cmdline (the canonical source of truth for
# `kernel-install`). Per /usr/lib/kernel/install.d/90-loaderentry.install
# lines 84-95, kernel-install reads /etc/kernel/cmdline first when
# authoring BLS entries. If we write this BEFORE re-running
# kernel-install, every BLS entry inherits our args. Persists
# across `dnf upgrade kernel`, `dnf reinstall grub2-*`, and any
# other path that re-fires kernel-install hooks.
#
# Path B: /etc/default/grub (legacy GRUB_CMDLINE_LINUX). Read by
# `grub2-mkconfig` for the generated grub.cfg. Belt-and-braces;
# kernel-install ignores this, but grub2-mkconfig respects it.
#
# Path C: grubby --update-kernel=ALL. Direct edit to BLS option
# lines. Acts as the last-writer in case our cmdline write didn't
# trigger a fresh kernel-install pass.
#
# Earlier veilor-os versions only used B+C. v0.5.31 adds Path A as
# the primary, because v0.5.30 testing showed B+C are racy with
# anaconda's own CreateBLSEntriesTask which uses kernel-install
# internally and can rewrite entries from empty /etc/kernel/cmdline,
# producing options lines with no rd.luks.uuid even when grubby
# successfully ran.
# Path A
mkdir -p /etc/kernel
if [ -n "$ROOT_UUID" ]; then
echo "root=UUID=${ROOT_UUID} ro rootflags=subvol=root ${LUKS_ARGS} ${HARDEN_ARGS}" > /etc/kernel/cmdline
echo "[INFO] wrote /etc/kernel/cmdline (canonical kernel-install source)"
else
echo "[WARN] could not determine root UUID; /etc/kernel/cmdline not written"
fi
# Path B
if ! grep -q "rd.luks.uuid" /etc/default/grub 2>/dev/null; then
sed -i "s|^GRUB_CMDLINE_LINUX=\"|GRUB_CMDLINE_LINUX=\"${LUKS_ARGS} |" /etc/default/grub
fi
# Re-run kernel-install for every kernel — picks up new /etc/kernel/cmdline,
# rewrites BLS entries with our args. This is the load-bearing step.
for kver in /lib/modules/*/; do
kver=$(basename "$kver")
[ -f "/lib/modules/$kver/vmlinuz" ] || continue
kernel-install add "$kver" "/lib/modules/$kver/vmlinuz" 2>&1 | tail -3 || \
echo "[WARN] kernel-install add $kver failed"
done
# Path C: belt-and-braces grubby update in case kernel-install missed any
grubby --update-kernel=ALL --args="${LUKS_ARGS}" 2>&1 | tail -5 || true
# Verification: every BLS entry MUST carry the LUKS arg.
drift=$(grep -L "rd.luks.uuid" /boot/loader/entries/*.conf 2>/dev/null)
if [ -n "$drift" ]; then
echo "[ERR] BLS entries missing rd.luks.uuid after all 3 paths: $drift"
else
echo "[OK] all BLS entries carry rd.luks.uuid"
fi
fi
# Verify anaconda wrote /etc/crypttab for the LUKS device. anaconda's
# custom-partitioning code path normally does this for `--encrypted`
# part directives; if it didn't (edge case, F43+ regressions), write
# a minimal entry so systemd-cryptsetup-generator can find the device
# at boot from the BLS args alone.
if [ -n "$LUKS_UUID" ] && ! grep -q "$LUKS_UUID" /etc/crypttab 2>/dev/null; then
echo "luks-${LUKS_UUID} UUID=${LUKS_UUID} none discard" >> /etc/crypttab
echo "[INFO] wrote /etc/crypttab fallback entry"
fi
# Switch plymouth to text-only `details` theme (scrolling boot log, no
# graphics, no logo). Theme is built-in to plymouth package, no asset
# install needed. v0.6 will ship custom veilor-themed plymouth.
plymouth-set-default-theme details 2>/dev/null || true
# Force-include LUKS + plymouth modules in initramfs. dracut autodetects
# crypt+plymouth from the running config, but custom-partitioning %post
# runs before dracut sees stable LUKS state, and stale initramfs files
# from anaconda's pre-install kernel may persist. Belt-and-braces.
mkdir -p /etc/dracut.conf.d
cat > /etc/dracut.conf.d/10-veilor-luks.conf <<'DRACUTEOF'
# veilor-os: guarantee LUKS + plymouth modules in initramfs
add_dracutmodules+=" crypt systemd-cryptsetup plymouth "
DRACUTEOF
# Regenerate initramfs with new theme + dracut.conf.d picks. Remove
# stale initramfs first so the regen actually rewrites bytes.
rm -f /boot/initramfs-*.img 2>/dev/null || true
dracut --force --regenerate-all 2>&1 | tail -5 || true
# Verify cryptsetup landed in initramfs. If not, LUKS unlock is impossible
# and the user gets emergency shell on first boot. Surfacing this early.
KVER=$(ls /lib/modules | head -1)
if [ -n "$KVER" ] && [ -f "/boot/initramfs-${KVER}.img" ]; then
if ! lsinitrd "/boot/initramfs-${KVER}.img" 2>/dev/null | grep -q cryptsetup; then
echo "[ERR] cryptsetup not found in initramfs — LUKS unlock will fail"
fi
fi
# Regen grub.cfg with new branding (anaconda already wrote one; replace).
grub2-mkconfig -o /boot/grub2/grub.cfg 2>/dev/null || true
[ -f /boot/efi/EFI/fedora/grub.cfg ] && \
grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg 2>/dev/null || true
# Suppress the auto-generated "Fedora Linux 0-rescue-…" BLS entry. Fedora
# ships a rescue kernel image alongside every install; the BLS entry that
# points at it is created by `kernel-install` and shows up in GRUB as a
# second menu item. For a branded distro it's noisy + reveals "Fedora"
# in the menu. The rescue image itself is harmless to keep on disk.
# Match both `<machine-id>-0-rescue.conf` (current Fedora 43 layout) and
# `<machine-id>-0-rescue-<kver>.conf` (older layout). The earlier glob
# `*-0-rescue-*.conf` required a trailing hyphen and missed the new form.
rm -f /boot/loader/entries/*-0-rescue*.conf 2>/dev/null || true
# Hostname: default to "veilor" rather than the localhost-live / fedora
# fallback that anaconda writes. User can override post-install with
# `hostnamectl set-hostname`.
echo "veilor" > /etc/hostname
# /etc/machine-info — feeds `hostnamectl status` and some BLS title
# rendering paths. PRETTY_HOSTNAME is what GRUB titles read when
# GRUB_DISTRIBUTOR doesn't override (Fedora 43 BLS entry titles ignore
# GRUB_DISTRIBUTOR; this is the closest we can get without rewriting
# every entry's `title` line).
cat > /etc/machine-info <<'MACHINFO'
PRETTY_HOSTNAME="veilor-os"
MACHINFO
# Rewrite the `title` line of every remaining BLS entry to use our
# brand. `kernel-install` will write new entries with "Fedora Linux …"
# but the file we ship + every kernel update goes through grubby which
# preserves whatever title was last set.
for entry in /boot/loader/entries/*.conf; do
[ -f "$entry" ] || continue
# Pull kernel version out of the existing title (e.g. "6.17.1-300.fc43.x86_64")
kver=$(awk '/^version / {print $2}' "$entry")
sed -i "s|^title .*|title veilor-os (${kver})|" "$entry"
done
# Symlink display-manager.service → sddm.service. (Anaconda usually
# handles this when sddm is the only DM, but be explicit.)
ln -sf /usr/lib/systemd/system/sddm.service /etc/systemd/system/display-manager.service
# Real install boots straight to SDDM (NOT to TTY1 installer like live).
systemctl set-default graphical.target
# zram swap (no disk swap; keys never leak to platter)
cat > /etc/systemd/zram-generator.conf << 'EOF'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
EOF
# Enable services
systemctl enable veilor-firstboot.service 2>/dev/null || true
systemctl enable veilor-modules-lock.service 2>/dev/null || true
systemctl enable sshd fail2ban usbguard tuned auditd firewalld chronyd sddm
# Default tuned profile = balanced (AC/battery udev rule will override)
tuned-adm profile veilor-balanced 2>/dev/null || true
# Force admin to set a fresh password on first login. The `user` directive
# above already created the account with the user's chosen password —
# `chage -d 0` expires it so SDDM/passwd prompts for change immediately.
chage -d 0 admin 2>/dev/null || true
# Lock root explicitly (kickstart --lock should already do this)
passwd -l root
# Sanity: zero references to onyx / personal IPs in installed system
if grep -rqi 'onyx\|192\.168\.0\.\|fedora\.local' /etc/veilor* /etc/tuned/profiles/veilor-* 2>/dev/null; then
echo "[ERR] brand leak detected in /etc — investigate"
fi
echo "════════════════════════════════════════════════════════"
echo " veilor-os install complete"
echo "════════════════════════════════════════════════════════"
%end
# Reboot when done
reboot
KSEOF
# Substitute placeholders. Use | as sed delimiter. validate_pw()
# already rejects "$\`&|/\n at input — sed_escape() is defence in
# depth in case future code paths feed unsanitised values (e.g.
# locale/hostname from a file, or a relaxed validator).
# Detect cloud-init seed pubkey (if attached) → embed as anaconda
# `sshkey --username=admin` directive. Adds key to admin's
# authorized_keys at install time so SSH validation works first boot.
# No seed = empty directive line (anaconda treats blank line as no-op).
local seed_pubkey sshkey_directive=""
seed_pubkey=$(detect_seed_pubkey 2>/dev/null) || true
if [[ -n $seed_pubkey ]]; then
echo "[INFO] using cloud-init seed pubkey for admin authorized_keys"
# sshkey directive — quote pubkey, no shell meta in pubkeys.
sshkey_directive="sshkey --username=admin \"${seed_pubkey}\""
fi
sed -i \
-e "s|__LOCALE__|$(sed_escape "$SEL_LOCALE")|" \
-e "s|__HOSTNAME__|$(sed_escape "$SEL_HOSTNAME")|" \
-e "s|__DISK_BASENAME__|$(sed_escape "$disk_basename")|" \
-e "s|__LUKS_PW__|$(sed_escape "$SEL_LUKS_PW")|" \
-e "s|__ADMIN_PW__|$(sed_escape "$SEL_ADMIN_PW")|" \
-e "s|__SSHKEY_DIRECTIVE__|$(sed_escape "$sshkey_directive")|" \
"$out"
echo "[INFO] generated kickstart at $out"
return 0
}
_dump_logs_to_host() {
# If a virtio-9p share tagged "hostlogs" was attached by run-vm.sh,
# mount it and dump every anaconda + installer log into it. Runs on
# success AND failure (called via trap). No-op on real hardware where
# the 9p tag doesn't exist.
if ! grep -qw 9p /proc/filesystems 2>/dev/null; then return 0; fi
local m=/mnt/hostlogs
mkdir -p "$m"
mount -t 9p -o trans=virtio,version=9p2000.L hostlogs "$m" 2>/dev/null || return 0
cp -af /tmp/anaconda.log "$m/" 2>/dev/null || true
cp -af /tmp/program.log "$m/" 2>/dev/null || true
cp -af /tmp/storage.log "$m/" 2>/dev/null || true
cp -af /tmp/packaging.log "$m/" 2>/dev/null || true
cp -af /tmp/dnf.log "$m/" 2>/dev/null || true
cp -af /tmp/dnf.librepo.log "$m/" 2>/dev/null || true
cp -af /tmp/anaconda-cmdline.log "$m/" 2>/dev/null || true
cp -af /var/log/veilor-installer.log "$m/" 2>/dev/null || true
cp -af /run/install/veilor-generated.ks "$m/" 2>/dev/null || true
dmesg > "$m/dmesg.log" 2>/dev/null || true
journalctl -b > "$m/journal.log" 2>/dev/null || true
sync
umount "$m" 2>/dev/null || true
}
run_install() {
# Capture logs to host on every exit path (success, failure, ^C).
trap _dump_logs_to_host EXIT
# Anaconda env setup (see comments below).
export LANG="${SEL_LOCALE:-en_GB.UTF-8}"
export LC_ALL="$LANG"
# LANG / LC_ALL: anaconda's keyboard.activate_keyboard() raises
# AnacondaError: 'LANG' if missing. tty1 inherits no locale by default.
# XDG_RUNTIME_DIR: anaconda's display.setup_display() probes
# os.getenv("XDG_RUNTIME_DIR") + Wayland socket and crashes with
# TypeError on None. We're running unattended so no display needed,
# but env var still has to exist.
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 0700 "$XDG_RUNTIME_DIR"
if [[ $TUI == gum ]]; then
clear
# gum spin runs anaconda as subprocess. --show-output forwards
# stdout/stderr to /tmp/anaconda-cmdline.log so we can debug.
# --title shows live-updating header. --spinner dot is minimal.
local rc=0
gum spin --spinner dot \
--title "Installing veilor-os to $SEL_DISK · this takes 10-30 min · logs on tty2" \
--show-output \
-- bash -c 'anaconda --cmdline --kickstart=/run/install/veilor-generated.ks 2>&1 | tee /tmp/anaconda-cmdline.log' || rc=$?
if [[ $rc -eq 0 ]]; then
gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \
"✓ Install complete" \
"" \
"System will reboot in 5 seconds." \
"Remove the install media."
sleep 5
systemctl reboot
else
prompt_error "Anaconda exited non-zero (status $rc).
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
return 1
fi
else
prompt_message "Installing veilor-os to $SEL_DISK ...
This will take 10-30 minutes.
Logs: /var/log/veilor-installer.log + /tmp/anaconda.log"
sleep 1
if anaconda --cmdline --kickstart=/run/install/veilor-generated.ks; then
prompt_message "Install complete. System will reboot.
Remove the install media after shutdown."
sleep 3
systemctl reboot
else
prompt_error "Anaconda exited non-zero.
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
return 1
fi
fi
}
drop_to_shell() {
clear
cat << 'EOF'
═══════════════════════════════════════════════════
veilor-os live shell
═══════════════════════════════════════════════════
You are in a live, in-memory environment.
Nothing persists across reboot.
Re-run the installer: sudo veilor-installer
Reboot: sudo systemctl reboot
Power off: sudo systemctl poweroff
EOF
exec /bin/bash --login
}
launch_desktop() {
clear
echo "Launching KDE Plasma..."
sleep 1
systemctl isolate graphical.target
# systemd-isolate switches target; sddm spawns on tty1.
# If user logs out, they come back here. Loop continues.
}
# ── Entry ──
# (require_tty already called above before exec redirect — see top of file)
banner
while true; do
case "$(main_menu)" in
"Install")
if collect_answers && generate_ks; then
run_install || continue
fi
;;
"live · KDE") launch_desktop ;;
"live · shell") drop_to_shell ;;
"──────") banner ;; # separator clicked: redraw, no-op
"Reboot") systemctl reboot ;;
"Power off") systemctl poweroff ;;
"") banner ;; # ESC/cancel from menu: redraw
*) banner ;;
esac
done