v0.6: pre-stage veilor-update + veilor-doctor CLI tools

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.
This commit is contained in:
veilor-org 2026-05-02 04:13:49 +01:00
parent a7e7d6e10c
commit 1b3a64bc2a
4 changed files with 463 additions and 1 deletions

128
docs/CLI.md Normal file
View file

@ -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)

View file

@ -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.

View file

@ -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 <section> <name> <pass|fail> <detail>
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<NF;i++)printf "%s ", $i; print $NF; exit}')
[[ -n $LAST_DNF ]] && check Updates last_dnf pass "$LAST_DNF" \
|| check Updates last_dnf pass "(unknown — try \`sudo dnf history\`)"
# `dnf check-update` exits 100 if updates available, 0 if not.
sudo -n dnf check-update -q >/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

View file

@ -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