veilor-os/overlay/usr/local/bin/veilor-installer
veilor-org affac5799d v0.5.26: log to /run, tolerate tee failure
User hit `/usr/local/bin/veilor-installer: line 33: /usr/bin/tee:
input/output error` on real-hardware install. Cause: LOG was
`/var/log/veilor-installer.log`, which on the live ISO is backed by an
overlay over squashfs. A bad sector / flaky USB → tee write fails →
process substitution dies → installer aborts before the menu renders.

Two changes:

1. Move LOG to /run/veilor-installer.log — pure tmpfs, never touches
   the live medium. Same path also unaffected by /var fill or overlay
   weirdness.

2. Wrap the `exec > >(tee -a $LOG) 2>&1` redirect in a writability
   probe. If the log can't be appended to (tmpfs OOM, fd exhaustion,
   anything), skip the tee and run the installer without on-disk
   persistence rather than crashing.

Persistence is a nice-to-have for post-mortem debugging; the installer
running is the must-have. This inverts the priority correctly.
2026-05-04 14:20:26 +01:00

763 lines
28 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/4] 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/4] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || return 1
# ── Admin password ──
admin_pw=$(prompt_password "[3/4] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || return 1
# ── Locale ──
locale=$(prompt_choose "[4/4] Locale" \
"en_GB.UTF-8" \
"en_US.UTF-8") || return 1
# ── 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
repo --name=updates --baseurl="https://download.fedoraproject.org/pub/fedora/linux/updates/43/Everything/x86_64/" --install
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
# sometimes skips the LUKS auto-args; without these, dracut's
# cryptsetup-generator never creates the unlock unit + dracut-initqueue
# loops on dev-disk-by-uuid forever. Detect LUKS partition + inject
# explicitly if missing.
LUKS_UUID=$(blkid -t TYPE=crypto_LUKS -o value -s UUID 2>/dev/null | head -1)
if [ -n "$LUKS_UUID" ] && ! grep -q "rd.luks.uuid" /etc/default/grub 2>/dev/null; then
sed -i "s|^GRUB_CMDLINE_LINUX=\"|GRUB_CMDLINE_LINUX=\"rd.luks.uuid=luks-${LUKS_UUID} |" /etc/default/grub
echo "[INFO] injected rd.luks.uuid=luks-${LUKS_UUID} into GRUB cmdline"
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
# 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