Blueforce-Linux/provisioning/firstboot/bf-firstboot
emirhanmamak-blueforce ef102b9657 feat(field-os): provisioning layer, real ISO build, offline contracts
Adds the Blueforce Field OS provisioning stack on top of the existing fleet
architecture, without rewriting the working systems (installer, diagnostics,
Ansible, WireGuard/SSH/xRDP/RustDesk, Docker policies, monitoring, Docusaurus).

ISO build (the previous builder was a skeleton that produced no medium):
- boot layout is read from the base ISO at build time via
  `xorriso -report_el_torito as_mkisofs` and rebuilt with `-as mkisofs`;
  `-boot_image any replay` cannot work on Ubuntu live ISOs because the GRUB2
  MBR lives in the source image's first 16 sectors and the EFI system partition
  is an appended partition, neither of which is a file in the ISO9660 tree
- preflight now asserts El Torito boot metadata positively: xorriso exits 0 on
  a non-ISO input, which previously let an arbitrary file pass --check
- artifact produced and verified: dist/Blueforce-Field-OS-1.0.0-amd64.iso,
  6 481 917 952 bytes, SHA256 cb0cc56f...d431296, BIOS+UEFI entries preserved,
  /autoinstall.yaml at the media root, casper/minimal.squashfs byte-identical
  to the base
- recorded baseline deviation: the verified medium on the build host is the
  Desktop flavour, so the manifest carries a baseline_deviation block instead
  of hiding it; Server remains one flag away

Offline and identity:
- offline APT contract with concrete package requirements, pin format and an
  audit table; builds fail closed while pins are UNPINNED
- autoinstall keeps interactive-sections: [storage]: the operator confirms the
  target disk, and no automatic wipe is ever performed
- firstboot terminal wizard for the 8-digit dealer id, three-state lifecycle
  (PROVISIONED_OFFLINE -> ENROLLED -> READY) with fail-closed gates, so a
  missing internet link or WireGuard handshake cannot fail provisioning but can
  never produce a false READY

Fleet and migration:
- five read-only Ansible playbooks for Field OS version, provisioning,
  enrollment, remote channels and offline readiness, all wave-scoped with a
  separate production override
- Windows pre-migration inventory and data export extended to the full brief
  scope, read-only, never collecting credentials

Tests: tests/run-all.sh -> 8/8 pass, exit 0. Boot acceptance (UEFI, Legacy
BIOS, Secure Boot), air-gapped install, real enrollment endpoint and Windows
rollback remain LAB work and are listed in IMPLEMENTATION-REPORT.md.
2026-09-16 11:14:42 +03:00

390 lines
16 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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() { [[ -e "$MARKER" ]]; }
marker_device_id() {
[[ -f "$MARKER" ]] || return 0
tr -d '[:space:]' < "$MARKER" || true
}
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
args+=(--resume)
log "partial install state found in $STATE_DIR/install-state; resuming instead of restarting"
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