Blueforce-Linux/admin/lib/targets.sh
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

476 lines
17 KiB
Bash

#!/usr/bin/env bash
# admin/lib/targets.sh -- device identity, inventory parsing and target selection.
#
# Sourced by admin/bf-menu. Contains no UI rendering and no execution: it answers
# "which hosts does this operation apply to, and which Ansible --limit does that
# become".
#
# Contracts it enforces (sources of truth in parentheses):
# * dealer number matches ^[0-9]{8}$ exactly (docs/02)
# * machine hostname is bf-<dealer>, human id is BF-<dealer> (docs/02)
# * waves are lab(2) -> pilot_1(5) -> pilot_2(20) -> wave_1(50) -> wave_2(100)
# -> production, in that order (docs/10)
# * the inventory is the Ansible inventory; no device is ever addressed that is
# not in it (docs/09)
#
# Inputs (set by bf-menu before calling):
# BF_REPO repository root
# BF_INVENTORY resolved inventory path, or empty to trigger auto-discovery
#
# shellcheck shell=bash
# Canonical wave order. Kept in one place so the menu, the gate checks and the
# fleet-wide loop can never disagree.
BF_WAVES=(lab pilot_1 pilot_2 wave_1 wave_2 production)
# ---------------------------------------------------------------------------
# Identity helpers
# ---------------------------------------------------------------------------
# BF_DEALER_RE -- the single validation rule for dealer numbers.
BF_DEALER_RE='^[0-9]{8}$'
bf_dealer_validate() {
[[ "$1" =~ $BF_DEALER_RE ]]
}
bf_dealer_to_host() {
printf 'bf-%s\n' "$1"
}
bf_dealer_to_device_id() {
printf 'BF-%s\n' "$1"
}
# bf_host_to_dealer HOST -> dealer number, empty when the name is not bf-<8 digits>.
bf_host_to_dealer() {
local host="$1"
if [[ "$host" =~ ^bf-([0-9]{8})$ ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
# bf_is_wave NAME -- true for the six approved wave groups (docs/10).
bf_is_wave() {
local name="$1" wave
for wave in "${BF_WAVES[@]}"; do
[[ "$name" == "$wave" ]] && return 0
done
return 1
}
bf_waves_csv() {
local IFS=,
printf '%s\n' "${BF_WAVES[*]}"
}
# ---------------------------------------------------------------------------
# Inventory discovery and parsing
# ---------------------------------------------------------------------------
# bf_inventory_candidates -- every path we are willing to read, best first.
# hosts.example.yml is listed last on purpose: it is a template with example
# device IDs, so it is only usable after an explicit acknowledgement.
bf_inventory_candidates() {
local dir="${BF_REPO:-.}/ansible/inventory"
if [[ -n "${BF_INVENTORY:-}" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
printf '%s\n' \
"$dir/hosts.yml" \
"$dir/hosts.yaml" \
"$dir/hosts" \
"$dir/hosts.ini"
}
# bf_inventory_resolve -- pick the inventory file. Prints the path on success.
# Prints a remediation message to stderr and returns 1 when nothing is usable.
bf_inventory_resolve() {
local candidate example="${BF_REPO:-.}/ansible/inventory/hosts.example.yml"
if [[ -n "${BF_INVENTORY:-}" ]]; then
if [[ -r "$BF_INVENTORY" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
tui_error "Inventory not readable: $BF_INVENTORY"
tui_note "Pass a real path with --inventory PATH or unset it to auto-discover." >&2
return 1
fi
while IFS= read -r candidate; do
[[ -n "$candidate" ]] || continue
if [[ -r "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(bf_inventory_candidates)
tui_error "No Ansible inventory found under ${BF_REPO:-.}/ansible/inventory/."
# These remediation lines go to stderr on purpose: the resolver's stdout is
# captured as the resolved path by the caller.
if [[ -r "$example" ]]; then
tui_note "Only the example inventory exists: $example" >&2
tui_note "It carries example device IDs (bf-1201xxxx) and must not be used against real devices." >&2
tui_note "Create the real inventory first:" >&2
tui_note " cp $example ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
tui_note " \$EDITOR ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
else
tui_note "Create ansible/inventory/hosts.yml with your bf-<8-digit dealer> hosts (docs/02, docs/09)." >&2
fi
return 1
}
# bf_inventory_pairs FILE -- "group<TAB>host" for every host in the inventory.
# A deliberately small YAML reader: the inventory is a nested map of
# all.children.<group>.children.<wave>.hosts.<hostname>. It tracks indentation
# instead of pulling in a YAML parser (none is guaranteed on a field console).
bf_inventory_pairs() {
local inv="$1"
[[ -r "$inv" ]] || return 1
awk '
function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s }
function unquote(s) { gsub(/^["\x27]|["\x27]$/, "", s); return s }
{
line = $0
if (line ~ /^[[:space:]]*#/) next
if (line ~ /^[[:space:]]*$/) next
if (line ~ /^[[:space:]]*-/) next
if (line !~ /:/) next
match(line, /^ */); ind = RLENGTH
key = line; sub(/^[[:space:]]*/, "", key); sub(/:.*$/, "", key)
key = unquote(trim(key))
val = line; sub(/^[^:]*:/, "", val); val = trim(val)
if (key == "") next
while (depth > 0 && sind[depth] >= ind) depth--
# A "hosts:" block owns the nearest enclosing group key.
if (key == "hosts" && val == "") {
h_active = 1; h_ind = ind; h_step = 0
h_grp = (depth > 0) ? skey[depth] : ""
skey[++depth] = key; sind[depth] = ind
next
}
# Anything at or above the "hosts:" indentation ends that block.
if (h_active && ind <= h_ind) h_active = 0
if (h_active && ind > h_ind) {
if (h_step == 0) h_step = ind
if (ind == h_step) {
print h_grp "\t" key
skey[++depth] = key; sind[depth] = ind
next
}
}
if (val == "") { skey[++depth] = key; sind[depth] = ind }
}
' "$inv"
}
# Reads the inventory and caches the pairs; repeated calls are cheap and the
# cache is invalidated by BF_INVENTORY_CACHE_FILE going away.
bf_inventory_cache() {
local inv="${BF_INVENTORY:-}"
[[ -n "$inv" ]] || return 1
if [[ -z "${BF_INVENTORY_CACHE_FILE:-}" ]]; then
BF_INVENTORY_CACHE_FILE="$(mktemp "${TMPDIR:-/tmp}/bf-inventory.XXXXXX")"
export BF_INVENTORY_CACHE_FILE
fi
if [[ ! -s "$BF_INVENTORY_CACHE_FILE" ]]; then
bf_inventory_pairs "$inv" >"$BF_INVENTORY_CACHE_FILE" || return 1
fi
return 0
}
bf_inventory_groups() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$1]++ { print $1 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$2]++ { print $2 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts_of_group() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { print $2 }' "$BF_INVENTORY_CACHE_FILE"
}
# bf_count_lines -- wc -l without the set -e trap of grep -c returning 1 on zero.
bf_count_lines() {
local n
n="$(printf '%s\n' "$1" | grep -c . || true)"
printf '%s\n' "${n:-0}"
}
bf_inventory_group_exists() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_host_exists() {
local host="$1"
bf_inventory_cache || return 1
awk -F '\t' -v h="$host" '$2 == h { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_count_all() {
bf_count_lines "$(bf_inventory_hosts || true)"
}
# bf_inventory_host_wave HOST -- the approved wave that contains HOST.
# Prints nothing and returns 1 when the device sits outside every wave group.
bf_inventory_host_wave() {
local host="$1" wave
bf_inventory_cache || return 1
for wave in "${BF_WAVES[@]}"; do
bf_inventory_group_exists "$wave" || continue
if bf_inventory_hosts_of_group "$wave" | grep -qx -- "$host"; then
printf '%s\n' "$wave"
return 0
fi
done
return 1
}
# bf_wave_groups_present -- canonical waves that actually exist in the inventory,
# always in rollout order.
bf_wave_groups_present() {
local wave
for wave in "${BF_WAVES[@]}"; do
if bf_inventory_group_exists "$wave"; then
printf '%s\n' "$wave"
fi
done
}
# ---------------------------------------------------------------------------
# Target selection
# ---------------------------------------------------------------------------
bf_target_reset() {
BF_TARGET_KIND=""
BF_TARGET_LIMIT=""
BF_TARGET_WAVE=""
BF_TARGET_LABEL=""
BF_TARGET_COUNT=0
}
bf_target_is_set() {
[[ -n "${BF_TARGET_KIND:-}" && -n "${BF_TARGET_LIMIT:-}" ]]
}
# bf_target_set_host DEALER [MODE]
# MODE: any|wave|release. Returns 1 (after explaining) when the mode forbids a
# single-device selection or the device is unknown to the inventory.
bf_target_set_host() {
local dealer="$1" mode="${2:-any}" host wave
if ! bf_dealer_validate "$dealer"; then
tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE} (8 digits, no spaces)."
return 1
fi
if [[ "$mode" == "release" ]]; then
tui_error "Release-gated operations are wave-scoped (docs/10): select a wave group, not a single device."
return 1
fi
host="$(bf_dealer_to_host "$dealer")"
if ! bf_inventory_host_exists "$host"; then
tui_error "$host is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Add the device to its wave group first, then retry (see admin/README.md, docs/02)."
return 1
fi
wave="$(bf_inventory_host_wave "$host" || true)"
if [[ -z "$wave" && "$mode" != "any" ]]; then
tui_error "$host is not inside an approved wave group ($(bf_waves_csv))."
tui_note "Wave-scoped playbooks refuse a device that no wave covers (fail-closed)."
return 1
fi
BF_TARGET_KIND="host"
BF_TARGET_LIMIT="$host"
BF_TARGET_WAVE="$wave"
BF_TARGET_COUNT=1
if [[ -n "$wave" ]]; then
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host) in wave '$wave'"
else
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host)"
fi
return 0
}
# bf_target_set_group GROUP [MODE]
bf_target_set_group() {
local group="$1" mode="${2:-any}" count
if [[ "$group" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if ! bf_inventory_group_exists "$group"; then
tui_error "Group '$group' is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
fi
if [[ "$mode" == "wave" || "$mode" == "release" ]] && ! bf_is_wave "$group"; then
tui_error "Group '$group' is not an approved wave; wave-scoped operations need one of: $(bf_waves_csv)."
return 1
fi
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if [[ "$count" -eq 0 ]]; then
tui_error "Group '$group' contains no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="group"
BF_TARGET_LIMIT="$group"
BF_TARGET_WAVE="$group"
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
if bf_is_wave "$group"; then
BF_TARGET_LABEL="wave '$group' ($count device(s))"
else
BF_TARGET_LABEL="group '$group' ($count device(s))"
fi
return 0
}
# bf_target_set_all MODE -- the whole fleet.
# For release-gated operations this is refused: the update/reboot playbooks are
# gated wave by wave and a fleet-wide limit would defeat the gate (docs/10).
bf_target_set_all() {
local mode="${1:-any}" count
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused for release-gated operations: roll out one wave at a time (docs/10)."
return 1
fi
count="$(bf_inventory_count_all)"
if [[ "$count" -eq 0 ]]; then
tui_error "The inventory lists no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="all"
BF_TARGET_LIMIT="all"
BF_TARGET_WAVE=""
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
BF_TARGET_LABEL="all devices in the fleet ($count device(s))"
return 0
}
# bf_target_parse WORD MODE -- accept what the operator typed in one prompt:
# an 8-digit dealer number, a group/wave name, or "all".
bf_target_parse() {
local word="$1" mode="${2:-any}"
bf_target_reset
if [[ -z "$word" ]]; then
tui_error "No target given."
return 1
fi
if [[ "$word" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if bf_dealer_validate "$word"; then
bf_target_set_host "$word" "$mode"
return $?
fi
if bf_inventory_group_exists "$word"; then
bf_target_set_group "$word" "$mode"
return $?
fi
# A bf-<dealer> hostname is accepted as a convenience.
if [[ "$word" =~ ^bf-[0-9]{8}$ ]]; then
if bf_dealer_validate "${word#bf-}"; then
bf_target_set_host "${word#bf-}" "$mode"
return $?
fi
fi
tui_error "'$word' is not a dealer number (${BF_DEALER_RE}), a group, or 'all'."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
}
# bf_target_describe -- one line for the confirmation screen.
bf_target_describe() {
printf '%s --limit %s%s\n' "${BF_TARGET_LABEL:-unset}" "${BF_TARGET_LIMIT:-unset}" \
"$([[ -n "${BF_TARGET_WAVE:-}" ]] && printf ' (wave %s)' "$BF_TARGET_WAVE")"
}
# bf_target_select MODE
# Interactive target picker. MODE:
# any single device, any group, or the whole fleet
# wave single device, approved wave group, or the whole fleet wave by wave
# release approved wave group only
# On success the BF_TARGET_* globals describe the selection.
bf_target_select() {
local mode="${1:-any}" choice word count index group_list=()
bf_target_reset
while true; do
tui_section "Target selection (mode: $mode)"
tui_item 1 "Single device by dealer number" "8 digits, e.g. 12010101"
tui_item 2 "Group / wave" "lab, pilot_1, pilot_2, wave_1, wave_2, production"
if [[ "$mode" == "release" ]]; then
tui_note " all) not offered: release-gated operations run one wave at a time"
else
tui_item 3 "All devices in the fleet" "wave-scoped playbooks ask per wave"
fi
tui_item 9 "Type the target directly" "dealer number, group or 'all'"
tui_footer "Back to the operation"
printf '\n'
if ! tui_read "Target > "; then return 1; fi
choice="$TUI_REPLY"
case "$choice" in
""|0|q|Q) return 1 ;;
1)
tui_read "Dealer number (8 digits) > " || return 1
word="$TUI_REPLY"
bf_target_set_host "$word" "$mode" && return 0
;;
2)
tui_section "Groups in the inventory"
group_list=()
while IFS= read -r group; do
[[ -n "$group" ]] || continue
group_list+=("$group")
done < <(if [[ "$mode" == "any" ]]; then bf_inventory_groups || true; else bf_wave_groups_present || true; fi)
if [[ ${#group_list[@]} -eq 0 ]]; then
tui_error "No usable group in the inventory (${BF_INVENTORY:-unresolved})."
return 1
fi
index=0
for group in "${group_list[@]}"; do
index=$((index + 1))
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if bf_is_wave "$group"; then
tui_item "$index" "$group" "$count device(s), approved wave"
else
tui_item "$index" "$group" "$count device(s)"
fi
done
printf '\n'
tui_note "Approved waves: $(bf_waves_csv) -- source: $(basename "${BF_INVENTORY:-unresolved}")"
if ! tui_read "Group number > "; then return 1; fi
choice="$TUI_REPLY"
if ! tui_menu_index "$choice" "$index"; then
tui_error "Enter a number between 1 and $index."
continue
fi
bf_target_set_group "${group_list[$((choice - 1))]}" "$mode" && return 0
;;
3)
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused here."
continue
fi
bf_target_set_all "$mode" && return 0
;;
9)
tui_read "Target (dealer number, group or all) > " || return 1
bf_target_parse "$TUI_REPLY" "$mode" && return 0
;;
*) tui_error "Unknown choice '$choice'." ;;
esac
printf '\n'
tui_pause
done
}