From 1b3a64bc2acfdea4e03d77cc0ac6ef65b67bff77 Mon Sep 17 00:00:00 2001 From: veilor-org Date: Sat, 2 May 2026 04:13:49 +0100 Subject: [PATCH] v0.6: pre-stage veilor-update + veilor-doctor CLI tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-facing commands shipped in overlay/usr/local/bin/. Wraps dnf+flatpak update flow and read-only health diagnostic. Uses gum if available, plain output otherwise. No kickstart wiring yet beyond chmod — full integration in v0.6.0 release. --- docs/CLI.md | 128 +++++++++++++++ kickstart/veilor-os.ks | 2 +- overlay/usr/local/bin/veilor-doctor | 240 ++++++++++++++++++++++++++++ overlay/usr/local/bin/veilor-update | 94 +++++++++++ 4 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 docs/CLI.md create mode 100755 overlay/usr/local/bin/veilor-doctor create mode 100755 overlay/usr/local/bin/veilor-update diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..f9597f2 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,128 @@ +# veilor-os CLI + +User-facing commands shipped at `/usr/local/bin/`. Every veilor-* tool +is a small bash script — readable, auditable, no compiled bits. + +--- + +## `veilor-update` + +Wraps `dnf upgrade --refresh -y` plus `flatpak update -y`. One command +for "give me everything new". Mirrors the operator habit of always +patching both DNF and Flatpak — neither is sufficient on its own. + +**Usage:** + +```sh +veilor-update +``` + +**What it does:** + +1. Pings `mirrors.fedoraproject.org`. If unreachable, exits early with + a helpful message instead of letting `dnf` spin and time out. +2. Runs `sudo dnf upgrade --refresh -y` and tees output for live + progress. +3. Counts packages from the `Upgraded:`/`Installed:` lines of dnf + output and reports the total. +4. If `flatpak` is installed, runs `flatpak update -y`. +5. Compares running kernel to the newest installed kernel and prints + a reboot suggestion if they differ. + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | dnf and flatpak both succeeded | +| 1 | dnf upgrade failed | +| 2 | flatpak failed (dnf still ran successfully) | +| 3 | no network — pre-check failed | + +**Example:** + +``` +=== veilor-update: refreshing DNF metadata + applying updates === +... dnf output ... +=== veilor-update: updating flatpaks === +... flatpak output ... +=== veilor-update: complete === + Packages updated : 47 + Running kernel : 6.19.14-200.fc43.x86_64 + Newest kernel : 6.19.16-200.fc43.x86_64 (reboot suggested) +``` + +If `gum` is on the system, status banners render with colour and a +spinner; otherwise plain ASCII output. Either form is identical in +substance. + +--- + +## `veilor-doctor` + +Read-only diagnostic. Walks the v0.2 hardening checklist and reports +drift. Never modifies system state — fixes are a separate, deliberate +step. + +**Usage:** + +```sh +veilor-doctor # full coloured table +veilor-doctor --quiet # PASS/FAIL summary only +veilor-doctor --json # machine-readable JSON +``` + +**Sections checked:** + +| Section | Checks | +|------------|--------| +| System | hostname, OS, kernel, uptime | +| Hardening | SELinux mode, USBGuard active, fail2ban active, firewalld zone, `kernel.yama.ptrace_scope`, `kernel.kptr_restrict` | +| Disk | LUKS device + cipher, btrfs subvolume count, root free space | +| Network | NetworkManager state, default route, DNS servers, public IP | +| Updates | last `dnf history` entry, pending update count via `dnf check-update` | +| veilor | state of `veilor-firstboot.service` + `veilor-modules-lock.service` | + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | all checks passed | +| 1 | one or more checks failed | +| 2 | bad CLI flag | + +**Example output:** + +``` +── System ── + [OK] hostname veilor + [OK] os veilor-os + [OK] kernel 6.19.14-200.fc43.x86_64 + [OK] uptime up 3 hours, 21 minutes + +── Hardening ── + [OK] selinux Enforcing + [OK] usbguard active + [OK] fail2ban active + [OK] firewalld_zone drop + [OK] ptrace_scope 2 + [OK] kptr_restrict 2 + +── Disk ── + [OK] luks dm-0: aes-xts-plain64 + [OK] btrfs 4 subvolume(s) + [OK] root_free 72G free / 234G (32% used) + +19 checks passed. +``` + +`veilor-doctor --json` emits a single-line JSON object with `pass`, +`fail`, and `checks` keys. Suitable for piping into a monitoring +agent. + +--- + +## See also + +- `veilor-power` — switch tuned profile (save / mid / perf) +- `veilor-firstboot` — root-owned, runs once on first boot +- `veilor-installer` — TTY1 TUI installer (live ISO only) diff --git a/kickstart/veilor-os.ks b/kickstart/veilor-os.ks index 04f2ff9..e328797 100644 --- a/kickstart/veilor-os.ks +++ b/kickstart/veilor-os.ks @@ -205,7 +205,7 @@ echo " veilor-os install — %post" echo "════════════════════════════════════════════════════════" REPO=/usr/share/veilor-os -chmod +x $REPO/scripts/*.sh $REPO/scripts/selinux/*.sh /usr/local/bin/veilor-power /usr/local/sbin/veilor-firstboot /usr/local/sbin/veilor-installer +chmod +x $REPO/scripts/*.sh $REPO/scripts/selinux/*.sh /usr/local/bin/veilor-power /usr/local/bin/veilor-update /usr/local/bin/veilor-doctor /usr/local/sbin/veilor-firstboot /usr/local/sbin/veilor-installer # Live image plumbing (matches upstream Fedora live ks). Without these the # squashfs/EFI build fails — livesys-scripts ships systemd units lorax expects. diff --git a/overlay/usr/local/bin/veilor-doctor b/overlay/usr/local/bin/veilor-doctor new file mode 100755 index 0000000..e308695 --- /dev/null +++ b/overlay/usr/local/bin/veilor-doctor @@ -0,0 +1,240 @@ +#!/usr/bin/bash +# veilor-doctor — read-only diagnostic / health check. +# User-facing CLI shipped in /usr/local/bin/. v0.6 ergonomic tooling. +# +# Reports on system, hardening, disk, network, updates, veilor units. +# No fixes are ever applied — output only. Use this to verify drift +# from the v0.2+ baseline. +# +# Flags: +# --quiet print only PASS/FAIL summary +# --json emit JSON for monitoring +# -h|--help + +set -uo pipefail + +QUIET=0 +JSON=0 +for arg in "$@"; do + case "$arg" in + --quiet|-q) QUIET=1 ;; + --json) JSON=1 ;; + -h|--help) + sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } + +# ── Output helpers ────────────────────────────────────────────────── +PASS=0 +FAIL=0 +ROWS=() # human table rows: "Section|Check|Status|Detail" +JSON_ROWS=() # JSON-serialisable rows + +# Use color only if stdout is a TTY and we're not in --quiet/--json mode. +if [[ -t 1 && $QUIET -eq 0 && $JSON -eq 0 ]]; then + GREEN=$'\033[32m'; RED=$'\033[31m'; DIM=$'\033[2m'; OFF=$'\033[0m' +else + GREEN=""; RED=""; DIM=""; OFF="" +fi + +# JSON-escape a string for embedding. +json_esc() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\t'/\\t} + printf '%s' "$s" +} + +# check
+check() { + local section=$1 name=$2 status=$3 detail=$4 + if [[ $status == pass ]]; then + PASS=$((PASS+1)) + else + FAIL=$((FAIL+1)) + fi + ROWS+=("${section}|${name}|${status}|${detail}") + JSON_ROWS+=("{\"section\":\"$(json_esc "$section")\",\"name\":\"$(json_esc "$name")\",\"status\":\"$status\",\"detail\":\"$(json_esc "$detail")\"}") +} + +# ── 1. System ─────────────────────────────────────────────────────── +HOSTNAME_VAL=$(hostnamectl --static 2>/dev/null || hostname) +OS_NAME=$(. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-unknown}") +KERNEL=$(uname -r) +UPTIME=$(uptime -p 2>/dev/null || uptime) +check System hostname pass "$HOSTNAME_VAL" +check System os pass "$OS_NAME" +check System kernel pass "$KERNEL" +check System uptime pass "$UPTIME" + +# ── 2. Hardening ──────────────────────────────────────────────────── +SELINUX=$(getenforce 2>/dev/null || echo "unknown") +[[ $SELINUX == "Enforcing" ]] && check Hardening selinux pass "$SELINUX" \ + || check Hardening selinux fail "$SELINUX (expected Enforcing)" + +if systemctl is-active --quiet usbguard; then + check Hardening usbguard pass active +else + check Hardening usbguard fail "$(systemctl is-active usbguard 2>/dev/null || echo missing)" +fi + +if systemctl is-active --quiet fail2ban; then + check Hardening fail2ban pass active +else + check Hardening fail2ban fail "$(systemctl is-active fail2ban 2>/dev/null || echo missing)" +fi + +FW_ZONE=$(firewall-cmd --get-default-zone 2>/dev/null || echo unknown) +[[ $FW_ZONE == "drop" ]] && check Hardening firewalld_zone pass "$FW_ZONE" \ + || check Hardening firewalld_zone fail "$FW_ZONE (expected drop)" + +PTRACE=$(sysctl -n kernel.yama.ptrace_scope 2>/dev/null || echo "") +[[ ${PTRACE:-0} -ge 2 ]] && check Hardening ptrace_scope pass "$PTRACE" \ + || check Hardening ptrace_scope fail "${PTRACE:-unset} (expected >=2)" + +KPTR=$(sysctl -n kernel.kptr_restrict 2>/dev/null || echo "") +[[ ${KPTR:-0} -ge 2 ]] && check Hardening kptr_restrict pass "$KPTR" \ + || check Hardening kptr_restrict fail "${KPTR:-unset} (expected >=2)" + +# ── 3. Disk ───────────────────────────────────────────────────────── +LUKS_DEV=$(lsblk -lno NAME,TYPE 2>/dev/null | awk '$2=="crypt" {print $1; exit}') +if [[ -n $LUKS_DEV ]]; then + LUKS_STATUS=$(cryptsetup status "$LUKS_DEV" 2>/dev/null \ + | awk -F: '/cipher/ {gsub(/^ +/,"",$2); print $2; exit}') + check Disk luks pass "${LUKS_DEV}: ${LUKS_STATUS:-active}" +else + check Disk luks fail "no LUKS device found (full-disk encryption expected)" +fi + +if have btrfs && btrfs filesystem df / >/dev/null 2>&1; then + SUBVOLS=$(btrfs subvolume list / 2>/dev/null | wc -l) + check Disk btrfs pass "${SUBVOLS} subvolume(s)" +else + check Disk btrfs fail "btrfs not detected on /" +fi + +ROOT_FREE=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free / " $2 " (" $5 " used)"}') +check Disk root_free pass "${ROOT_FREE:-unknown}" + +# ── 4. Network ────────────────────────────────────────────────────── +if systemctl is-active --quiet NetworkManager; then + check Network networkmanager pass active +else + check Network networkmanager fail inactive +fi + +DEFAULT_ROUTE=$(ip -o route show default 2>/dev/null | awk '{print $3 " via " $5; exit}') +[[ -n $DEFAULT_ROUTE ]] && check Network default_route pass "$DEFAULT_ROUTE" \ + || check Network default_route fail "no default route" + +DNS_LIST=$(awk '/^nameserver/ {print $2}' /etc/resolv.conf 2>/dev/null \ + | paste -sd, - 2>/dev/null) +[[ -n $DNS_LIST ]] && check Network dns pass "$DNS_LIST" \ + || check Network dns fail "no nameservers" + +PUBLIC_IP=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "") +[[ -n $PUBLIC_IP ]] && check Network public_ip pass "$PUBLIC_IP" \ + || 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 + +# ── 6. veilor services ────────────────────────────────────────────── +for unit in veilor-firstboot.service veilor-modules-lock.service; do + if systemctl list-unit-files "$unit" 2>/dev/null | grep -q "$unit"; then + STATE=$(systemctl is-enabled "$unit" 2>/dev/null || echo unknown) + ACTIVE=$(systemctl is-active "$unit" 2>/dev/null || echo unknown) + # firstboot is meant to be one-shot + disabled after run. + check veilor "$unit" pass "${STATE} (${ACTIVE})" + else + check veilor "$unit" fail "unit not installed" + fi +done + +# ── Output ────────────────────────────────────────────────────────── +if [[ $JSON -eq 1 ]]; then + printf '{"pass":%d,"fail":%d,"checks":[' "$PASS" "$FAIL" + for i in "${!JSON_ROWS[@]}"; do + [[ $i -gt 0 ]] && printf ',' + printf '%s' "${JSON_ROWS[$i]}" + done + printf ']}\n' + [[ $FAIL -eq 0 ]] && exit 0 || exit 1 +fi + +if [[ $QUIET -eq 1 ]]; then + if [[ $FAIL -eq 0 ]]; then + echo "PASS ($PASS checks)" + exit 0 + else + echo "FAIL ($FAIL of $((PASS+FAIL)) checks failed)" + exit 1 + fi +fi + +_print_plain_table() { + local last_section="" + for r in "${ROWS[@]}"; do + IFS='|' read -r sec name status detail <<<"$r" + if [[ $sec != "$last_section" ]]; then + printf '\n%s%s%s\n' "$DIM" "── $sec ──" "$OFF" + last_section=$sec + fi + if [[ $status == pass ]]; then + printf ' %s[OK]%s %-20s %s\n' "$GREEN" "$OFF" "$name" "$detail" + else + printf ' %s[FAIL]%s %-20s %s\n' "$RED" "$OFF" "$name" "$detail" + fi + done +} + +# Pretty table — gum if available, else plain. gum table reads tab-separated +# input on stdin; we feed it the rows then fall back to the plain printer +# if gum exits non-zero (e.g. no TTY). +if have gum; then + { + printf 'Section\tCheck\tStatus\tDetail\n' + for r in "${ROWS[@]}"; do + IFS='|' read -r sec name status detail <<<"$r" + mark=$([[ $status == pass ]] && echo "OK" || echo "FAIL") + printf '%s\t%s\t%s\t%s\n' "$sec" "$name" "$mark" "$detail" + done + } | gum table --print --separator $'\t' 2>/dev/null || _print_plain_table +else + _print_plain_table +fi + +echo +if [[ $FAIL -eq 0 ]]; then + printf '%s%d checks passed.%s\n' "$GREEN" "$PASS" "$OFF" + exit 0 +else + printf '%s%d of %d checks failed.%s\n' "$RED" "$FAIL" "$((PASS+FAIL))" "$OFF" + exit 1 +fi diff --git a/overlay/usr/local/bin/veilor-update b/overlay/usr/local/bin/veilor-update new file mode 100755 index 0000000..9086804 --- /dev/null +++ b/overlay/usr/local/bin/veilor-update @@ -0,0 +1,94 @@ +#!/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. +# +# Exit codes: +# 0 success +# 1 dnf failed +# 2 flatpak failed (dnf 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 + printf '\n=== %s ===\n' "$1" + fi +} + +run_with_spinner() { + local title=$1; shift + if [[ -n $GUM ]]; then + gum spin --spinner dot --title "$title" -- "$@" + else + echo "[+] $title" + "$@" + fi +} + +# ── Pre-flight: network check ─────────────────────────────────────── +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\`." + 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." + 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 }') + +# ── Flatpak (best-effort) ─────────────────────────────────────────── +FLATPAK_RC=0 +if have flatpak; then + say "veilor-update: updating flatpaks" + if ! flatpak update -y; then + FLATPAK_RC=2 + echo " flatpak update failed; continuing anyway." + 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 }') + +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" +fi + +exit $FLATPAK_RC