veilor-os/overlay/usr/local/bin/veilor-installer
veilor-org 01a37a98d4 v0.5.29: narrow anaconda patch + LUKS UX + initramfs assertion
Five-fix bundle from 7-agent research wave on the v0.5.28-final
gen_grub_cfgstub failure.

## 1. Narrow the anaconda transaction_progress patch (CRITICAL)

The v0.5.28 patch was too broad. It rewrote
`process_transaction_progress` so every 'error' token in the
transaction queue became a `log.warning`. That queue carries four
distinct error classes:

  - cpio_error      — payload extraction (genuinely fatal)
  - script_error    — RPM 6.0 cmdline-mode scriptlet warning-as-error
                      (the ONE we want to ignore)
  - unpack_error    — payload corruption (genuinely fatal)
  - error           — generic transaction error (genuinely fatal)

By swallowing all four we silently masked grub2-efi-x64's posttrans
failure mid-install. /boot/efi/EFI/fedora/ ended up incomplete →
gen_grub_cfgstub then failed at the bootloader install phase with
"gen_grub_cfgstub script failed" because its `set -eu` script
couldn't read the missing files.

v0.5.29 narrows the patch: override only the `script_error` callback
inside transaction_progress.py to log a warning and NOT enqueue
'error'. The consumer (`process_transaction_progress`) reverts to
upstream behaviour where cpio_error / unpack_error / error still
raise PayloadInstallationError. Real install-fatal events keep
aborting; only the F43-RPM-6.0 scriptlet regression is silenced.

The patch is applied via `python3 -c` regex rewrite (more robust
than nested sed across multi-line method bodies).

## 2. LUKS UX — `tries=5,timeout=0` (FIX)

Default cryptsetup-generator unit allows ONE passphrase try with a
1m30s wait. One typo on a long passphrase = wait 1m30s, then the
device-wait timer trips, then dracut emergency shell after 3min total.
Brutal. Adding `rd.luks.options=luks-XXX=tries=5,timeout=0` gives
five typo-friendly retries with no auto-timeout.

## 3. fbcon=nodefer on installed-system cmdline (FIX)

Live ISO cmdline already has `fbcon=nodefer` (added in v0.5.27 to fix
the real-laptop black-screen-after-dracut). The installed-system
bootloader directive in the generated install ks did NOT carry it.
Same KMS handoff happens on the installed system on the same hardware.
Now both have the flag.

## 4. /etc/crypttab fallback assertion (BELT-BRACES)

Anaconda's custom-partitioning code path normally writes /etc/crypttab
for `--encrypted` part directives. Edge cases observed in F43+ where
it doesn't. Without crypttab, systemd-cryptsetup-generator can still
work from kernel cmdline alone, but cleanup paths and second-stage
unlock both fall over. Adding a fallback `echo` that writes the
canonical line if it's missing post-anaconda.

## 5. Initramfs LUKS module assertion (DEFENSIVE)

Force-include `crypt + systemd-cryptsetup + plymouth` modules in
initramfs via /etc/dracut.conf.d/10-veilor-luks.conf. dracut autodetects
these when it sees an active LUKS mapping, but %post runs before the
LUKS state is fully observable from the chroot. Plus we wipe stale
initramfs (`rm -f /boot/initramfs-*.img`) before `--regenerate-all`
so the regen actually rewrites bytes. Final assertion runs
`lsinitrd | grep -q cryptsetup` and surfaces a [ERR] line in build
output if the module didn't make it.

## What this should fix

After the man-db fix in v0.5.28-final, install proceeded past
"Configuring xxx" cleanly but died at "Installing boot loader" with
gen_grub_cfgstub. Root-cause was the over-broad patch from #1 above.

After v0.5.29:
  - Install transaction completes (man-db excluded; non-man-db
    scriptlet warnings still suppressed; real errors still raise)
  - gen_grub_cfgstub runs against complete /boot/efi/EFI/fedora/
  - Bootloader install completes
  - Reboot to disk lands at GRUB veilor-os entry
  - Kernel + initramfs load (cryptsetup confirmed present)
  - Plymouth LUKS prompt appears with text fallback
  - User has 5 tries, no timeout
  - Unlock → btrfs subvol mount → systemd → SDDM

Files: kickstart/veilor-os.ks (+45 lines), overlay/usr/local/bin/veilor-installer (+50 lines).
Verified: bash -n clean, ksvalidator clean.

References:
  pyanaconda transaction_progress.py:110-136 (4 producers of 'error')
  pyanaconda bootloader/efi.py:194-201 (gen_grub_cfgstub call site)
  /usr/bin/gen_grub_cfgstub (set -eu wrapper for grub2-mkconfig stub)
  Fedora wiki Changes/RPM-6.0
  dnf5 issue #2507 (RPM 6.0 scriptlet propagation regression)
2026-05-05 05:12:24 +01:00

916 lines
36 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
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>
prompt_password() {
local header=$1
if [[ $TUI == gum ]]; then
gum input --password --header "$header"
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 ──
luks_pw=$(prompt_password "[2/3] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || return 1
# ── Admin password ──
admin_pw=$(prompt_password "[3/3] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || return 1
# ── 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 and any boot-time text remain
# visible on real GPU drivers (intel/amdgpu/nvidia). Already in live
# ISO cmdline; was previously missing from installed-system cmdline,
# which produced a black-screen boot on real hardware until KMS
# stabilised.
# Anaconda picks bootloader location (UEFI ESP or BIOS MBR) automatically;
# `--location=mbr` would be cargo-cult on UEFI and risky on multi-disk.
bootloader --append="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer"
# 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.
# 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
# Args:
# rd.luks.uuid=luks-XXX — tells dracut to expect a LUKS device,
# triggers cryptsetup-generator.
# rd.luks.options=...=tries=5 — five typo retries before giving up
# (default 1; one slip = emergency
# shell after 3min, terrible UX).
# rd.luks.options=...=timeout=0 — never time out unlock device wait
# (default 1m30s; slow user typing
# on a long passphrase still works).
# fbcon=nodefer — keep linux framebuffer console alive
# through KMS handoff. Without this on
# real laptops the plymouth LUKS prompt
# draws into a frozen framebuffer and
# the user sees a black screen with a
# blinking cursor. Already in the live
# ISO bootloader cmdline; missing from
# the installed-system bootloader line
# in the generated install ks above
# (also fixed there).
LUKS_ARGS="rd.luks.uuid=luks-${LUKS_UUID} rd.luks.options=luks-${LUKS_UUID}=tries=5,timeout=0 fbcon=nodefer"
# Path 1: persist into /etc/default/grub so future kernels inherit.
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
# Path 2: update existing BLS entries so the kernel that boots NEXT
# gets the args. grubby walks /boot/loader/entries/*.conf and edits
# the `options` line in-place.
grubby --update-kernel=ALL --args="${LUKS_ARGS}" 2>&1 | tail -5 || true
# Verification: every BLS entry MUST carry the LUKS arg now. Empty
# output = success.
drift=$(grep -L "rd.luks.uuid" /boot/loader/entries/*.conf 2>/dev/null)
if [ -n "$drift" ]; then
echo "[WARN] BLS entries missing rd.luks.uuid: $drift"
fi
echo "[INFO] injected ${LUKS_ARGS} into /etc/default/grub + BLS entries"
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.
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
}
run_install() {
# 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