Blueforce-Linux/scripts/diagnostics/bf-enrollment-status
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

223 lines
8.5 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# bf-enrollment-status - read-only canonical Field OS lifecycle view.
#
# Lifecycle contract (docs/29-DEVICE-ENROLLMENT.md, provisioning/enrollment/README.md):
#
# PROVISIONED_OFFLINE local (offline) provisioning is complete. No internet,
# no central record and no WireGuard handshake is needed
# or claimed at this stage.
# ENROLLED central accepted the device, a WireGuard peer was
# assigned and monitoring/inventory/remote access
# configuration is applied (or being applied).
# READY local health plus every central channel (monitoring,
# remote, management) is verified.
#
# Transitions are forward-only and never skip a stage:
# (none) -> PROVISIONED_OFFLINE -> ENROLLED -> READY
# A missing stage, a backwards move, or READY without verified central
# evidence is reported as INVALID and exits non-zero (fail closed).
#
# State file: ${BF_STATE_FILE:-/var/lib/blueforce/state.json}
# mode 0600; fields phase, enrollment_status, fleet_status, device_id,
# provisioning_id. Unknown extra fields are preserved by writers and ignored
# here.
#
# Output vocabulary (stable, consumed by bf-status/bf-check-*):
# Lifecycle : PROVISIONED_OFFLINE | ENROLLED | READY | PENDING | INVALID
# Enrollment : PENDING | ENROLLED | COMPLETE
# Fleet : PENDING | NOT_READY | READY
#
# Exit status: 0 lifecycle valid, 1 missing/invalid state, 2 usage error.
# Never writes anything and never prints secrets.
#
set -euo pipefail
STATE_FILE="${BF_STATE_FILE:-${STATE_DIR:-/var/lib/blueforce}/state.json}"
# Canonical lifecycle phases; the order in this list is the only legal order.
VALID_STATES='PROVISIONED_OFFLINE ENROLLED READY'
FIELD='all'
usage() {
cat <<'EOF'
Usage: bf-enrollment-status [--field phase|enrollment|fleet] [--check]
Print the canonical Field OS lifecycle state (read-only, no secrets).
Options:
--field NAME print one bare value instead of the three-line report
(NAME is phase, enrollment or fleet)
--check accepted for symmetry with the installer modules; this
command never mutates anything
--help show this help
Exit status: 0 valid lifecycle, 1 missing or contract-violating state,
2 usage error.
Lifecycle vocabulary : PROVISIONED_OFFLINE | ENROLLED | READY | PENDING | INVALID
Enrollment vocabulary: PENDING | ENROLLED | COMPLETE
Fleet vocabulary : PENDING | NOT_READY | READY
Forward-only transitions: PROVISIONED_OFFLINE -> ENROLLED -> READY.
EOF
}
if [[ "${1:-}" == '--help' || "${1:-}" == '-h' ]]; then
usage
exit 0
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--field)
[[ $# -ge 2 ]] || { echo 'ERROR: --field needs a value' >&2; exit 2; }
FIELD="$2"
shift 2
;;
--check)
shift
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
case "$FIELD" in
all|phase|enrollment|fleet) ;;
*) echo "ERROR: --field must be phase, enrollment or fleet (got: $FIELD)" >&2; exit 2 ;;
esac
if [[ ! -r "$STATE_FILE" ]]; then
# No lifecycle state yet: the device has not completed offline provisioning.
if [[ "$FIELD" == 'all' ]]; then
printf 'Lifecycle: PENDING\nEnrollment: PENDING\nFleet: PENDING\n'
else
printf 'PENDING\n'
fi
exit 1
fi
python3 - "$STATE_FILE" "$FIELD" <<'PY'
import json
import os
import re
import stat
import sys
path, field = sys.argv[1], sys.argv[2]
VALID_STATES = ('PROVISIONED_OFFLINE', 'ENROLLED', 'READY')
VALID_ENROLLMENT = ('PENDING', 'ENROLLED', 'COMPLETE')
VALID_FLEET = ('PENDING', 'NOT_READY', 'READY')
CHANNELS = ('monitoring', 'remote', 'management')
DEVICE_ID = re.compile(r'^BF-[0-9]{8}$')
def invalid(reason):
sys.stderr.write('INVALID: %s\n' % reason)
print('INVALID' if field != 'all' else 'Lifecycle: INVALID')
raise SystemExit(1)
try:
with open(path, encoding='utf-8') as handle:
state = json.load(handle)
except OSError as error:
invalid('state file is unreadable (%s)' % error.__class__.__name__)
except ValueError as error:
invalid('state file is not valid JSON (%s)' % error.__class__.__name__)
if not isinstance(state, dict):
invalid('state file is not a JSON object')
phase = state.get('phase')
if phase not in VALID_STATES:
invalid('phase is missing or not one of %s' % ', '.join(VALID_STATES))
enrollment = state.get('enrollment_status')
if enrollment not in VALID_ENROLLMENT:
invalid('enrollment_status is missing or not one of %s' % ', '.join(VALID_ENROLLMENT))
fleet = state.get('fleet_status')
if fleet not in VALID_FLEET:
invalid('fleet_status is missing or not one of %s' % ', '.join(VALID_FLEET))
device_id = state.get('device_id')
if not isinstance(device_id, str) or not DEVICE_ID.match(device_id):
invalid('device_id is missing or does not match BF-<8 digits>')
provisioning_id = state.get('provisioning_id')
if not isinstance(provisioning_id, str) or not provisioning_id or len(provisioning_id) > 128 \
or any(character.isspace() for character in provisioning_id):
invalid('provisioning_id is missing or malformed')
def evidence_problem(record):
"""Return a reason string when central verification evidence is unusable."""
if not isinstance(record, dict):
return 'central_verification is missing'
if set(record) - {'status', 'verification_id', 'monitoring', 'remote', 'management'}:
return 'central_verification carries unexpected fields'
if not {'status', 'verification_id'}.issubset(record):
return 'central_verification is incomplete'
if record['status'] not in ('pending', 'verified'):
return 'central_verification.status is invalid'
if not isinstance(record['verification_id'], str) or not record['verification_id']:
return 'central_verification.verification_id is missing'
for name in CHANNELS:
item = record.get(name)
if not isinstance(item, dict) or set(item) != {'status', 'verification_id'}:
return 'central_verification.%s is missing or malformed' % name
if item['status'] not in ('pending', 'verified'):
return 'central_verification.%s.status is invalid' % name
if not isinstance(item['verification_id'], str) or not item['verification_id']:
return 'central_verification.%s.verification_id is missing' % name
return None
record = state.get('central_verification')
problem = evidence_problem(record)
if phase == 'PROVISIONED_OFFLINE':
if enrollment != 'PENDING':
invalid('PROVISIONED_OFFLINE requires enrollment_status PENDING (got %s)' % enrollment)
if fleet == 'READY':
invalid('PROVISIONED_OFFLINE cannot be fleet READY: a stage was skipped')
if record is not None:
sys.stderr.write('WARN: central_verification is present before enrollment\n')
elif phase == 'ENROLLED':
if enrollment not in ('ENROLLED', 'COMPLETE'):
invalid('ENROLLED requires enrollment_status ENROLLED or COMPLETE (got %s)' % enrollment)
if fleet == 'READY':
invalid('ENROLLED cannot be fleet READY: fleet READY is recorded with phase READY')
if problem:
invalid(problem)
elif phase == 'READY':
if enrollment not in ('ENROLLED', 'COMPLETE'):
invalid('READY requires a completed enrollment (got %s)' % enrollment)
if fleet != 'READY':
invalid('READY requires fleet_status READY (got %s)' % fleet)
if problem:
invalid(problem)
pending = [name for name in CHANNELS if record[name]['status'] != 'verified']
if record['status'] != 'verified' or pending:
invalid('READY requires verified central evidence for every channel')
try:
mode = stat.S_IMODE(os.stat(path).st_mode)
if mode & 0o077:
sys.stderr.write('WARN: %s is mode %04o; the lifecycle contract requires 0600\n' % (path, mode))
except OSError:
pass
# Enrolment is reported as COMPLETE once READY is recorded: both facts come from
# the same forward-only transition chain.
enrollment_display = 'COMPLETE' if (phase == 'READY' or enrollment == 'COMPLETE') else enrollment
fleet_display = 'READY' if fleet == 'READY' else 'NOT READY'
if field == 'phase':
print(phase)
elif field == 'enrollment':
print(enrollment_display)
elif field == 'fleet':
print(fleet_display)
else:
print('Lifecycle: ' + phase)
print('Enrollment: ' + enrollment_display)
print('Fleet: ' + fleet_display)
PY