#!/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
[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
[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
[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
# # 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 # 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 # 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 # 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:-}" # 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 `-0-rescue.conf` (current Fedora 43 layout) and # `-0-rescue-.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 # v0.6: split the success screen into THREE stacked boxes. # # 1. Green success box — quiet confirmation. # 2. Yellow eject box — promoted out of the buried # one-liner the v0.5 success box used. Operators on # both onyx and the friend's RTX 4080 rig missed the # reminder and rebooted into the live ISO instead of # the install. Now it's its own loud thick-bordered # box that sits BELOW the success box and is # impossible to miss. # 3. Reboot countdown — embedded inside the green # success box so the operator can see "complete + # Xs to reboot" at a glance. # # Each tick clears + redraws all three, so the eject-media # box stays in front of the operator for the full 10-second # window and isn't scrolled off by a banner refresh. local secs for secs in 10 9 8 7 6 5 4 3 2 1; do clear gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \ "✓ Install complete" \ "" \ "Rebooting in ${secs}s..." gum style --foreground 3 --border thick --margin "0 2" --padding "1 3" \ --border-foreground 3 \ " Remove the install media NOW " \ "" \ " Unplug the USB stick / eject the DVD before " \ " reboot, otherwise the system will boot back " \ " into the live ISO instead of your fresh install. " sleep 1 done 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