Blueforce-Linux/admin/bf-menu
emirhanmamak-blueforce f8cdd1ed01 feat(admin): operator console, git-based install, optional credential delivery
Simplifies the primary path from "build a custom ISO" to "install from git and
run one menu", which is what the operator actually asked for. The ISO tooling
stays in the tree as a secondary/optional route and is not removed.

admin/bf-bootstrap.sh -- one command on Ubuntu:
- clones the repo to /opt/blueforce-linux or fast-forwards an existing checkout
  (a dirty tree is reported and skipped, never reset or stashed)
- installs git/curl/ansible-core, links admin/bf-* and scripts/*/bf-* into
  /usr/local/bin, and finishes with the exact next command
- --check is read-only, works without root, and mutates nothing; the piped form
  (curl ... | sudo -S -p '' bash) is guarded against eating the script

admin/bf-menu -- numbered operator console (categories 1-5):
- 1 installs THIS device locally; 2 and 5 drive the fleet through the existing
  21 Ansible playbooks; 3 prepares and verifies central integration without
  installing a central server; 4 holds local tools
- every operation picks one target: a dealer number or an approved wave group.
  Scope is derived from the playbook file itself, so a playbook whose declared
  scope disagrees with the console's table is refused rather than run
- the command is always shown before it runs; release-gated operations require
  a typed wave token, production requires a second token, and "all" runs
  wave-by-wave, stopping at the first failure
- audit log at /var/log/blueforce-console.log (mode 600, falls back to
  $XDG_STATE_HOME when not writable), recording sanitized target/command/exit
  only: no playbook output, no secrets

admin/bf-creds -- optional credential record, DEFAULT OFF:
- a bare run generates nothing and reports what already exists (device id,
  WireGuard public key, private key path only, RustDesk id)
- --generate writes /root/blueforce-credentials.txt (0600) and --fetch delivers
  it to admin/secrets/<dealer>.txt (0700/0600, git-ignored). The secret never
  reaches stdout, stderr or argv
- this does not change the SSH design: password auth stays off and the
  blueforce account stays locked, per docs/06. It is a local delivery aid only

docs/33-OPERATOR-CONSOLE.md -- step-by-step guide for an operator new to Linux
and DevOps: which command to type where, what the screen will ask, both
end-to-end sample sessions, the five most common errors, and the security rules.

Tests: run-all.sh -> 9/9 pass, exit 0 (adds check-admin-static.sh, 159 checks,
with negative controls and mutation-proven non-vacuity).
2026-09-16 15:26:17 +03:00

1626 lines
63 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# bf-menu -- numbered operator console for the Blueforce fleet.
#
# One entry point for two very different jobs:
# * category 1 installs THIS device locally, on the machine you are sitting at
# * categories 2 and 5 drive the fleet through the existing Ansible playbooks
# with an explicit --limit and never invent a new playbook
#
# Safety rules this console is built around:
# * every command is printed before it runs and must be confirmed,
# * destructive operations ask a second, explicit question,
# * wave-gated playbooks are never handed a gate flag silently: the operator
# has to confirm the gate (and type "production" for the production wave),
# * the audit log is mode 600 and never receives a secret: only the sanitized
# command, its exit status and the target are recorded (raw playbook output
# is shown on screen but never written to the log),
# * a missing Ansible, inventory or playbook produces an explanation, never a
# silent fallback to "something similar".
#
# Usage: admin/bf-menu [options] (see --help)
#
set -euo pipefail
BF_MENU_VERSION="1.0.0"
BF_MENU_NAME="admin/bf-menu"
DEFAULT_LOG_FILE="/var/log/blueforce-console.log"
INSTALLER_REL="scripts/install/blueforce-install.sh"
PLAYBOOK_DIR_REL="ansible/playbooks"
# ---------------------------------------------------------------------------
# Globals (set by argument parsing / discovery)
# ---------------------------------------------------------------------------
BF_REPO="${BF_REPO:-}"
BF_INVENTORY="${BF_INVENTORY:-}"
BF_LOG_FILE="${BF_CONSOLE_LOG:-$DEFAULT_LOG_FILE}"
BF_LOG_READY=0
BF_DRY_RUN=0
BF_INVENTORY_CACHE_FILE=""
BF_CMD=()
BF_EXTRA=()
BF_LAST_RC=0
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
bf_usage() {
cat <<EOF
Usage: $BF_MENU_NAME [options]
Numbered operator console for the Blueforce fleet ($BF_MENU_VERSION).
Category 1 installs THIS device locally with the one-click installer.
Categories 2 and 5 run the repository's existing Ansible playbooks against
the fleet, always with an explicit --limit. No new playbook is introduced
and no device outside the inventory is ever addressed.
Options:
-h, --help Show this help and exit
-V, --version Show the console version and exit
--repo PATH Repository root (default: parent directory of this script)
--inventory PATH Ansible inventory (default: \$repo/ansible/inventory/hosts.yml)
--log-file PATH Audit log (default: $DEFAULT_LOG_FILE)
--dry-run Print every command instead of running it
--no-color Disable ANSI colors (also honored: NO_COLOR)
--list Print the operation catalogue and exit
--list-targets Print inventory groups and host counts, then exit
Environment:
BF_REPO, BF_INVENTORY, BF_CONSOLE_LOG same as the options above
Exit status: 0 success or clean quit, 1 failure, 2 usage error.
EOF
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
bf_parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) bf_usage; exit 0 ;;
-V|--version) printf '%s %s\n' "$BF_MENU_NAME" "$BF_MENU_VERSION"; exit 0 ;;
--repo) [[ $# -ge 2 ]] || { printf 'ERROR: --repo needs a path\n' >&2; exit 2; }; BF_REPO="$2"; shift 2 ;;
--inventory) [[ $# -ge 2 ]] || { printf 'ERROR: --inventory needs a path\n' >&2; exit 2; }; BF_INVENTORY="$2"; shift 2 ;;
--log-file) [[ $# -ge 2 ]] || { printf 'ERROR: --log-file needs a path\n' >&2; exit 2; }; BF_LOG_FILE="$2"; shift 2 ;;
--dry-run) BF_DRY_RUN=1; shift ;;
--no-color) TUI_COLOR_MODE=never; shift ;;
--list) BF_LIST=1; shift ;;
--list-targets) BF_LIST_TARGETS=1; shift ;;
*) printf 'ERROR: unknown argument: %s\n' "$1" >&2; bf_usage >&2; exit 2 ;;
esac
done
}
BF_LIST=0
BF_LIST_TARGETS=0
# ---------------------------------------------------------------------------
# Repository discovery
# ---------------------------------------------------------------------------
bf_resolve_repo() {
local self self_real self_dir
if [[ -z "$BF_REPO" ]]; then
# bf-bootstrap.sh links this script into a bin directory, so the path we
# were invoked through is usually a symlink: resolve it before deriving
# the repository root.
self="${BASH_SOURCE[0]}"
self_real="$(readlink -f -- "$self" 2>/dev/null || printf '%s' "$self")"
self_dir="$(cd "$(dirname "$self_real")" && pwd)"
BF_REPO="$(cd "$self_dir/.." && pwd)"
fi
if [[ ! -d "$BF_REPO/ansible" || ! -d "$BF_REPO/scripts" ]]; then
printf 'ERROR: %s does not look like the Blueforce repository (no ansible/ and scripts/).\n' "$BF_REPO" >&2
printf ' Clone it and run the console from the checkout, or pass --repo PATH.\n' >&2
exit 2
fi
if [[ ! -r "$BF_REPO/admin/lib/tui.sh" || ! -r "$BF_REPO/admin/lib/targets.sh" ]]; then
printf 'ERROR: %s/admin/lib/tui.sh or targets.sh is missing.\n' "$BF_REPO" >&2
printf ' The console needs its libraries next to it (admin/lib/).\n' >&2
exit 2
fi
export BF_REPO
}
# ---------------------------------------------------------------------------
# Audit log: mode 600, sanitized, never a secret
# ---------------------------------------------------------------------------
# bf_log_sanitize -- single choke point before anything reaches the log file.
bf_log_sanitize() {
sed -E \
-e 's/((password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)[A-Za-z0-9_.-]*[[:space:]]*[=:])[^[:space:]]+/\1<redacted>/Ig' \
-e 's/(BEGIN [A-Z ]*PRIVATE KEY).*/\1 <redacted>/g'
}
bf_log() {
local level="$1"; shift
[[ "$BF_LOG_READY" -eq 1 ]] || return 0
printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$(printf '%s' "$*" | bf_log_sanitize)" >>"$BF_LOG_FILE"
}
# bf_log_init -- keep the log at mode 600 or refuse to log into an insecure
# path. Falls back to a per-user state directory when /var/log is not writable.
bf_log_init() {
local fallback_dir candidate
fallback_dir="${XDG_STATE_HOME:-$HOME/.local/state}/blueforce"
for candidate in "$BF_LOG_FILE" "$fallback_dir/blueforce-console.log"; do
[[ -n "$candidate" ]] || continue
if [[ -e "$candidate" ]]; then
[[ -w "$candidate" ]] || continue
else
mkdir -p "$(dirname "$candidate")" 2>/dev/null || continue
[[ -w "$(dirname "$candidate")" ]] || continue
fi
if [[ -e "$candidate" ]]; then
chmod 600 "$candidate" 2>/dev/null || continue
else
( umask 077; : >"$candidate" ) 2>/dev/null || continue
chmod 600 "$candidate" 2>/dev/null || continue
fi
BF_LOG_FILE="$candidate"
BF_LOG_READY=1
if [[ "$candidate" != "${BF_LOG_REQUESTED:-$DEFAULT_LOG_FILE}" ]]; then
printf '[WARN] %s is not writable; logging to %s instead (mode 600).\n' \
"${BF_LOG_REQUESTED:-$DEFAULT_LOG_FILE}" "$candidate" >&2
fi
return 0
done
BF_LOG_READY=0
printf '[WARN] no writable audit log found; this session will not be logged.\n' >&2
return 0
}
bf_cleanup() {
if [[ -n "$BF_INVENTORY_CACHE_FILE" && -f "$BF_INVENTORY_CACHE_FILE" ]]; then
rm -f "$BF_INVENTORY_CACHE_FILE"
fi
}
bf_quit() {
local code="${1:-0}"
tui_note "Leaving the Blueforce console."
exit "$code"
}
# ---------------------------------------------------------------------------
# Runtime checks with explanations instead of guesses
# ---------------------------------------------------------------------------
bf_have_ansible() {
command -v ansible-playbook >/dev/null 2>&1
}
bf_require_ansible() {
if bf_have_ansible; then
return 0
fi
tui_error "ansible-playbook was not found on this machine; fleet operations cannot run."
tui_note "The console does not emulate Ansible: it drives the repository's playbooks (docs/09)."
tui_note "Install it on the admin machine, e.g.:"
tui_note " sudo apt install ansible # or: pipx install ansible-core"
return 1
}
bf_require_inventory() {
local resolved
# stdout carries the resolved path, stderr carries the explanation: never
# merge them, or an error message would end up inside the path.
if resolved="$(bf_inventory_resolve)"; then
BF_INVENTORY="$resolved"
export BF_INVENTORY
return 0
fi
return 1
}
bf_require_root() {
if [[ "$(id -u)" -eq 0 ]]; then
return 0
fi
tui_error "This operation needs root on THIS device."
tui_note "Re-run the console with sudo: sudo $BF_MENU_NAME"
return 1
}
# bf_tool_path NAME -> preferred path of a diagnostics/maintenance tool.
# A tool installed under /usr/local/bin wins (that is the canonical Field OS
# location); otherwise the copy in this checkout is used.
bf_tool_path() {
local name="$1"
local sys="/usr/local/bin/$name"
local repo="$BF_REPO/scripts/diagnostics/$name"
local maint="$BF_REPO/scripts/maintenance/$name"
if [[ -x "$sys" ]]; then printf '%s\n' "$sys"; return 0; fi
if [[ -x "$repo" ]]; then printf '%s\n' "$repo"; return 0; fi
if [[ -x "$maint" ]]; then printf '%s\n' "$maint"; return 0; fi
return 1
}
bf_installer_path() {
local p="$BF_REPO/$INSTALLER_REL"
[[ -f "$p" ]] && printf '%s\n' "$p"
}
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
# bf_cmd_string -- the command as a copy/pasteable shell line.
bf_cmd_string() {
printf '%q ' "${BF_CMD[@]}"
}
# bf_exec -- run BF_CMD (or print it under --dry-run). Sets BF_LAST_RC.
bf_exec() {
local printable rc=0
printable="$(bf_cmd_string)"
printf '\n'
tui_section "Command"
printf ' %s\n\n' "$printable"
if [[ "$BF_DRY_RUN" -eq 1 ]]; then
tui_warn "dry-run: the command above was NOT executed."
bf_log "DRYRUN" "$printable"
BF_LAST_RC=0
return 0
fi
bf_log "RUN" "$printable"
set +e
"${BF_CMD[@]}"
rc=$?
set -e
BF_LAST_RC="$rc"
if [[ "$rc" -eq 0 ]]; then
tui_ok "Command finished successfully (exit 0)."
bf_log "OK" "exit=0 :: $printable"
else
tui_error "Command failed with exit $rc."
bf_log "FAIL" "exit=$rc :: $printable"
fi
return 0
}
# bf_confirm_and_exec LABEL DESTRUCTIVE
# Shows the command, asks for confirmation, then runs it. Returns 0 when the
# command was executed (including a confirmed --check run), 1 when cancelled.
bf_confirm_and_exec() {
local label="$1" destructive="${2:-no}" answer=0
if [[ "$BF_DRY_RUN" -eq 1 ]]; then
bf_exec
return 0
fi
if [[ "$destructive" == "yes" ]]; then
tui_warn "State-changing operation: $label"
tui_warn "Read the command above before confirming; it changes the selected target(s)."
tui_ask_run "Proceed? " || answer=$?
else
tui_ask_run "Run this command?" || answer=$?
fi
case "$answer" in
0) : ;;
2)
BF_CMD+=(--check)
tui_info "check mode: the command runs with --check and applies no change."
;;
*)
tui_info "Cancelled; nothing was run."
bf_log "CANCEL" "aborted by operator :: $(bf_cmd_string)"
return 1
;;
esac
bf_exec
return 0
}
# ---------------------------------------------------------------------------
# Operation catalogue
# ---------------------------------------------------------------------------
# Fields: id|label|playbook|scope|destructive|prompt|description
# scope simple | wave | release (cross-checked against the playbook)
# destructive yes|no (second confirmation)
# prompt none | packages | service | script | grace | disk_pct |
# log_lines | expected_version
BF_OPS_REPORT=(
"ping|Ping / reachability|bf-ping.yml|simple|no|none|SSH reachability check (read-only)"
"uptime|Uptime|bf-uptime.yml|simple|no|none|Uptime and load (read-only)"
"disk|Disk usage|bf-disk-check.yml|simple|no|disk_pct|Disk usage, warns above a threshold"
"docker|Docker status|bf-docker-status.yml|simple|no|none|Docker daemon and containers (read-only)"
"packages|Package versions|bf-package-check.yml|simple|no|none|Installed versions of pinned packages"
"security-updates|Pending security updates|bf-security-updates-check.yml|simple|no|none|Report only, never installs"
"provisioning-status|Provisioning phase report|bf-provisioning-status.yml|wave|no|none|state.json phase per device"
"enrollment-status|Enrollment + central evidence|bf-enrollment-status.yml|wave|no|none|ENROLLED/READY claims vs central proof"
"fieldos-version|Field OS version report|bf-fieldos-version.yml|wave|no|expected_version|Version per device, optional drift check"
"remote-channels|Remote access channels|bf-remote-channels-check.yml|wave|no|none|SSH/xRDP/RustDesk/WireGuard readiness"
"offline-ready|Offline readiness report|bf-offline-ready-check.yml|wave|no|none|Local preconditions for offline provisioning"
)
BF_OPS_MAINTENANCE=(
"rdp-restart|Restart RDP|bf-rdp-restart.yml|simple|yes|none|Restart xrdp"
"rustdesk-restart|Restart RustDesk|bf-rustdesk-restart.yml|simple|yes|none|Restart the RustDesk service"
"wireguard-restart|Restart WireGuard|bf-wireguard-restart.yml|simple|yes|none|Restart wg-quick@wg0"
"service-restart|Restart an allowlisted service|bf-service-restart.yml|simple|yes|service|Allowlisted service names only"
"gui-off|GUI off (headless)|bf-gui-off.yml|wave|yes|none|Switch the wave to multi-user.target"
"gui-on|GUI on (maintenance)|bf-gui-on.yml|wave|yes|none|Start the graphical session again"
"collect-logs|Collect logs / support bundle|bf-collect-logs.yml|wave|no|log_lines|Timestamped journal + snapshot bundle"
"run-script|Run an approved script|bf-run-script.yml|wave|yes|script|Only /opt/blueforce/bin and /usr/local/sbin"
"reboot|Controlled reboot|bf-reboot.yml|release|yes|grace|Serial reboots with a health gate"
"deploy-update|Deploy approved package update|bf-deploy-update.yml|release|yes|packages|Pinned package=version, health-gated"
)
bf_op_field() {
local row="$1" field="$2"
printf '%s' "$row" | awk -F'|' -v n="$field" '{print $n}'
}
# ---------------------------------------------------------------------------
# Playbook helpers: the playbook file stays the source of truth for its gates
# ---------------------------------------------------------------------------
# bf_playbook_var_present PLAYBOOK VAR -- true when VAR is used outside comments.
bf_playbook_var_present() {
local pb="$1" var="$2" file="$BF_REPO/$PLAYBOOK_DIR_REL/$1"
[[ -r "$file" ]] || return 1
grep -vE '^[[:space:]]*#' "$file" | grep -qE "\\b${var}\\b"
}
# bf_playbook_scope PLAYBOOK -> wave|release|simple, derived from the playbook.
bf_playbook_scope() {
local pb="$1"
if bf_playbook_var_present "$pb" release_wave; then
printf 'release\n'
return 0
fi
if bf_playbook_var_present "$pb" operation_wave; then
printf 'wave\n'
return 0
fi
printf 'simple\n'
}
# bf_playbook_check PLAYBOOK DECLARED_SCOPE -- existence plus a metadata
# cross-check, so a console table that drifted from the playbook can never
# silently drop a wave gate.
bf_playbook_check() {
local pb="$1" declared="$2" file="$BF_REPO/$PLAYBOOK_DIR_REL/$1" actual
if [[ ! -r "$file" ]]; then
tui_error "Playbook not found: ansible/playbooks/$pb"
tui_note "Expected at: $file"
tui_note "Set --repo to the repository checkout that contains it."
return 1
fi
actual="$(bf_playbook_scope "$pb")"
if [[ "$actual" != "$declared" ]]; then
tui_error "Refusing to run $pb: the console expects scope '$declared' but the playbook declares '$actual'."
tui_note "The playbook is the source of truth for its gates; update admin/bf-menu before using it."
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# Extra -e variables, validated with the same rules the playbooks enforce
# ---------------------------------------------------------------------------
BF_SERVICE_ALLOWLIST=(blueforce-agent docker ssh sshd systemd-journald cron)
BF_SCRIPT_DIRS=(/opt/blueforce/bin /usr/local/sbin)
bf_prompt_extra() {
local kind="$1"
BF_EXTRA=()
case "$kind" in
none) return 0 ;;
packages) bf_prompt_packages ;;
service) bf_prompt_service ;;
script) bf_prompt_script ;;
grace) bf_prompt_grace ;;
disk_pct) bf_prompt_disk_pct ;;
log_lines) bf_prompt_log_lines ;;
expected_version) bf_prompt_expected_version ;;
*) tui_error "Console bug: unknown prompt kind '$kind'."; return 1 ;;
esac
}
bf_prompt_packages() {
local pins=() pin array=()
tui_note "Exact pins only: package=version (the playbook refuses ranges, 'latest' and empty lists)."
tui_prompt "Packages (comma separated)" "" || return 1
if [[ -z "$TUI_REPLY" ]]; then
tui_error "At least one pinned package is required."
return 1
fi
IFS=',' read -r -a pins <<<"$TUI_REPLY"
for pin in "${pins[@]}"; do
pin="${pin#"${pin%%[![:space:]]*}"}"
pin="${pin%"${pin##*[![:space:]]}"}"
[[ -n "$pin" ]] || continue
if [[ ! "$pin" =~ ^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$ ]]; then
tui_error "Invalid pin '$pin': expected package=version (e.g. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)."
return 1
fi
array+=("\"$pin\"")
done
[[ ${#array[@]} -gt 0 ]] || { tui_error "No usable package pin was given."; return 1; }
BF_EXTRA=(-e "update_packages=[$(IFS=,; printf '%s' "${array[*]}")]")
return 0
}
bf_prompt_service() {
local name
tui_note "Allowlist: ${BF_SERVICE_ALLOWLIST[*]}"
tui_prompt "Service name" "" || return 1
name="$TUI_REPLY"
for allowed in "${BF_SERVICE_ALLOWLIST[@]}"; do
if [[ "$name" == "$allowed" ]]; then
BF_EXTRA=(-e "service_name=$name")
return 0
fi
done
tui_error "Refusing '$name': not in the service allowlist (the playbook would fail closed anyway)."
return 1
}
bf_prompt_script() {
local path args dir ok=0
tui_note "Allowed directories: ${BF_SCRIPT_DIRS[*]}"
tui_prompt "Script path" "" || return 1
path="$TUI_REPLY"
if [[ "$path" != /* ]]; then
tui_error "Script path must be absolute."
return 1
fi
for dir in "${BF_SCRIPT_DIRS[@]}"; do
[[ "$path" == "$dir"/* ]] && ok=1
done
if [[ "$ok" -ne 1 ]]; then
tui_error "Refusing '$path': outside the approved directories (the playbook would refuse it too)."
return 1
fi
tui_prompt "Script arguments (optional)" "" || return 1
args="$TUI_REPLY"
BF_EXTRA=(-e "script_path=$path")
if [[ -n "$args" ]]; then
BF_EXTRA+=(-e "script_args=$args")
fi
return 0
}
bf_prompt_grace() {
local seconds
tui_prompt "Reboot grace period in seconds" "300" || return 1
seconds="$TUI_REPLY"
if [[ ! "$seconds" =~ ^[0-9]+$ || "$seconds" -lt 0 ]]; then
tui_error "Grace period must be a whole number of seconds."
return 1
fi
BF_EXTRA=(-e "reboot_grace_seconds=$seconds")
return 0
}
bf_prompt_disk_pct() {
local pct
tui_prompt "Warn above percent (blank = playbook default 80)" "" || return 1
pct="$TUI_REPLY"
if [[ -z "$pct" ]]; then
return 0
fi
if [[ ! "$pct" =~ ^[0-9]+$ || "$pct" -lt 1 || "$pct" -gt 100 ]]; then
tui_error "Threshold must be between 1 and 100."
return 1
fi
BF_EXTRA=(-e "disk_warn_pct=$pct")
return 0
}
bf_prompt_log_lines() {
local lines
tui_prompt "Journal lines per device" "500" || return 1
lines="$TUI_REPLY"
if [[ ! "$lines" =~ ^[0-9]+$ || "$lines" -lt 1 ]]; then
tui_error "Line count must be a positive whole number."
return 1
fi
BF_EXTRA=(-e "log_lines=$lines")
return 0
}
bf_prompt_expected_version() {
local version
tui_prompt "Expected Field OS version (blank = no drift check)" "" || return 1
version="$TUI_REPLY"
if [[ -z "$version" ]]; then
return 0
fi
BF_EXTRA=(-e "fieldos_expected_version=$version")
return 0
}
# ---------------------------------------------------------------------------
# Ansible command construction
# ---------------------------------------------------------------------------
bf_build_ansible_cmd() {
local pb="$1" scope="$2" wave="${3:-}" gate_confirmed="${4:-no}"
# Single choke point: no playbook command is ever built without a target.
if ! bf_target_is_set; then
return 1
fi
BF_CMD=(ansible-playbook -i "$BF_INVENTORY" "$BF_REPO/$PLAYBOOK_DIR_REL/$pb" --limit "$BF_TARGET_LIMIT")
case "$scope" in
wave)
BF_CMD+=(-e "operation_wave=$wave" -e operation_confirmed=true)
if [[ "$wave" == "production" ]]; then
if bf_playbook_var_present "$pb" production_override; then
BF_CMD+=(-e production_override=true)
fi
fi
;;
release)
BF_CMD+=(-e "release_wave=$wave")
if [[ "$gate_confirmed" == "yes" ]] && bf_playbook_var_present "$pb" wave_gate_confirmed; then
BF_CMD+=(-e wave_gate_confirmed=true)
fi
if bf_playbook_var_present "$pb" reboot_confirmed; then
BF_CMD+=(-e reboot_confirmed=true)
fi
if [[ "$wave" == "production" ]]; then
if bf_playbook_var_present "$pb" production_override; then
BF_CMD+=(-e production_override=true)
fi
if bf_playbook_var_present "$pb" allow_production_reboot; then
BF_CMD+=(-e allow_production_reboot=true)
fi
fi
;;
esac
if [[ ${#BF_EXTRA[@]} -gt 0 ]]; then
BF_CMD+=("${BF_EXTRA[@]}")
fi
return 0
}
# bf_confirm_wave_gate WAVE PLAYBOOK -- reproduce the playbook's own gate
# contract as an explicit operator statement. Nothing is auto-confirmed.
bf_confirm_wave_gate() {
local wave="$1" pb="$2"
if [[ "$wave" != "production" ]]; then
return 0
fi
tui_warn "Wave 'production' is the live fleet: $pb refuses to run without an explicit production override."
if ! tui_confirm_token "Type production to confirm a PRODUCTION run." "production"; then
tui_info "Production confirmation not given."
return 1
fi
return 0
}
bf_confirm_release_gate() {
local pb="$1"
if ! bf_playbook_var_present "$pb" wave_gate_confirmed; then
return 0
fi
tui_warn "$pb refuses to run unless the wave gate is confirmed."
tui_warn "Confirm only after the previous wave's health gate and the central approval have passed (docs/10)."
if ! tui_confirm_token "Wave gate confirmation." "GATE"; then
tui_info "Wave gate not confirmed."
return 1
fi
return 0
}
# ---------------------------------------------------------------------------
# The device-operation runner
# ---------------------------------------------------------------------------
# bf_show_operation_header ROW -- always show what is about to happen.
bf_show_operation_header() {
local row="$1"
tui_kv "Operation" "$(bf_op_field "$row" 2)"
tui_kv "Playbook" "ansible/playbooks/$(bf_op_field "$row" 3)"
tui_kv "Scope" "$(bf_op_field "$row" 4)"
tui_kv "Inventory" "${BF_INVENTORY:-unresolved}"
tui_kv "Target" "${BF_TARGET_LABEL:-unset}"
if [[ -n "${BF_TARGET_WAVE:-}" ]]; then
tui_kv "Wave" "$BF_TARGET_WAVE"
fi
printf '\n'
}
# bf_run_wave_once ROW WAVE -- build, confirm and run one wave.
bf_run_wave_once() {
local row="$1" wave="$2" pb="$3" destructive="$4" gate="no" scope
scope="$(bf_op_field "$row" 4)"
# The production token only applies where the playbook actually carries a
# wave/release gate; a simple playbook on a production device does not.
if [[ "$scope" != "simple" ]] && ! bf_confirm_wave_gate "$wave" "$pb"; then
return 1
fi
if ! bf_confirm_release_gate "$pb"; then
return 1
fi
if bf_playbook_var_present "$pb" wave_gate_confirmed; then
gate="yes"
fi
if ! bf_build_ansible_cmd "$pb" "$(bf_op_field "$row" 4)" "$wave" "$gate"; then
tui_error "No target is selected; refusing to build a command."
return 1
fi
printf '\n'
tui_section "Command"
printf ' %s\n\n' "$(bf_cmd_string)"
# Fleet-wide scope is the one selection that can hurt many devices at once:
# it needs its own explicit confirmation, not just the run prompt.
if [[ "${BF_TARGET_KIND:-}" == "all" ]]; then
tui_warn "FLEET-WIDE: this command targets every device in the inventory (${BF_TARGET_COUNT} device(s))."
if ! tui_confirm_token "Fleet-wide scope confirmation." "all"; then
tui_info "Fleet-wide confirmation not given."
bf_log "CANCEL" "fleet-wide confirmation withheld :: $(bf_cmd_string)"
return 1
fi
fi
bf_confirm_and_exec "$(bf_op_field "$row" 2) on wave '$wave'" "$destructive" || return 1
return 0
}
# bf_run_row ROW -- full flow: target selection, extra vars, execution.
bf_run_row() {
local row="$1" id pb declared scope prompt destructive mode wave count
id="$(bf_op_field "$row" 1)"
pb="$(bf_op_field "$row" 3)"
declared="$(bf_op_field "$row" 4)"
destructive="$(bf_op_field "$row" 5)"
prompt="$(bf_op_field "$row" 6)"
bf_require_ansible || return 1
bf_require_inventory || return 1
bf_playbook_check "$pb" "$declared" || return 1
scope="$declared"
case "$scope" in
wave) mode="wave" ;;
release) mode="release" ;;
*) mode="any" ;;
esac
tui_screen "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)"
tui_note "Choose the target: dealer number, group, or all."
if [[ "$scope" == "release" ]]; then
tui_note "Release-gated playbooks accept one approved wave, never a single device."
fi
printf '\n'
if ! bf_target_select "$mode"; then
return 0
fi
if ! bf_prompt_extra "$prompt"; then
tui_warn "No command was built."
tui_pause
return 0
fi
bf_log "TARGET" "$id target=${BF_TARGET_LIMIT} kind=${BF_TARGET_KIND} wave=${BF_TARGET_WAVE:-none}"
if [[ "$scope" == "wave" && "$BF_TARGET_KIND" == "all" ]]; then
tui_screen "$(bf_op_field "$row" 2)" "wave by wave, stops at the first failure"
tui_warn "This playbook is wave-scoped: the fleet is processed one wave at a time."
tui_warn "Order: $(bf_waves_csv | tr ',' ' '). Each wave is shown and confirmed separately."
printf '\n'
if ! tui_confirm "Start with the first wave?"; then
tui_info "Cancelled; nothing was run."
return 0
fi
# An array, not `while read < <(...)`: a redirection on a loop would also
# hijack the stdin that the confirmation prompts read from.
local waves=()
mapfile -t waves < <(bf_wave_groups_present || true)
for wave in "${waves[@]}"; do
[[ -n "$wave" ]] || continue
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$wave" || true)")"
tui_screen "$(bf_op_field "$row" 2)" "wave $wave -- $count device(s)"
if ! tui_confirm_token "About to run on wave '$wave' ($count device(s))." "$wave"; then
tui_info "Wave '$wave' skipped by the operator."
tui_pause
continue
fi
BF_TARGET_KIND="group"
BF_TARGET_LIMIT="$wave"
BF_TARGET_WAVE="$wave"
BF_TARGET_COUNT="$count"
BF_TARGET_LABEL="wave '$wave' ($count device(s))"
if ! bf_run_wave_once "$row" "$wave" "$pb" "$destructive"; then
tui_error "Wave '$wave' did not run to completion; stopping the sequence (no later wave is touched)."
bf_log "STOP" "$id stopped after wave=$wave"
tui_pause
return 1
fi
if [[ "$BF_LAST_RC" -ne 0 ]]; then
tui_error "Wave '$wave' failed (exit $BF_LAST_RC); stopping the sequence."
bf_log "STOP" "$id failed at wave=$wave"
tui_pause
return 1
fi
tui_pause
done
return 0
fi
wave="${BF_TARGET_WAVE:-}"
if [[ "$scope" == "wave" && -z "$wave" ]]; then
tui_error "This playbook is wave-scoped but the target carries no approved wave."
tui_note "Pick a wave group (or a device that belongs to one)."
tui_pause
return 1
fi
if [[ "$scope" == "release" && -z "$wave" ]]; then
tui_error "Release-gated playbooks need an approved wave group as the target."
tui_pause
return 1
fi
tui_screen "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)"
bf_show_operation_header "$row"
bf_run_wave_once "$row" "$wave" "$pb" "$destructive" || return 0
tui_pause
return 0
}
# ---------------------------------------------------------------------------
# Local (on this device) command runner
# ---------------------------------------------------------------------------
# bf_local LABEL DESTRUCTIVE -- cmd... : show, confirm, run a local command.
bf_local() {
local label="$1" destructive="$2"
shift 2
BF_CMD=("$@")
printf '\n'
tui_section "$label"
bf_confirm_and_exec "$label" "$destructive" || return 1
return 0
}
# bf_show_local_cmd LABEL -- cmd... : run a read-only command without ceremony.
# The command is passed as arguments; when it is omitted, the caller's BF_CMD is
# used as-is (defensive: an accidental empty argument list must not erase it).
bf_show_local_cmd() {
local label="$1"
shift
if [[ $# -gt 0 ]]; then
BF_CMD=("$@")
fi
printf '\n'
tui_section "$label"
printf ' %s\n\n' "$(bf_cmd_string)"
if [[ "$BF_DRY_RUN" -eq 1 ]]; then
tui_warn "dry-run: the command above was NOT executed."
return 0
fi
bf_log "RUN" "$(bf_cmd_string)"
set +e
"${BF_CMD[@]}"
BF_LAST_RC=$?
set -e
if [[ "$BF_LAST_RC" -eq 0 ]]; then
bf_log "OK" "exit=0 :: $(bf_cmd_string)"
else
tui_warn "Command exited with $BF_LAST_RC."
bf_log "FAIL" "exit=$BF_LAST_RC :: $(bf_cmd_string)"
fi
return 0
}
# bf_run_tool NAME [ARGS...] -- run a diagnostics tool from the preferred path.
bf_run_tool() {
local name="$1"
shift
local path
if ! path="$(bf_tool_path "$name")"; then
tui_warn "$name is not available (neither /usr/local/bin/$name nor the checkout copy)."
tui_note "Install it from this checkout: sudo install -m 0755 scripts/diagnostics/$name /usr/local/bin/$name"
return 1
fi
bf_show_local_cmd "$name" "$path" "$@"
return 0
}
# ---------------------------------------------------------------------------
# Category 1 -- local installation of THIS device
# ---------------------------------------------------------------------------
bf_installer_or_error() {
local path
if ! path="$(bf_installer_path)"; then
tui_error "Installer not found: $INSTALLER_REL"
tui_note "Set --repo to the checkout that contains it, or clone the repository first:"
tui_note " git clone <repository-url> Blueforce-Linux && cd Blueforce-Linux"
return 1
fi
printf '%s\n' "$path"
}
bf_cat_install() {
local installer choice dealer offline=0 resume=0
while true; do
tui_screen "1) Installation (this device)" "Local install only -- no fleet device is touched here"
tui_item 1 "Pre-flight check" "installer --check, writes nothing"
tui_item 2 "Install this device" "full run, asks dealer number"
tui_item 3 "Resume after a failed run" "installer --resume"
tui_item 4 "Run a single module" "installer --only"
tui_item 5 "Show module state" "/var/lib/blueforce/install-state"
tui_item 6 "Show the installer log" "tail of /var/log/blueforce-install.log"
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
1)
installer="$(bf_installer_or_error)" || { tui_pause; continue; }
tui_note "Read-only pre-flight: the installer creates no log, state or directory in --check mode."
tui_read "Dealer number (8 digits) > " || bf_quit
dealer="$TUI_REPLY"
if ! bf_dealer_validate "$dealer"; then
tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE}."
tui_pause; continue
fi
BF_CMD=(bash "$installer" --dealer-id "$dealer" --check)
bf_show_local_cmd "Pre-flight check for $(bf_dealer_to_device_id "$dealer")" "${BF_CMD[@]}"
tui_pause
;;
2|3|4)
installer="$(bf_installer_or_error)" || { tui_pause; continue; }
bf_require_root || { tui_pause; continue; }
printf '\n'
tui_read "Dealer number (8 digits) > " || bf_quit
dealer="$TUI_REPLY"
if ! bf_dealer_validate "$dealer"; then
tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE}."
tui_pause; continue
fi
offline=0 resume=0
if [[ "$choice" == "3" ]]; then
resume=1
else
if tui_confirm "Install from the local offline repository (--offline)?"; then
offline=1
fi
if [[ "$choice" == "2" ]] && tui_confirm "Resume: skip modules already recorded as OK?"; then
resume=1
fi
fi
BF_CMD=(bash "$installer" --dealer-id "$dealer")
[[ "$offline" -eq 1 ]] && BF_CMD+=(--offline)
[[ "$resume" -eq 1 ]] && BF_CMD+=(--resume)
if [[ "$choice" == "4" ]]; then
bf_pick_module "$installer" || { tui_pause; continue; }
fi
printf '\n'
tui_info "Target device: $(bf_dealer_to_device_id "$dealer") ($(bf_dealer_to_host "$dealer"))"
bf_confirm_and_exec "install on $(bf_dealer_to_device_id "$dealer")" "yes" || true
tui_pause
;;
5)
BF_CMD=(cat /var/lib/blueforce/install-state)
bf_show_local_cmd "Installer module state" "${BF_CMD[@]}"
tui_pause
;;
6)
BF_CMD=(tail -n 40 /var/log/blueforce-install.log)
bf_show_local_cmd "Installer log (last 40 lines)" "${BF_CMD[@]}"
tui_pause
;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
# bf_pick_module INSTALLER -- choose one installer module and append --only.
bf_pick_module() {
local installer="$1" dir index=0 name choice i=0 pick="" modules=()
dir="$BF_REPO/scripts/install/installer/modules"
if [[ ! -d "$dir" ]]; then
tui_error "Module directory not found: $dir"
return 1
fi
while IFS= read -r name; do
[[ -n "$name" ]] || continue
modules+=("$name")
done < <(find "$dir" -maxdepth 1 -type f -name '[0-9][0-9]-*.sh' -printf '%f\n' 2>/dev/null | sort)
if [[ ${#modules[@]} -eq 0 ]]; then
tui_error "No installer modules found under $dir"
return 1
fi
printf '\n'
for name in "${modules[@]}"; do
index=$((index + 1))
tui_item "$index" "${name%.sh}" ""
done
tui_footer "Cancel the module selection"
printf '\n'
tui_read "Module number > " || return 1
choice="$TUI_REPLY"
if ! tui_menu_index "$choice" "${#modules[@]}"; then
tui_error "Enter a number between 1 and ${#modules[@]}."
return 1
fi
for name in "${modules[@]}"; do
i=$((i + 1))
if [[ "$i" -eq "$choice" ]]; then
pick="${name%.sh}"
break
fi
done
[[ -n "$pick" ]] || return 1
BF_CMD+=(--only "$pick")
tui_info "Selected module: $pick"
return 0
}
# ---------------------------------------------------------------------------
# Category 2 -- device operations through Ansible
# ---------------------------------------------------------------------------
bf_ops_menu() {
local title="$1" subtitle="$2"
shift 2
local rows=("$@") row index=0 choice
while true; do
tui_screen "$title" "$subtitle"
for row in "${rows[@]}"; do
index=$((index + 1))
tui_item "$index" "$(bf_op_field "$row" 2)" "$(bf_op_field "$row" 7)"
done
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
*)
index=0
if tui_menu_index "$choice" "${#rows[@]}"; then
bf_run_row "${rows[$((choice - 1))]}"
else
tui_error "Unknown choice '$choice'."
tui_pause
fi
;;
esac
index=0
done
}
bf_cat_devices() {
local choice
while true; do
tui_screen "2) Device operations" "Existing playbooks only, always with an explicit target"
if ! bf_have_ansible; then
tui_warn "ansible-playbook is not installed on this machine: fleet operations will refuse to run."
fi
tui_item 1 "Read-only reports" "${#BF_OPS_REPORT[@]} operations, no change on any device"
tui_item 2 "Maintenance actions" "${#BF_OPS_MAINTENANCE[@]} operations, some destructive"
tui_item 3 "Show inventory targets" "groups, hosts and counts"
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
1) bf_ops_menu "2.1) Read-only fleet reports" "No playbook in this list changes a device" "${BF_OPS_REPORT[@]}" ;;
2) bf_ops_menu "2.2) Fleet maintenance" "Destructive operations ask twice" "${BF_OPS_MAINTENANCE[@]}" ;;
3) bf_show_targets; tui_pause ;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
bf_show_targets() {
if ! bf_require_inventory; then
return 1
fi
local group count host waves=() groups=() hosts=()
mapfile -t waves < <(bf_wave_groups_present || true)
mapfile -t groups < <(bf_inventory_groups || true)
mapfile -t hosts < <(bf_inventory_hosts || true)
printf '\n'
tui_section "Waves (rollout order)"
for group in "${waves[@]}"; do
[[ -n "$group" ]] || continue
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
tui_kv "$group" "$count device(s)"
done
printf '\n'
tui_section "Other groups in the inventory"
for group in "${groups[@]}"; do
[[ -n "$group" ]] || continue
bf_is_wave "$group" && continue
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
tui_kv "$group" "$count device(s)"
done
printf '\n'
tui_section "Devices"
for host in "${hosts[@]}"; do
[[ -n "$host" ]] || continue
printf ' %s\n' "$host"
done
printf '\n'
tui_kv "Inventory" "$BF_INVENTORY"
tui_kv "Devices total" "$(bf_inventory_count_all)"
return 0
}
# ---------------------------------------------------------------------------
# Category 3 -- central side preparation (no central server is installed here)
# ---------------------------------------------------------------------------
bf_cat_central() {
local choice
while true; do
tui_screen "3) Central (preparation)" "This console prepares and verifies central integration; it installs no central server"
tui_item 1 "Central readiness report" "inventory, placeholders, admin key"
tui_item 2 "Show configured central endpoints" "WireGuard hub, monitoring, mirror"
tui_item 3 "Probe central endpoints" "DNS + TCP, read-only"
tui_item 4 "Wave rollout gate state" "per-wave device counts and gate rules"
tui_item 5 "Central preparation checklist" "what must exist before enrollment"
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
1) bf_central_readiness; tui_pause ;;
2) bf_central_endpoints; tui_pause ;;
3) bf_central_probe; tui_pause ;;
4) bf_wave_gate_state; tui_pause ;;
5) bf_central_checklist; tui_pause ;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
bf_group_vars_path() {
local p="$BF_REPO/ansible/inventory/group_vars/all.yml"
[[ -r "$p" ]] && printf '%s\n' "$p"
}
bf_group_vars_value() {
local key="$1" file
file="$(bf_group_vars_path)" || return 1
awk -v k="$key" '
$0 ~ "^[[:space:]]*" k "[[:space:]]*:" {
sub(/^[^:]*:/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, "")
gsub(/^["\x27]|["\x27]$/, ""); gsub(/[[:space:]]*#.*$/, ""); print; exit
}' "$file"
}
bf_central_readiness() {
local file placeholders keyfile
printf '\n'
tui_section "Central readiness"
if ! bf_require_inventory; then
return 1
fi
tui_ok "Inventory found: $BF_INVENTORY ($(bf_inventory_count_all) device(s))"
if file="$(bf_group_vars_path)"; then
placeholders="$(grep -c 'PLACEHOLDER' "$file" || true)"
if [[ "${placeholders:-0}" -gt 0 ]]; then
tui_warn "group_vars/all.yml still contains ${placeholders} PLACEHOLDER value(s):"
grep -n 'PLACEHOLDER' "$file" | sed 's/^/ /'
tui_note "Replace them before a production run; the console refuses nothing here on purpose, it reports."
else
tui_ok "No PLACEHOLDER left in group_vars/all.yml"
fi
else
tui_warn "ansible/inventory/group_vars/all.yml was not found."
fi
keyfile="${HOME}/.ssh/blueforce_id_ed25519"
if [[ -r "$keyfile" ]]; then
tui_ok "Admin SSH key present: $keyfile (contents are never read or printed)"
else
tui_warn "Admin SSH key not found at $keyfile (docs/06)."
tui_note "The inventory may point somewhere else: ansible_ssh_private_key_file."
fi
if grep -q 'example' "$BF_INVENTORY" 2>/dev/null || [[ "$BF_INVENTORY" == *example* ]]; then
tui_warn "This looks like an example inventory; do not aim it at real devices."
fi
return 0
}
bf_central_endpoints() {
local key value
printf '\n'
tui_section "Configured central endpoints"
for key in wg_subnet wg_interface wg_hub_endpoint monitoring_endpoint monitoring_job update_repo_url meg_image_tag support_bundle_dir; do
value="$(bf_group_vars_value "$key" || true)"
if [[ -n "$value" ]]; then
tui_kv "$key" "$value"
fi
done
tui_note "Values come from ansible/inventory/group_vars/all.yml; no secret is stored there or printed here."
return 0
}
bf_central_probe() {
local endpoint host port resolved
printf '\n'
tui_section "Central endpoint probe (read-only)"
for endpoint in "$(bf_group_vars_value wg_hub_endpoint || true)" "$(bf_group_vars_value monitoring_endpoint || true)" "$(bf_group_vars_value update_repo_url || true)"; do
[[ -n "$endpoint" ]] || continue
host="${endpoint#*//}"
host="${host%%/*}"
host="${host%%:*}"
port=""
if [[ "$endpoint" =~ :([0-9]+)$ ]]; then
port="${BASH_REMATCH[1]}"
elif [[ "$endpoint" == http://* ]]; then
port=80
elif [[ "$endpoint" == https://* ]]; then
port=443
fi
if [[ -z "$host" ]]; then
continue
fi
if resolved="$(getent hosts "$host" 2>/dev/null | awk 'NR==1{print $1}')" && [[ -n "$resolved" ]]; then
if [[ -n "$port" ]]; then
if timeout 3 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
tui_ok "$host:$port reachable (resolves to $resolved)"
else
tui_warn "$host:$port not reachable (resolves to $resolved)"
fi
else
tui_ok "$host resolves to $resolved (no port derived from the value)"
fi
else
tui_warn "$host does not resolve -- expected while the placeholder is unfilled"
fi
done
return 0
}
bf_wave_gate_state() {
local wave count
printf '\n'
tui_section "Wave rollout gate state"
if ! bf_require_inventory; then
return 1
fi
if ! bf_inventory_cache; then
tui_error "Inventory could not be parsed: $BF_INVENTORY"
return 1
fi
for wave in "${BF_WAVES[@]}"; do
if bf_inventory_group_exists "$wave"; then
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$wave" || true)")"
tui_kv "$wave" "$count device(s)"
else
tui_kv "$wave" "absent from the inventory"
fi
done
tui_note "Gate rule: a wave may only start after the previous wave passed its health gate and central approval (docs/10)."
tui_note "Production additionally needs the typed 'production' confirmation in this console and its override flag."
return 0
}
bf_central_checklist() {
printf '\n'
tui_section "Central preparation checklist (informational)"
tui_msg " 1. WireGuard hub: endpoint, keys and peer allocation for each bf-<dealer> (docs/08)."
tui_msg " 2. Enrollment token service: one-time, TTL-limited tokens (docs/29)."
tui_msg " 3. Monitoring: Prometheus/VictoriaMetrics + Grafana, device_id label = BF-<no> (docs/14)."
tui_msg " 4. Remote access: RustDesk hbbs/hbbr or MeshCentral reachable over WireGuard (docs/07)."
tui_msg " 5. Package mirror + offline snapshot for field installs (docs/10, docs/27)."
tui_msg " 6. Ansible inventory with real hostnames and a per-admin SSH key (docs/02, docs/06, docs/09)."
tui_note "This console verifies 6 and prepares 1-5; it installs no central component."
return 0
}
# ---------------------------------------------------------------------------
# Category 4 -- tools
# ---------------------------------------------------------------------------
bf_cat_tools() {
local choice out_dir
while true; do
tui_screen "4) Tools" "Local helpers; the ISO build is intentionally not part of this console"
tui_section "Repository"
tui_item 1 "Update this checkout" "git pull --ff-only"
tui_item 2 "Environment and reference info" "paths, versions, resolved settings"
tui_item 3 "Show the operation catalogue" "--list output"
tui_section "Local device tools"
tui_item 4 "Local offline gate" "bf-check-local"
tui_item 5 "Enrollment gate" "bf-check-enrollment"
tui_item 6 "Ready gate" "bf-check-ready"
tui_item 7 "Deep diagnostics" "bf-diagnostics (or bf-status)"
tui_item 8 "Live hardware / OS check" "bf-live-hw-check, read-only"
tui_item 9 "Remote access channels" "bf-remote-status --check"
tui_item 10 "Field OS release identity" "bf-release"
tui_item 11 "Hardware inventory" "bf-hardware-inventory.sh"
tui_item 12 "Support bundle" "bf-support-bundle --out-dir"
tui_section "This console"
tui_item 13 "Show the console log" "last 40 lines of the audit log"
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
1)
BF_CMD=(git -C "$BF_REPO" pull --ff-only)
if bf_confirm_and_exec "update the checkout at $BF_REPO" "yes"; then
tui_note "The console itself may have changed; restart it after a pull."
fi
tui_pause
;;
2) bf_show_environment; tui_pause ;;
3) bf_print_catalogue; tui_pause ;;
4) bf_run_tool bf-check-local --check; tui_pause ;;
5) bf_run_tool bf-check-enrollment --check; tui_pause ;;
6) bf_run_tool bf-check-ready --check; tui_pause ;;
7)
if ! bf_run_tool bf-diagnostics; then
bf_run_tool bf-status || true
fi
tui_pause
;;
8) bf_run_tool bf-live-hw-check; tui_pause ;;
9) bf_run_tool bf-remote-status --check; tui_pause ;;
10) bf_run_tool bf-release; tui_pause ;;
11)
if [[ "$(id -u)" -eq 0 ]]; then
out_dir="/var/lib/blueforce/inventory"
else
out_dir="${XDG_STATE_HOME:-$HOME/.local/state}/blueforce/inventory"
fi
if bf_tool_path bf-hardware-inventory.sh >/dev/null 2>&1; then
tui_prompt "Dealer number (8 digits)" "" || continue
if ! bf_dealer_validate "$TUI_REPLY"; then
tui_error "Invalid dealer number; it must match ${BF_DEALER_RE}."
tui_pause; continue
fi
bf_show_local_cmd "Hardware inventory for $(bf_dealer_to_device_id "$TUI_REPLY")" \
"$(bf_tool_path bf-hardware-inventory.sh)" --dealer-id "$TUI_REPLY" --out-dir "$out_dir"
else
tui_warn "bf-hardware-inventory.sh is not available."
fi
tui_pause
;;
12)
if bf_tool_path bf-support-bundle >/dev/null 2>&1; then
bf_local "Support bundle" "no" "$(bf_tool_path bf-support-bundle)" --out-dir /var/tmp
else
tui_warn "bf-support-bundle is not available."
fi
tui_pause
;;
13)
BF_CMD=(tail -n 40 "$BF_LOG_FILE")
bf_show_local_cmd "Console log ($BF_LOG_FILE)" "${BF_CMD[@]}"
tui_pause
;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
bf_show_environment() {
printf '\n'
tui_section "Environment"
tui_kv "Console" "$BF_MENU_NAME $BF_MENU_VERSION"
tui_kv "Repository" "$BF_REPO"
tui_kv "Inventory" "${BF_INVENTORY:-not resolved yet}"
tui_kv "Audit log" "$BF_LOG_FILE (mode $(stat -c '%a' "$BF_LOG_FILE" 2>/dev/null || echo '?'))"
tui_kv "dry-run" "$([[ "$BF_DRY_RUN" -eq 1 ]] && echo yes || echo no)"
tui_kv "Running as" "uid $(id -u) ($(id -un 2>/dev/null || echo unknown))"
tui_kv "Ansible" "$(command -v ansible-playbook 2>/dev/null || echo 'not installed')"
if bf_have_ansible; then
tui_kv "ansible-core" "$(ansible --version 2>/dev/null | awk 'NR==1{print $2}' || echo unknown)"
fi
tui_kv "Installer" "$(bf_installer_path || echo 'not found')"
tui_kv "Playbook dir" "$BF_REPO/$PLAYBOOK_DIR_REL"
printf '\n'
tui_section "Resolved tool paths"
for tool in bf-status bf-diagnostics bf-check-local bf-check-enrollment bf-check-ready bf-release bf-remote-status bf-support-bundle bf-hardware-inventory.sh; do
tui_kv "$tool" "$(bf_tool_path "$tool" || echo 'not available')"
done
return 0
}
bf_print_catalogue() {
local row index
printf '\n'
tui_section "Category 2.1 -- read-only fleet reports"
index=0
for row in "${BF_OPS_REPORT[@]}"; do
index=$((index + 1))
printf ' %2s) %-34s %-36s scope=%-8s destructive=%s\n' "$index" "$(bf_op_field "$row" 1)" \
"$(bf_op_field "$row" 3)" "$(bf_op_field "$row" 4)" "$(bf_op_field "$row" 5)"
done
printf '\n'
tui_section "Category 2.2 -- fleet maintenance"
index=0
for row in "${BF_OPS_MAINTENANCE[@]}"; do
index=$((index + 1))
printf ' %2s) %-34s %-36s scope=%-8s destructive=%s\n' "$index" "$(bf_op_field "$row" 1)" \
"$(bf_op_field "$row" 3)" "$(bf_op_field "$row" 4)" "$(bf_op_field "$row" 5)"
done
printf '\n'
tui_note "scope=wave needs -e operation_wave=<wave> -e operation_confirmed=true (set by this console after confirmation)."
tui_note "scope=release needs -e release_wave=<wave> plus the wave gate typed confirmation."
return 0
}
# ---------------------------------------------------------------------------
# Category 5 -- device information
# ---------------------------------------------------------------------------
bf_cat_info() {
local choice
while true; do
tui_screen "5) Device information" "Read-only: nothing here changes a device"
tui_section "This device"
tui_item 1 "Identity" "hostname, device id, dealer number"
tui_item 2 "Health summary" "bf-status"
tui_item 3 "Provisioning and enrollment state" "state.json whitelist + bf-enrollment-status"
tui_item 4 "Network" "addresses, route, DNS, WireGuard peers"
tui_item 5 "Disk, memory, CPU" "df, free, load"
tui_item 6 "Installed pinned packages" "dpkg-query"
tui_item 7 "Service status" "ssh, xrdp, rustdesk, docker, wg, meshagent"
tui_item 8 "Recent journal errors" "journalctl -p err"
tui_section "The fleet"
tui_item 9 "Inventory summary" "waves, groups, counts"
tui_item 10 "Resolve a target" "check a dealer number or group"
tui_footer
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
0) return 0 ;;
q|Q) bf_quit ;;
1) bf_info_identity; tui_pause ;;
2) bf_run_tool bf-status; tui_pause ;;
3) bf_info_state; tui_pause ;;
4) bf_info_network; tui_pause ;;
5) bf_info_resources; tui_pause ;;
6) bf_info_packages; tui_pause ;;
7) bf_info_services; tui_pause ;;
8) bf_info_journal; tui_pause ;;
9) bf_show_targets; tui_pause ;;
10) bf_info_resolve_target; tui_pause ;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
bf_info_identity() {
local host dealer device_id
host="$(hostname 2>/dev/null || echo unknown)"
printf '\n'
tui_section "Identity"
tui_kv "Hostname (machine)" "$host"
if dealer="$(bf_host_to_dealer "$host")"; then
tui_kv "Dealer number" "$dealer"
tui_kv "Device ID (human)" "$(bf_dealer_to_device_id "$dealer")"
else
tui_warn "Hostname is not bf-<8 digits>; it does not match the naming standard (docs/02)."
tui_note "The installer sets it from --dealer-id: sudo $BF_MENU_NAME -> category 1"
fi
if [[ -r /var/lib/blueforce/state.json ]]; then
device_id="$(bf_state_value device_id || true)"
[[ -n "$device_id" ]] && tui_kv "state.json device_id" "$device_id"
fi
tui_kv "Kernel" "$(uname -sr 2>/dev/null || echo unknown)"
tui_kv "Console" "$BF_MENU_NAME $BF_MENU_VERSION"
return 0
}
# bf_state_value KEY -- read one whitelisted key from /var/lib/blueforce/state.json.
# Only non-secret identity/lifecycle fields are ever extracted: the file is read
# through a whitelist so a future token field can never leak into the UI or log.
BF_STATE_ALLOWED_KEYS="phase enrollment_status fleet_status device_id provisioning_id"
bf_state_value() {
local key="$1" state="/var/lib/blueforce/state.json"
[[ -r "$state" ]] || return 1
[[ " $BF_STATE_ALLOWED_KEYS " == *" $key "* ]] || return 1
if command -v python3 >/dev/null 2>&1; then
python3 -c '
import json, sys
try:
with open(sys.argv[1], encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
sys.exit(1)
if not isinstance(data, dict):
sys.exit(1)
value = data.get(sys.argv[2])
if value is None or isinstance(value, bool):
sys.exit(1)
sys.stdout.write(str(value) + "\n")
' "$state" "$key" 2>/dev/null || return 1
return 0
fi
awk -v pattern="\"$key\"" '
index($0, pattern) {
line = $0
sub(/^[^:]*:[[:space:]]*/, "", line)
gsub(/^"|"[,]?$/, "", line)
print line; exit
}' "$state" || return 1
return 0
}
bf_info_state() {
local key value
printf '\n'
tui_section "Provisioning and enrollment state"
if [[ -r /var/lib/blueforce/state.json ]]; then
for key in phase enrollment_status fleet_status device_id provisioning_id; do
value="$(bf_state_value "$key" || true)"
[[ -n "$value" ]] && tui_kv "$key" "$value"
done
tui_note "Only the identity/lifecycle whitelist above is read; tokens and keys are never printed."
else
tui_warn "/var/lib/blueforce/state.json is missing: the device has not been provisioned by the installer."
fi
printf '\n'
printf ' %sProvisioning : PROVISIONED_OFFLINE -> Enrollment : ENROLLED -> Fleet State : READY%s\n' "$C_DIM" "$C_RESET"
tui_note "Forward-only state machine; READY requires verified central evidence (docs/29)."
return 0
}
bf_info_network() {
printf '\n'
tui_section "Network"
if command -v ip >/dev/null 2>&1; then
bf_show_local_cmd "Interfaces" ip -brief address
fi
if command -v ip >/dev/null 2>&1; then
bf_show_local_cmd "Default routes" ip route show default
fi
if [[ -r /etc/resolv.conf ]]; then
bf_show_local_cmd "DNS servers (nameserver lines only)" awk '/^nameserver/{print}' /etc/resolv.conf
fi
if command -v wg >/dev/null 2>&1; then
bf_show_local_cmd "WireGuard listen port" wg show wg0 listen-port
bf_show_local_cmd "WireGuard peer count" sh -c 'wg show wg0 peers | wc -l'
bf_show_local_cmd "WireGuard handshakes (epoch, no keys)" wg show wg0 latest-handshakes
else
tui_note "wg is not installed; WireGuard channels are not configured on this device."
fi
tui_note "WireGuard private and preshared keys are never requested, displayed or logged."
return 0
}
bf_info_resources() {
printf '\n'
tui_section "Resources"
bf_show_local_cmd "Filesystems" df -h
bf_show_local_cmd "Memory" free -h
bf_show_local_cmd "Load average and uptime" uptime
return 0
}
bf_info_packages() {
printf '\n'
tui_section "Installed versions"
if command -v dpkg-query >/dev/null 2>&1; then
# shellcheck disable=SC2016 # dpkg field syntax, not a shell expansion
bf_show_local_cmd "Pinned packages" dpkg-query -W -f='${Package} ${Version} ${Status}\n' \
docker-ce wireguard rustdesk xrdp openssh-server
else
tui_warn "dpkg-query is not available on this machine."
fi
return 0
}
bf_info_services() {
printf '\n'
tui_section "Service status (read-only)"
if ! command -v systemctl >/dev/null 2>&1; then
tui_warn "systemctl is not available on this machine."
return 0
fi
bf_show_local_cmd "Units" systemctl is-active ssh sshd xrdp rustdesk docker wg-quick@wg0 meshagent cron
return 0
}
bf_info_journal() {
printf '\n'
tui_section "Recent journal errors"
if command -v journalctl >/dev/null 2>&1; then
bf_show_local_cmd "Errors (last 20)" journalctl -p err -n 20 --no-pager
else
tui_warn "journalctl is not available on this machine."
fi
return 0
}
bf_info_resolve_target() {
local word mode="any" answer
printf '\n'
tui_section "Resolve a target"
tui_note "Accepts a dealer number, a group name, 'all' or a bf-<dealer> hostname."
tui_read "Target > " || return 1
word="$TUI_REPLY"
if [[ -z "$word" ]]; then
return 1
fi
if ! bf_require_inventory; then
return 1
fi
if tui_confirm "Is this target for a wave-scoped operation?"; then
mode="wave"
fi
answer="$word"
if bf_target_parse "$answer" "$mode"; then
printf '\n'
tui_kv "Kind" "$BF_TARGET_KIND"
tui_kv "Ansible --limit" "$BF_TARGET_LIMIT"
tui_kv "Wave" "${BF_TARGET_WAVE:-none}"
tui_kv "Devices" "$BF_TARGET_COUNT"
printf '\n'
tui_note "Example: ansible-playbook -i $BF_INVENTORY $BF_REPO/$PLAYBOOK_DIR_REL/bf-ping.yml --limit $BF_TARGET_LIMIT"
else
tui_warn "That target cannot be used."
fi
return 0
}
# ---------------------------------------------------------------------------
# Main menu
# ---------------------------------------------------------------------------
bf_main_menu() {
local choice
while true; do
tui_screen "Blueforce operator console $BF_MENU_VERSION" "repo: $BF_REPO"
if [[ "$BF_DRY_RUN" -eq 1 ]]; then
tui_warn "DRY-RUN MODE: commands are printed, never executed."
fi
if ! bf_have_ansible; then
tui_warn "ansible-playbook not found: categories 2, 3 and 5-fleet need Ansible."
fi
tui_section "Categories"
tui_item 1 "Installation" "this device, local one-click installer"
tui_item 2 "Device operations" "fleet playbooks, explicit target"
tui_item 3 "Central (preparation)" "verify and prepare central integration"
tui_item 4 "Tools" "repository and local helpers"
tui_item 5 "Device information" "read-only information screens"
printf '\n'
tui_hr '-'
printf ' %s%2s%s) %s\n' "$C_BOLD" "q" "$C_RESET" "Quit the console"
printf ' %sLog:%s %s\n' "$C_DIM" "$C_RESET" "$BF_LOG_FILE"
tui_hr '-'
if ! tui_read "Choice > "; then bf_quit; fi
choice="$TUI_REPLY"
case "$choice" in
1) bf_cat_install ;;
2) bf_cat_devices ;;
3) bf_cat_central ;;
4) bf_cat_tools ;;
5) bf_cat_info ;;
q|Q) bf_quit ;;
0) tui_note "Already at the top level."; tui_pause ;;
*) tui_error "Unknown choice '$choice'."; tui_pause ;;
esac
done
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
main() {
bf_parse_args "$@"
BF_LOG_REQUESTED="$BF_LOG_FILE"
bf_resolve_repo
trap bf_cleanup EXIT
# shellcheck source=lib/tui.sh disable=SC1091
source "$BF_REPO/admin/lib/tui.sh"
tui_init_colors "${TUI_COLOR_MODE:-auto}"
# shellcheck source=lib/targets.sh disable=SC1091
source "$BF_REPO/admin/lib/targets.sh"
bf_log_init
if [[ "$BF_LIST_TARGETS" -eq 1 ]]; then
bf_show_targets
exit $?
fi
if [[ "$BF_LIST" -eq 1 ]]; then
bf_print_catalogue
exit 0
fi
bf_log "SESSION" "$BF_MENU_NAME $BF_MENU_VERSION start uid=$(id -u) repo=$BF_REPO dry_run=$BF_DRY_RUN"
if ! bf_require_inventory; then
tui_warn "Starting without a usable inventory: fleet categories will explain what is missing."
BF_INVENTORY=""
fi
bf_main_menu
}
main "$@"