- Updated `bf-wireguard-restart.yml` to include dynamic host targeting based on `operation_wave`. - Added pre-tasks to assert that the operation wave is approved and confirmed, ensuring proper execution context. - Modified the service restart task to use a more structured command format. - Adjusted `operations.py` and `ui_gui.py` to reflect changes in operation scope from "simple" to "wave" for relevant operations. - Improved UI handling in `ui_gui.py` and `ui_surfaces.py` for better output management and user interaction. - Updated log rotation configuration to target specific log files. - Enhanced systemd service configurations for WireGuard to improve restart behavior. - Refined firstboot scripts to ensure proper handling of dealer ID and state management. - Improved error handling and rollback mechanisms in identity and installation scripts.
405 lines
17 KiB
Bash
Executable file
405 lines
17 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Blueforce Field OS first-boot entry point.
|
||
#
|
||
# Role: obtain the 8-digit dealer number (console wizard for a technician, or a flag/file
|
||
# for automation), validate it, verify the offline prerequisites, run the local offline
|
||
# installer exactly once, then stamp the completion marker.
|
||
#
|
||
# Every comment and identifier in this file is English. The only Turkish strings are the
|
||
# technician-facing wizard labels, because the field console UI is Turkish.
|
||
#
|
||
# Guarantees:
|
||
# * idempotent: when the marker exists the run changes nothing and exits 0;
|
||
# * identity is write-once: the device identity cannot be changed through the normal
|
||
# path or through the wizard; changing it needs the separate admin procedure
|
||
# (--force-reidentity), which stays disabled unless BF_ALLOW_REIDENTITY=1;
|
||
# * fail-closed: an invalid dealer number, a missing terminal, or an unacceptable
|
||
# administrator public key aborts with an explicit message and a non-zero exit;
|
||
# * no secret material is ever printed, logged, or copied. Only key paths, never key
|
||
# content, are used in messages.
|
||
set -euo pipefail
|
||
|
||
PROG='bf-firstboot'
|
||
DEALER_ID_RE='^[0-9]{8}$'
|
||
DEFAULT_DEALER_FILE='/etc/blueforce/dealer-id'
|
||
DEFAULT_ADMIN_KEY_FILE='/etc/blueforce/admin-authorized-key.pub'
|
||
DEFAULT_MARKER='/var/lib/blueforce/firstboot-complete'
|
||
DEFAULT_INSTALLER='/usr/local/sbin/blueforce-install.sh'
|
||
AUDIT_LOG="${BF_REIDENTITY_AUDIT_LOG:-/var/log/blueforce-reidentity.log}"
|
||
|
||
MARKER="${BF_FIRSTBOOT_MARKER:-$DEFAULT_MARKER}"
|
||
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
|
||
INPUT_TIMEOUT="${BF_FIRSTBOOT_INPUT_TIMEOUT:-300}"
|
||
MAX_ATTEMPTS="${BF_FIRSTBOOT_MAX_ATTEMPTS:-3}"
|
||
|
||
DEALER_ID=''
|
||
DEALER_FILE="$DEFAULT_DEALER_FILE"
|
||
DEALER_FILE_GIVEN=0
|
||
CHECK_ONLY=0
|
||
FORCE_REIDENTITY=0
|
||
FORCED_INTERACTIVE=0
|
||
NONINTERACTIVE="${BF_FIRSTBOOT_NONINTERACTIVE:-0}"
|
||
TTY_FD=''
|
||
WIZ_OUT_FD='1'
|
||
INSTALLER=''
|
||
KEY_FILE=''
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
Usage: bf-firstboot [--dealer-id 8DIGITS] [--dealer-id-file PATH]
|
||
[--interactive] [--non-interactive] [--check]
|
||
|
||
Interactive (default when a console is present): the wizard prints the field console
|
||
banner, asks for the 8-digit dealer number, shows the resulting Device ID and hostname,
|
||
and asks for confirmation before anything is installed.
|
||
|
||
Automation: --dealer-id or --dealer-id-file (or BF_FIRSTBOOT_NONINTERACTIVE=1 with the
|
||
seeded dealer id file) never prompts, so the offline installer can run unattended.
|
||
|
||
--check validate the inputs and report; creates no state and no marker
|
||
--interactive force the wizard, even when a seeded dealer id file exists
|
||
--non-interactive never prompt; use --dealer-id-file or the seeded dealer id file
|
||
|
||
Identity changes are not part of this command. They use the separate admin-only
|
||
--force-reidentity path, which is documented in provisioning/firstboot/README.md and
|
||
stays disabled unless BF_ALLOW_REIDENTITY=1 is set by an operator at the console.
|
||
EOF
|
||
}
|
||
|
||
# Every message carries dealer ids, device ids and paths only. Secret material (key
|
||
# contents, tokens, passwords) is never formatted into them, so printing them is safe.
|
||
# The systemd unit sends stdout/stderr to the console TTY, so each line is also mirrored
|
||
# to the journal through logger when that tool is present.
|
||
journal() {
|
||
command -v logger >/dev/null 2>&1 || return 0
|
||
printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" | logger -t "$PROG" 2>/dev/null || true
|
||
}
|
||
log() { printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*"; journal "$*"; }
|
||
warn() { printf '%s [%s] WARNING: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" >&2; journal "WARNING: $*"; }
|
||
err() { printf '%s [%s] ERROR: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PROG" "$*" >&2; journal "ERROR: $*"; }
|
||
|
||
bf_device_id() { printf 'BF-%s' "$1"; }
|
||
bf_hostname() { printf 'bf-%s' "$1"; }
|
||
valid_dealer_id() { [[ "$1" =~ $DEALER_ID_RE ]]; }
|
||
|
||
parse_args() {
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--dealer-id)
|
||
[[ -n "${2:-}" ]] || { err '--dealer-id needs a value: exactly 8 digits'; usage >&2; exit 2; }
|
||
DEALER_ID="$2"; shift 2;;
|
||
--dealer-id-file)
|
||
[[ -n "${2:-}" ]] || { err '--dealer-id-file needs a path'; usage >&2; exit 2; }
|
||
DEALER_FILE="$2"; DEALER_FILE_GIVEN=1; shift 2;;
|
||
--check) CHECK_ONLY=1; shift;;
|
||
--force-reidentity) FORCE_REIDENTITY=1; shift;;
|
||
--interactive) FORCED_INTERACTIVE=1; shift;;
|
||
--non-interactive) NONINTERACTIVE=1; shift;;
|
||
--help|-h) usage; exit 0;;
|
||
*) err "unknown argument: $1"; usage >&2; exit 2;;
|
||
esac
|
||
done
|
||
[[ "$MAX_ATTEMPTS" =~ ^[1-9][0-9]*$ ]] || { err 'BF_FIRSTBOOT_MAX_ATTEMPTS must be a positive integer'; exit 2; }
|
||
[[ "$INPUT_TIMEOUT" =~ ^[0-9]+$ ]] || { err 'BF_FIRSTBOOT_INPUT_TIMEOUT must be a number of seconds'; exit 2; }
|
||
[[ "$NONINTERACTIVE" == 0 || "$NONINTERACTIVE" == 1 ]] || { err 'BF_FIRSTBOOT_NONINTERACTIVE must be 0 or 1'; exit 2; }
|
||
}
|
||
|
||
read_dealer_id_file() {
|
||
local path="$1" value
|
||
[[ -f "$path" && -r "$path" ]] || { err "dealer id file is missing or unreadable: $path"; exit 2; }
|
||
value="$(tr -d '[:space:]' < "$path" || true)"
|
||
[[ -n "$value" ]] || { err "dealer id file is empty: $path"; exit 2; }
|
||
printf '%s' "$value"
|
||
}
|
||
|
||
marker_exists() { [[ -s "$MARKER" && -f "$MARKER" && -r "$MARKER" ]] && valid_dealer_id "$(tr -d '[:space:]' < "$MARKER" 2>/dev/null || true)"; }
|
||
|
||
marker_device_id() {
|
||
[[ -f "$MARKER" && -r "$MARKER" ]] || return 0
|
||
local value
|
||
value="$(tr -d '[:space:]' < "$MARKER" || true)"
|
||
valid_dealer_id "$value" || return 0
|
||
printf '%s' "$value"
|
||
}
|
||
|
||
write_marker() {
|
||
local tmp
|
||
install -d -m 700 "$(dirname "$MARKER")"
|
||
tmp="$MARKER.tmp.$$"
|
||
printf '%s\n' "$DEALER_ID" > "$tmp"
|
||
chmod 600 "$tmp"
|
||
mv -f "$tmp" "$MARKER"
|
||
log "marker written: $MARKER (device $(bf_device_id "$DEALER_ID"))"
|
||
}
|
||
|
||
# Attach the wizard to a terminal. stdin is used when it already is a terminal (this is
|
||
# the systemd StandardInput=tty-force case); otherwise /dev/tty is opened, and when that
|
||
# fails too the caller falls back to the automation input path.
|
||
open_tty() {
|
||
if [[ -t 0 ]]; then TTY_FD=0; WIZ_OUT_FD=1; return 0; fi
|
||
if { exec 3<>/dev/tty; } 2>/dev/null; then TTY_FD=3; WIZ_OUT_FD=3; return 0; fi
|
||
return 1
|
||
}
|
||
|
||
require_tty() {
|
||
open_tty && return 0
|
||
err 'a terminal is required for the interactive wizard, but none is available; pass --dealer-id or --dealer-id-file instead'
|
||
exit 2
|
||
}
|
||
|
||
wizard_out() { printf '%s\n' "$*" >&"$WIZ_OUT_FD"; }
|
||
wizard_write() { printf '%s' "$*" >&"$WIZ_OUT_FD"; }
|
||
|
||
wizard_read() {
|
||
local __name="$1" __value=''
|
||
read -r -t "$INPUT_TIMEOUT" -u "$TTY_FD" __value || return 1
|
||
printf -v "$__name" '%s' "${__value%$'\r'}"
|
||
}
|
||
|
||
# Trim surrounding whitespace only: a pasted value keeps working, while any other
|
||
# deviation from the 8-digit identity is rejected and asked again.
|
||
trim_space() {
|
||
local value="$1"
|
||
value="${value#"${value%%[![:space:]]*}"}"
|
||
value="${value%"${value##*[![:space:]]}"}"
|
||
printf '%s' "$value"
|
||
}
|
||
|
||
# Interactive identity collection. A malformed number or a refused confirmation is never
|
||
# accepted: after MAX_ATTEMPTS the run aborts without changing anything.
|
||
wizard_collect() {
|
||
local attempt=1 line answer
|
||
wizard_out '# BLUEFORCE FIELD OS'
|
||
while (( attempt <= MAX_ATTEMPTS )); do
|
||
wizard_out '8 Haneli Bayi Numarası:'
|
||
wizard_write '> '
|
||
if ! wizard_read line; then
|
||
err 'no terminal input arrived (timed out); first boot aborted without changes'
|
||
exit 2
|
||
fi
|
||
wizard_out ''
|
||
line="$(trim_space "$line")"
|
||
if valid_dealer_id "$line"; then
|
||
wizard_out "Device ID (cihaz kimliği): $(bf_device_id "$line")"
|
||
wizard_out "Hostname (makine adı) : $(bf_hostname "$line")"
|
||
wizard_write "Kurulum bu kimlikle başlatılsın mı? [e/H]: "
|
||
if ! wizard_read answer; then
|
||
err 'no terminal input arrived (timed out); first boot aborted without changes'
|
||
exit 2
|
||
fi
|
||
wizard_out ''
|
||
if [[ "$answer" =~ ^([eE]|[eE]vet|y|Y|yes|YES)$ ]]; then
|
||
DEALER_ID="$line"
|
||
log "dealer number accepted at the console as $(bf_device_id "$DEALER_ID") on $(bf_hostname "$DEALER_ID")"
|
||
return 0
|
||
fi
|
||
wizard_out "Onay verilmedi; yeniden soruluyor. (deneme $attempt/$MAX_ATTEMPTS)"
|
||
else
|
||
wizard_out "Geçersiz giriş; 8 hane rakam gerekli. (deneme $attempt/$MAX_ATTEMPTS)"
|
||
fi
|
||
attempt=$((attempt + 1))
|
||
done
|
||
err "dealer number was not accepted after $MAX_ATTEMPTS attempts; first boot aborted without changes"
|
||
exit 2
|
||
}
|
||
|
||
# Identity resolution order: explicit flag, explicit file, forced wizard, explicit
|
||
# non-interactive mode, seeded dealer id file, wizard, hard failure.
|
||
resolve_dealer_id() {
|
||
local value
|
||
if [[ -n "$DEALER_ID" ]]; then
|
||
log 'dealer number supplied on the command line (non-interactive)'
|
||
return 0
|
||
fi
|
||
if [[ "$DEALER_FILE_GIVEN" == 1 ]]; then
|
||
DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")"
|
||
log "dealer number read from $DEALER_FILE (non-interactive)"
|
||
return 0
|
||
fi
|
||
if [[ "$FORCED_INTERACTIVE" == 1 ]]; then
|
||
require_tty
|
||
wizard_collect
|
||
return 0
|
||
fi
|
||
if [[ "$NONINTERACTIVE" == 1 ]]; then
|
||
DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")"
|
||
log "non-interactive mode: dealer number read from $DEALER_FILE"
|
||
return 0
|
||
fi
|
||
if [[ -f "$DEALER_FILE" && -r "$DEALER_FILE" ]]; then
|
||
value="$(tr -d '[:space:]' < "$DEALER_FILE" || true)"
|
||
if valid_dealer_id "$value"; then
|
||
DEALER_ID="$value"
|
||
log "seeded dealer id file used: $DEALER_FILE (wizard skipped)"
|
||
return 0
|
||
fi
|
||
warn "seeded dealer id file exists but is not acceptable: $DEALER_FILE"
|
||
fi
|
||
if open_tty; then
|
||
wizard_collect
|
||
return 0
|
||
fi
|
||
err "no terminal is available for the wizard and no dealer number was supplied; pass --dealer-id, --dealer-id-file, or seed $DEALER_FILE"
|
||
exit 2
|
||
}
|
||
|
||
require_dealer_id() {
|
||
[[ -n "$DEALER_ID" ]] || { err 'a dealer number is required (8 digits)'; exit 2; }
|
||
valid_dealer_id "$DEALER_ID" || { err 'invalid dealer number: exactly 8 digits are required'; exit 2; }
|
||
}
|
||
|
||
# The administrator public key is a mandatory offline prerequisite. Only a public key in
|
||
# a root-owned, not group/world writable file is accepted; private or inline key material
|
||
# is refused and never echoed.
|
||
validate_admin_key() {
|
||
KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}"
|
||
if [[ -z "$KEY_FILE" && -r "$DEFAULT_ADMIN_KEY_FILE" ]]; then KEY_FILE="$DEFAULT_ADMIN_KEY_FILE"; fi
|
||
[[ -n "$KEY_FILE" && -f "$KEY_FILE" && -r "$KEY_FILE" ]] || { echo 'OFFLINE BLOCKED: seed-provided BF_ADMIN_PUBLIC_KEY_FILE is required.' >&2; exit 1; }
|
||
[[ "$(stat -c '%U:%a' "$KEY_FILE")" =~ ^root:6[04]0$ ]] || { echo 'OFFLINE BLOCKED: administrator public-key file must be root-owned and not group/world writable.' >&2; exit 1; }
|
||
grep -qE '^(ssh-(ed25519|rsa|ecdsa)|sk-ssh-ed25519)' "$KEY_FILE" && ! grep -q 'PRIVATE KEY' "$KEY_FILE" || { echo 'OFFLINE BLOCKED: BF_ADMIN_PUBLIC_KEY_FILE must contain only a public SSH key.' >&2; exit 1; }
|
||
export BF_ADMIN_PUBLIC_KEY_FILE="$KEY_FILE"
|
||
log "administrator public key file accepted: $KEY_FILE"
|
||
}
|
||
|
||
resolve_installer() {
|
||
INSTALLER="${BF_INSTALLER_PATH:-$DEFAULT_INSTALLER}"
|
||
if [[ ! -x "$INSTALLER" ]]; then
|
||
INSTALLER="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../scripts/install" 2>/dev/null && pwd || true)/blueforce-install.sh"
|
||
fi
|
||
[[ -x "$INSTALLER" ]] || { err "offline installer is unavailable: $INSTALLER"; exit 1; }
|
||
log "offline installer: $INSTALLER"
|
||
}
|
||
|
||
# The installer is always called offline with the validated dealer number and --yes,
|
||
# because this script already obtained the operator decision (wizard) or runs in an
|
||
# explicitly requested automation mode. A previous partial run is resumed.
|
||
run_installer() {
|
||
local -a args=(--offline --dealer-id "$DEALER_ID" --yes)
|
||
if [[ -s "$STATE_DIR/install-state" ]]; then
|
||
# A pending install-state belongs to the identity it was recorded under. After an
|
||
# identity change the old module results must not be reused against the new one.
|
||
if [[ -f "$MARKER" && -r "$MARKER" ]]; then
|
||
local stamped
|
||
stamped="$(tr -d '[:space:]' < "$MARKER" 2>/dev/null || true)"
|
||
if [[ -n "$stamped" && "$stamped" != "$DEALER_ID" ]]; then
|
||
log "dropping stale install-state: recorded under $stamped, reinstalling for $DEALER_ID"
|
||
rm -f "$STATE_DIR/install-state"
|
||
fi
|
||
fi
|
||
if [[ -s "$STATE_DIR/install-state" ]]; then
|
||
args+=(--resume)
|
||
log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting"
|
||
fi
|
||
fi
|
||
if [[ "${BF_FIRSTBOOT_TEST_MODE:-0}" == 1 ]]; then
|
||
echo "TEST: would run $INSTALLER ${args[*]}"
|
||
exit 0
|
||
fi
|
||
"$INSTALLER" "${args[@]}"
|
||
}
|
||
|
||
audit_log() {
|
||
local message="$1"
|
||
install -d -m 700 "$(dirname "$AUDIT_LOG")" 2>/dev/null || true
|
||
printf '%s uid=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(id -u)" "$message" >> "$AUDIT_LOG" || {
|
||
err "identity change refused: audit log is not writable ($AUDIT_LOG)"
|
||
return 1
|
||
}
|
||
chmod 600 "$AUDIT_LOG" 2>/dev/null || true
|
||
log "AUDIT: $message"
|
||
}
|
||
|
||
# Admin/recovery path for changing the device identity. Disabled by default: it needs
|
||
# root, the BF_ALLOW_REIDENTITY=1 operator gate, a warning, a typed confirmation of the
|
||
# new dealer number, and a written audit record. The normal path and the wizard never
|
||
# reach this function.
|
||
reidentity() {
|
||
local existing new_id typed stamp
|
||
existing="$(marker_device_id)"
|
||
[[ "$(id -u)" -eq 0 ]] || { err '--force-reidentity is an administrative procedure and requires root'; exit 1; }
|
||
[[ "${BF_ALLOW_REIDENTITY:-0}" == 1 ]] || {
|
||
err 'identity change is disabled by default; an operator must set BF_ALLOW_REIDENTITY=1 and run --force-reidentity from the console as root'
|
||
exit 1
|
||
}
|
||
resolve_dealer_id
|
||
require_dealer_id
|
||
new_id="$DEALER_ID"
|
||
warn 'IDENTITY CHANGE: this machine will be re-identified and re-provisioned.'
|
||
warn "old identity: ${existing:-<none>} new identity: $(bf_device_id "$new_id") / $(bf_hostname "$new_id")"
|
||
warn 'All nine central systems that carry the identity must be updated after this step.'
|
||
require_tty
|
||
wizard_out ''
|
||
wizard_out "DİKKAT: kimlik değişimi. Eski: ${existing:-yok} Yeni: $(bf_device_id "$new_id")"
|
||
wizard_write "Onaylamak için yeni bayi numarasını yeniden yazın: "
|
||
if ! wizard_read typed; then
|
||
err 'no terminal input arrived (timed out); identity change aborted without changes'
|
||
exit 2
|
||
fi
|
||
wizard_out ''
|
||
typed="$(trim_space "$typed")"
|
||
[[ "$typed" == "$new_id" ]] || { err 'confirmation did not match; identity change aborted without changes'; exit 2; }
|
||
|
||
audit_log "identity-change requested old=${existing:-none} new=$(bf_device_id "$new_id")" || exit 1
|
||
validate_admin_key
|
||
resolve_installer
|
||
run_installer
|
||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
if [[ -f "$MARKER" ]]; then
|
||
cp -p "$MARKER" "$MARKER.reidentified-$stamp" || true
|
||
chmod 600 "$MARKER.reidentified-$stamp" 2>/dev/null || true
|
||
fi
|
||
write_marker
|
||
audit_log "identity-change applied old=${existing:-none} new=$(bf_device_id "$new_id") marker-archive=$MARKER.reidentified-$stamp"
|
||
log "identity change complete; update the remaining central systems before the device is used"
|
||
}
|
||
|
||
main() {
|
||
local existing
|
||
parse_args "$@"
|
||
|
||
# Check mode is read-only: it never prompts, never writes state, and never installs.
|
||
if [[ "$CHECK_ONLY" == 1 ]]; then
|
||
if marker_exists; then
|
||
existing="$(marker_device_id)"
|
||
log "check: first boot already completed${existing:+ for $existing}; nothing would change"
|
||
exit 0
|
||
fi
|
||
if [[ -z "$DEALER_ID" ]]; then
|
||
if [[ -f "$DEALER_FILE" && -r "$DEALER_FILE" ]]; then
|
||
DEALER_ID="$(read_dealer_id_file "$DEALER_FILE")"
|
||
else
|
||
err 'check mode needs a dealer number: pass --dealer-id or --dealer-id-file (no prompt is used in check mode)'
|
||
exit 2
|
||
fi
|
||
fi
|
||
require_dealer_id
|
||
log "check: dealer number $(bf_device_id "$DEALER_ID") is acceptable on $(bf_hostname "$DEALER_ID"); no changes were made"
|
||
exit 0
|
||
fi
|
||
|
||
# Idempotency: a stamped device is finished. Nothing is prompted, written, or installed.
|
||
if marker_exists && [[ "$FORCE_REIDENTITY" != 1 ]]; then
|
||
existing="$(marker_device_id)"
|
||
log "first boot already completed${existing:+ for $existing}; nothing to do"
|
||
if [[ -n "$DEALER_ID" && -n "$existing" && "$DEALER_ID" != "$existing" ]]; then
|
||
warn "requested dealer number differs from the provisioned identity ($existing); identity changes use the admin procedure described in provisioning/firstboot/README.md"
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
if [[ "$FORCE_REIDENTITY" == 1 ]]; then
|
||
reidentity
|
||
exit 0
|
||
fi
|
||
|
||
resolve_dealer_id
|
||
require_dealer_id
|
||
[[ "$(id -u)" -eq 0 ]] || { err 'first boot provisioning must run as root'; exit 1; }
|
||
validate_admin_key
|
||
resolve_installer
|
||
run_installer
|
||
write_marker
|
||
log "first boot finished for $(bf_device_id "$DEALER_ID"); enrollment and READY are separate steps"
|
||
}
|
||
|
||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
|