Anaconda's transaction died at "Configuring man-db.x86_64" in both v0.5.26 and v0.5.27 VM tests, reliably, days apart, against a freshly populated package cache. Same failure pattern, same package, with nothing in the visible error other than "The transaction process has ended with errors..". Pattern matches the same Fedora `updates` repo issue that the CI build kickstart already worked around by stripping the `updates` line entirely (`.github/workflows/build-iso.yml` line ~109). The installer-generated kickstart was adding the line back and re-introducing the bug for every user install. This commit aligns the install-time ks with the build-time ks: only the base `releases` repo is consumed by anaconda. Users who want updates run `dnf upgrade` post-install (or the v0.6 `veilor-update` wrapper). Trade-off: first-boot package versions are frozen to the Fedora 43 release date instead of including post-release updates. Acceptable — the alternative is "install reliably fails" which makes any freshness conversation moot. Verified locally: `bash -n` passes, ks template still well-formed. End-to-end re-validation goes through the next CI ISO + VM test run.
834 lines
32 KiB
Bash
834 lines
32 KiB
Bash
#!/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):
|
||
# --location=none: anaconda auto-places bootloader (UEFI grub2-efi or BIOS).
|
||
bootloader --append="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none"
|
||
|
||
# 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
|
||
|
||
%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
|
||
LUKS_ARGS="rd.luks.uuid=luks-${LUKS_UUID}"
|
||
|
||
# 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
|
||
|
||
echo "[INFO] injected ${LUKS_ARGS} into /etc/default/grub + BLS entries"
|
||
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
|
||
# Regenerate initramfs with new theme baked in (plymouth modules read
|
||
# theme at initramfs build time).
|
||
dracut --force --regenerate-all 2>&1 | tail -3 || true
|
||
|
||
# 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
|