diff --git a/overlay/etc/systemd/system/veilor-doctor.service b/overlay/etc/systemd/system/veilor-doctor.service new file mode 100644 index 0000000..aff4215 --- /dev/null +++ b/overlay/etc/systemd/system/veilor-doctor.service @@ -0,0 +1,7 @@ +[Unit] +Description=veilor-doctor — system health + drift check +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/veilor-doctor --quiet diff --git a/overlay/etc/systemd/system/veilor-doctor.timer b/overlay/etc/systemd/system/veilor-doctor.timer new file mode 100644 index 0000000..b1db81d --- /dev/null +++ b/overlay/etc/systemd/system/veilor-doctor.timer @@ -0,0 +1,10 @@ +[Unit] +Description=veilor-doctor weekly drift check + +[Timer] +OnCalendar=weekly +Persistent=true +RandomizedDelaySec=30m + +[Install] +WantedBy=timers.target diff --git a/overlay/etc/systemd/system/veilor-postinstall.service b/overlay/etc/systemd/system/veilor-postinstall.service new file mode 100644 index 0000000..9a7b057 --- /dev/null +++ b/overlay/etc/systemd/system/veilor-postinstall.service @@ -0,0 +1,17 @@ +[Unit] +Description=veilor-os one-time post-install TUI (first login) +After=graphical.target +ConditionPathExists=!/var/lib/veilor/postinstall-complete + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/veilor-postinstall +StandardInput=tty +StandardOutput=tty +StandardError=journal +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes + +[Install] +WantedBy=graphical.target multi-user.target diff --git a/overlay/usr/local/bin/veilor-doctor b/overlay/usr/local/bin/veilor-doctor index e308695..5323e76 100755 --- a/overlay/usr/local/bin/veilor-doctor +++ b/overlay/usr/local/bin/veilor-doctor @@ -147,23 +147,44 @@ PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "") || check Network public_ip fail "lookup timed out" # ── 5. Updates ────────────────────────────────────────────────────── -LAST_DNF=$(sudo -n dnf history list 2>/dev/null \ - | awk 'NR==4 {for(i=4;i/dev/null 2>&1 -RC=$? -case $RC in - 0) check Updates pending pass "system up-to-date" ;; - 100) - AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \ - | awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}') - check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`" - ;; - *) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;; -esac +# v0.7+ atomic — bootc replaces dnf as the update channel. Parse +# `bootc status --json` for the booted deployment + staged/cached image +# age. Fall back to dnf history if bootc not present (legacy v0.5.x). +if have bootc; then + BOOTC_JSON=$(sudo -n bootc status --json 2>/dev/null || echo "") + if [[ -n $BOOTC_JSON ]] && have jq; then + BOOTED_IMG=$(jq -r '.status.booted.image.image.image // "unknown"' <<<"$BOOTC_JSON") + BOOTED_DIGEST=$(jq -r '.status.booted.image.imageDigest // ""' <<<"$BOOTC_JSON") + check Updates booted_image pass "${BOOTED_IMG}@${BOOTED_DIGEST:0:12}" + STAGED=$(jq -r '.status.staged.image.image.image // ""' <<<"$BOOTC_JSON") + if [[ -n $STAGED ]]; then + check Updates staged_image fail "staged: $STAGED — reboot to apply" + else + check Updates staged_image pass "no staged update" + fi + else + check Updates bootc_state pass "bootc present (jq missing — install for richer detail)" + fi +elif have dnf; then + # Legacy v0.5.x kickstart-installed system. + LAST_DNF=$(sudo -n dnf history list 2>/dev/null \ + | awk 'NR==4 {for(i=4;i/dev/null 2>&1 + RC=$? + case $RC in + 0) check Updates pending pass "system up-to-date" ;; + 100) + AVAIL=$(sudo -n dnf check-update -q 2>/dev/null \ + | awk 'NF>=3 && $1!~/^Last/ {n++} END {print n+0}') + check Updates pending fail "${AVAIL} update(s) available — run \`veilor-update\`" + ;; + *) check Updates pending fail "dnf check-update returned $RC (need sudo?)" ;; + esac +else + check Updates channel fail "neither bootc nor dnf available" +fi # ── 6. veilor services ────────────────────────────────────────────── for unit in veilor-firstboot.service veilor-modules-lock.service; do diff --git a/overlay/usr/local/bin/veilor-postinstall b/overlay/usr/local/bin/veilor-postinstall new file mode 100755 index 0000000..2bd5b64 --- /dev/null +++ b/overlay/usr/local/bin/veilor-postinstall @@ -0,0 +1,178 @@ +#!/usr/bin/bash +# veilor-postinstall — first-login TUI on v0.7+ atomic systems. +# +# Runs ONCE on first SDDM login via the user-mode systemd unit +# `veilor-postinstall.service`. Asks the operator for the small set +# of decisions we deliberately defer from install time: +# - keyboard / locale +# - hostname override +# - GPU drivers (NVIDIA layered via rpm-ostree, mesa = no-op) +# - package preset (dev / media / homelab — additive, opt-out) +# - bluetooth opt-in +# - USBGuard policy snapshot +# - veilor-doctor first run +# Writes /var/lib/veilor/postinstall-complete on success and disables +# its own autostart unit. Idempotent: safe to re-run. +# +# Style: gum if present, plain bash read fallback. No decorative ASCII. + +set -uo pipefail +export TERM="${TERM:-linux}" + +STATE_DIR=/var/lib/veilor +DONE_MARKER="$STATE_DIR/postinstall-complete" +LOG=/var/log/veilor-postinstall.log + +have() { command -v "$1" >/dev/null 2>&1; } +GUM=$(have gum && echo gum || echo "") + +# Always log + tee to stdout for live progress. +mkdir -p "$STATE_DIR" 2>/dev/null || true +exec > >(tee -a "$LOG") 2>&1 + +if [[ -e $DONE_MARKER && ${1:-} != "--force" ]]; then + echo "veilor-postinstall already ran (marker: $DONE_MARKER). Pass --force to re-run." + exit 0 +fi + +# ── Wrappers ──────────────────────────────────────────────────────── +choose() { + local header=$1; shift + if [[ -n $GUM ]]; then + gum choose --header "$header" "$@" + else + echo + echo "$header" + local i=1 + for opt in "$@"; do printf ' %d) %s\n' "$i" "$opt"; ((i++)); done + local n + read -rp " choice (1-$#): " n + [[ $n -ge 1 && $n -le $# ]] || return 1 + eval "echo \${$n}" + fi +} + +ask() { + local prompt=$1 default=${2:-} + if [[ -n $GUM ]]; then + gum input --header "$prompt" --value "$default" + else + local v + read -rp "$prompt [$default] " v + echo "${v:-$default}" + fi +} + +confirm() { + local prompt=$1 + if [[ -n $GUM ]]; then + gum confirm "$prompt" && return 0 || return 1 + else + read -rp "$prompt [y/N] " y + [[ ${y,,} == y* ]] + fi +} + +say() { + if [[ -n $GUM ]]; then + gum style --foreground 212 --bold "$1" + else + printf '\n=== %s ===\n' "$1" + fi +} + +# Need root for several actions; re-exec under sudo if not root. +if [[ $EUID -ne 0 ]]; then + say "veilor-postinstall: sudo required" + exec sudo -E bash "$0" "$@" +fi + +say "veilor-postinstall — one-time setup" +echo " This runs once. Each step is skippable. Defaults are sane." +echo + +# ── 1. Keyboard layout ────────────────────────────────────────────── +KB=$(choose "Keyboard layout" us gb de fr es ru "skip") || KB=skip +if [[ $KB != skip ]]; then + localectl set-keymap "$KB" 2>/dev/null || true + echo " [OK] keymap = $KB" +fi + +# ── 2. Locale ─────────────────────────────────────────────────────── +LOC=$(choose "Locale" en_US.UTF-8 en_GB.UTF-8 de_DE.UTF-8 fr_FR.UTF-8 "skip") || LOC=skip +if [[ $LOC != skip ]]; then + localectl set-locale LANG="$LOC" 2>/dev/null || true + echo " [OK] locale = $LOC" +fi + +# ── 3. Hostname ───────────────────────────────────────────────────── +HN=$(ask "Hostname" "veilor") +if [[ -n $HN && $HN != $(hostnamectl --static 2>/dev/null) ]]; then + hostnamectl set-hostname "$HN" + echo " [OK] hostname = $HN" +fi + +# ── 4. GPU drivers ────────────────────────────────────────────────── +GPU=$(choose "GPU drivers" "Skip (use mesa defaults)" "NVIDIA proprietary (akmod-nvidia)" "Intel/AMD mesa (no-op)") || GPU=skip +case "$GPU" in + *NVIDIA*) + say "Layering NVIDIA driver — this takes a few minutes" + rpm-ostree install --idempotent akmod-nvidia xorg-x11-drv-nvidia-cuda \ + && echo " [OK] NVIDIA driver layered (reboot to use)" \ + || echo " [WARN] NVIDIA layer failed; check rpm-ostree status" + ;; + *) echo " (skipped GPU layering)" ;; +esac + +# ── 5. Package presets (multi-select) ─────────────────────────────── +say "Package presets — pick any combination (skip = none)" +PRESET_DEV="git tmux vim-enhanced htop podman skopeo" +PRESET_MEDIA="vlc obs-studio" +PRESET_HOMELAB="wireguard-tools jq yq tmux" + +PICKED=() +confirm "Install dev preset? ($PRESET_DEV)" && PICKED+=($PRESET_DEV) || true +confirm "Install media preset? ($PRESET_MEDIA)" && PICKED+=($PRESET_MEDIA) || true +confirm "Install homelab preset? ($PRESET_HOMELAB)" && PICKED+=($PRESET_HOMELAB) || true +if (( ${#PICKED[@]} > 0 )); then + # de-dupe + UNIQ=$(printf '%s\n' "${PICKED[@]}" | sort -u | tr '\n' ' ') + say "Layering: $UNIQ" + rpm-ostree install --idempotent $UNIQ \ + && echo " [OK] preset packages layered (reboot to use)" \ + || echo " [WARN] preset layer failed; check rpm-ostree status" +fi + +# ── 6. Bluetooth ──────────────────────────────────────────────────── +if confirm "Enable Bluetooth?"; then + systemctl enable --now bluetooth.service 2>/dev/null || true + echo " [OK] bluetooth enabled" +else + echo " (skipped bluetooth)" +fi + +# ── 7. USBGuard snapshot ──────────────────────────────────────────── +say "USBGuard policy snapshot" +echo " Plug in EVERY USB device you trust right now (keyboard," +echo " mouse, dock, yubikey, etc.) before continuing." +if confirm "Snapshot current USB devices into the allowlist?"; then + usbguard generate-policy > /etc/usbguard/rules.conf \ + && echo " [OK] policy written to /etc/usbguard/rules.conf" \ + || echo " [WARN] generate-policy failed" + systemctl restart usbguard 2>/dev/null || true +fi + +# ── 8. veilor-doctor ──────────────────────────────────────────────── +if confirm "Run veilor-doctor now?"; then + veilor-doctor || true +fi + +# ── Done ──────────────────────────────────────────────────────────── +date -u +"%Y-%m-%dT%H:%M:%SZ" > "$DONE_MARKER" +say "veilor-postinstall complete" +echo " Marker written: $DONE_MARKER" +echo " Disabling autostart unit so this never runs again." +systemctl --user --global disable veilor-postinstall.service 2>/dev/null || true +systemctl disable veilor-postinstall.service 2>/dev/null || true +echo +echo " If you layered any packages or drivers, reboot to activate." diff --git a/overlay/usr/local/bin/veilor-update b/overlay/usr/local/bin/veilor-update index 9086804..4aac36d 100755 --- a/overlay/usr/local/bin/veilor-update +++ b/overlay/usr/local/bin/veilor-update @@ -1,25 +1,22 @@ #!/usr/bin/bash -# veilor-update — system update wrapper. -# Wraps `dnf upgrade --refresh` + `flatpak update` behind a single command. -# User-facing CLI shipped in /usr/local/bin/. v0.6 ergonomic tooling. +# veilor-update — atomic update wrapper for v0.7+ (bootc + rpm-ostree). +# +# Wraps `bootc upgrade` + flatpak update behind a single command. +# Pre-checks rollback availability, pauses auditd while staging the +# new image, prints a clear post-state summary, and offers reboot. # # Exit codes: -# 0 success -# 1 dnf failed -# 2 flatpak failed (dnf still ran successfully) +# 0 success (with or without pending reboot) +# 1 bootc upgrade failed +# 2 flatpak failed (bootc still ran successfully) # 3 no network -# -# Uses `gum` for spinner output if present, falls back to plain stdout. set -uo pipefail -# ── Helpers ───────────────────────────────────────────────────────── have() { command -v "$1" >/dev/null 2>&1; } - GUM=$(have gum && echo gum || echo "") say() { - # Print a status line. Coloured if gum present, else plain. if [[ -n $GUM ]]; then gum style --foreground 212 --bold "$1" else @@ -27,46 +24,50 @@ say() { fi } -run_with_spinner() { - local title=$1; shift +confirm() { + local prompt=$1 if [[ -n $GUM ]]; then - gum spin --spinner dot --title "$title" -- "$@" + gum confirm "$prompt" else - echo "[+] $title" - "$@" + read -r -p "$prompt [y/N] " yn + [[ ${yn,,} == y* ]] fi } -# ── Pre-flight: network check ─────────────────────────────────────── +# ── Pre-flight: network ───────────────────────────────────────────── say "veilor-update: checking network" -if ! ping -c 1 -W 2 mirrors.fedoraproject.org >/dev/null 2>&1; then - echo - echo " No route to mirrors.fedoraproject.org." - echo " Connect to a network and re-run \`veilor-update\`." +if ! ping -c 1 -W 2 1.1.1.1 >/dev/null 2>&1; then + echo " No network. Connect and re-run \`veilor-update\`." exit 3 fi -# ── Snapshot kernel before upgrade so we can warn about reboot need ─ -KERNEL_BEFORE=$(uname -r) - -# ── DNF upgrade ───────────────────────────────────────────────────── -say "veilor-update: refreshing DNF metadata + applying updates" -# Capture upgrade output so we can count packages afterwards. Tee to -# stdout for live progress; swallow into a tempfile for the count. -LOG=$(mktemp -t veilor-update.XXXXXX) -trap 'rm -f "$LOG"' EXIT - -if ! sudo dnf upgrade --refresh -y 2>&1 | tee "$LOG"; then - echo - echo " dnf upgrade failed. See output above." +# ── Pre-flight: rollback target available ─────────────────────────── +# bootc has two deployments by design (booted + rollback). If +# something's wrong we want the user to see it before staging more. +if have bootc; then + say "veilor-update: bootc status" + bootc status || true +else + echo " bootc not present — this CLI targets v0.7+ atomic systems." exit 1 fi -# ── Count packages updated ────────────────────────────────────────── -# DNF prints "Upgraded: N", "Installed: N", "Removed: N" at end. -# Sum the upgrade/install lines for the user-visible total. -UPDATED=$(grep -E '^(Upgraded|Installed)\b' "$LOG" 2>/dev/null \ - | awk -F: '{ gsub(/[^0-9]/,"",$2); s+=$2 } END { print s+0 }') +# ── Pause auditd while staging ────────────────────────────────────── +# Reduces audit log noise during the heavy fs writes; resume after. +AUDIT_PAUSED=0 +if systemctl is-active auditd >/dev/null 2>&1; then + if sudo systemctl stop auditd 2>/dev/null; then + AUDIT_PAUSED=1 + fi +fi +trap '[[ $AUDIT_PAUSED == 1 ]] && sudo systemctl start auditd 2>/dev/null || true' EXIT + +# ── bootc upgrade ─────────────────────────────────────────────────── +say "veilor-update: bootc upgrade" +if ! sudo bootc upgrade; then + echo " bootc upgrade failed. See output above." + exit 1 +fi # ── Flatpak (best-effort) ─────────────────────────────────────────── FLATPAK_RC=0 @@ -74,21 +75,20 @@ if have flatpak; then say "veilor-update: updating flatpaks" if ! flatpak update -y; then FLATPAK_RC=2 - echo " flatpak update failed; continuing anyway." + echo " flatpak update failed; continuing." fi -else - echo " (flatpak not installed — skipping)" fi -# ── Post-update: reboot hint if kernel changed ────────────────────── -KERNEL_AFTER_LATEST=$(rpm -q kernel --last 2>/dev/null \ - | awk 'NR==1 { sub(/^kernel-/,"",$1); print $1 }') - +# ── Post-update summary ───────────────────────────────────────────── say "veilor-update: complete" -printf ' Packages updated : %s\n' "${UPDATED:-0}" -printf ' Running kernel : %s\n' "$KERNEL_BEFORE" -if [[ -n ${KERNEL_AFTER_LATEST:-} && $KERNEL_AFTER_LATEST != "$KERNEL_BEFORE" ]]; then - printf ' Newest kernel : %s (reboot suggested)\n' "$KERNEL_AFTER_LATEST" +bootc status 2>/dev/null | head -20 || true + +# ── Reboot prompt ─────────────────────────────────────────────────── +# bootc always writes the new image into the staged deployment; reboot +# is required for it to become the running root. +if confirm " Reboot now to activate the new image?"; then + say "veilor-update: rebooting" + sudo systemctl reboot fi exit $FLATPAK_RC