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

609 lines
24 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# bf-enroll - one-time enrollment client for a PROVISIONED_OFFLINE device.
#
# Security contract (docs/29-DEVICE-ENROLLMENT.md, provisioning/enrollment/README.md):
# * The one-time token is read from stdin (--token-stdin) or from a root-only
# single-use file staged for the boot timer (--systemd / --stage). It is
# never taken from argv, never logged, never written into the lifecycle
# state, a support bundle or removable media.
# * The WireGuard private key never leaves the device. The request carries only
# the public key plus non-secret hardware/release facts; the WireGuard
# configuration is assembled locally from the local private key and the
# validated peer fields of the response. A response that tries to inject a
# private key or an unexpected field is rejected.
# * The idempotency key is derived deterministically from
# provisioning_id + BF-<no> + public key, so central can deduplicate retries
# of the same device/release while the token itself stays single-use.
# * Every state update is an atomic tmp+rename write with mode 0600 and a
# forward-only transition check under a lock.
#
# Lifecycle (forward-only, never skipping a stage):
# (none) -> PROVISIONED_OFFLINE -> ENROLLED -> READY
# * This script performs PROVISIONED_OFFLINE -> ENROLLED only.
# * ENROLLED may be re-applied with --reenroll (idempotent re-enrollment);
# the phase never moves backwards.
# * READY never returns to ENROLLED; re-provision from the offline media.
#
# Failure handling: a failed request or a failed WireGuard activation rolls the
# configuration back and leaves the lifecycle at its prior state (PENDING or
# PROVISIONED_OFFLINE). Nothing reboots and nothing retries in a loop; the
# bounded retry lives in blueforce-enroll.service.
#
# Usage:
# bf-enroll --token-stdin [--reenroll] [--check] immediate enrollment
# bf-enroll --token-stdin --stage [--check] stage the token, enroll later
# bf-enroll --systemd [--check] attempt used by the boot timer
#
# Environment:
# BF_ENROLLMENT_ENDPOINT HTTPS enrollment endpoint (central configuration)
# BF_ENROLLMENT_TIMEOUT connect/max seconds per attempt (default 20)
# BF_ENROLLMENT_TOKEN_FILE staged token path (default <state dir>/enrollment.token)
# BF_ENROLLMENT_TOKEN_MAX_AGE staged token TTL in seconds (default 1800)
# BF_STATE_FILE / STATE_DIR / WG_DIR test and installer overrides
#
set -euo pipefail
umask 077
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
STATE_FILE="${BF_STATE_FILE:-$STATE_DIR/state.json}"
WG_DIR="${WG_DIR:-/etc/wireguard}"
ENDPOINT="${BF_ENROLLMENT_ENDPOINT:-}"
TIMEOUT="${BF_ENROLLMENT_TIMEOUT:-20}"
TOKEN_FILE="${BF_ENROLLMENT_TOKEN_FILE:-$STATE_DIR/enrollment.token}"
TOKEN_MAX_AGE="${BF_ENROLLMENT_TOKEN_MAX_AGE:-1800}"
MODE=''
STAGE=0
REENROLL=0
CHECK_MODE=0
TOKEN=''
HTTP_CODE=''
REQUEST_FILE=''
RESPONSE_FILE=''
STAGED_CONFIG=''
BACKUP_CONFIG=''
CV_FILE=''
usage() {
cat <<'EOF'
Usage: bf-enroll --token-stdin [--reenroll] [--check]
bf-enroll --token-stdin --stage [--check]
bf-enroll --systemd [--check]
Obtain the hub WireGuard peer configuration for a PROVISIONED_OFFLINE device
and record the ENROLLED lifecycle phase.
Token sources (a token is never accepted from argv and never logged):
--token-stdin read the one-time token once from stdin and enroll now
--stage store the stdin token as a root-only single-use file
(mode 0600, TTL BF_ENROLLMENT_TOKEN_MAX_AGE) for the boot timer
--systemd attempt used by blueforce-enroll.service: PENDING while no
token is staged, transient failures exit non-zero so the
bounded Restart= can retry, definitive rejections discard the
staged token
Options:
--reenroll allow re-applying enrollment on a device already in ENROLLED
(the phase never moves backwards; READY is refused)
--check dry run: validate configuration and state, print the planned
actions, write nothing and send nothing
--help show this help
Exit status: 0 success (or nothing to do), 1 failure, 2 usage error.
EOF
}
die() { printf 'bf-enroll: %s\n' "$*" >&2; exit 1; }
note() { printf 'bf-enroll: %s\n' "$*"; }
# A lifecycle precondition that cannot be met. Interactive runs fail loudly,
# the boot attempt reports PENDING and exits 0 so it never becomes a retry loop.
gate_block() {
if [[ "$MODE" == 'systemd' ]]; then
printf 'bf-enroll: PENDING: %s\n' "$*" >&2
exit 0
fi
die "$*"
}
cleanup() {
unset TOKEN HTTP_CODE REQUEST_FILE RESPONSE_FILE STAGED_CONFIG BACKUP_CONFIG CV_FILE
return 0
}
trap cleanup EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--token-stdin)
[[ -z "$MODE" ]] || { echo 'ERROR: only one token source can be selected' >&2; exit 2; }
MODE='stdin'; shift ;;
--systemd)
[[ -z "$MODE" ]] || { echo 'ERROR: only one token source can be selected' >&2; exit 2; }
MODE='systemd'; shift ;;
--stage) STAGE=1; shift ;;
--reenroll) REENROLL=1; shift ;;
--check) CHECK_MODE=1; shift ;;
--help|-h) usage; exit 0 ;;
*) printf 'ERROR: unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
[[ -n "$MODE" ]] || { usage >&2; exit 2; }
if [[ "$STAGE" -eq 1 && "$MODE" != 'stdin' ]]; then
echo 'ERROR: --stage applies to --token-stdin only' >&2
exit 2
fi
[[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || die 'BF_ENROLLMENT_TIMEOUT must be positive seconds'
[[ "$TOKEN_MAX_AGE" =~ ^[1-9][0-9]*$ ]] || die 'BF_ENROLLMENT_TOKEN_MAX_AGE must be positive seconds'
validate_endpoint() {
[[ "$ENDPOINT" =~ ^https://[^[:space:]/]+(/[^[:space:]]*)?$ ]] || die 'BF_ENROLLMENT_ENDPOINT must be a non-placeholder HTTPS URL'
case "$ENDPOINT" in
*example*|*invalid*|*'<'*|*'>'*) die 'BF_ENROLLMENT_ENDPOINT must be a non-placeholder HTTPS URL' ;;
esac
}
validate_token() {
local value="$1"
[[ -n "$value" ]] || return 1
((${#value} >= 8 && ${#value} <= 4096)) || return 1
[[ "$value" != *[[:space:]]* ]] || return 1
# [:graph:] is printable without space: no control characters, no blanks.
[[ "$value" =~ ^[[:graph:]]+$ ]] || return 1
}
mode_bits() { stat -c '%a' "$1" 2>/dev/null || printf ''; }
# --- lifecycle state ---------------------------------------------------------
DEALER_ID="$(tr -d '[:space:]' < "$STATE_DIR/dealer-id" 2>/dev/null || true)"
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || gate_block 'dealer identity is missing or invalid'
DEVICE_ID="BF-$DEALER_ID"
# read_state emits PHASE=/PROVISIONING_ID=/IDEMPOTENCY_KEY= for a usable state,
# or ERROR=<reason>. The idempotency key is stable across retries so central can
# deduplicate, while the one-time token remains single-use.
read_state() {
python3 - "$STATE_FILE" "$DEVICE_ID" "$WG_DIR/publickey" <<'PY'
import hashlib
import json
import sys
path, device, public_key_path = sys.argv[1], sys.argv[2], sys.argv[3]
VALID_STATES = ('PROVISIONED_OFFLINE', 'ENROLLED', 'READY')
def unavailable(reason):
print('ERROR=' + reason)
raise SystemExit(0)
try:
with open(path, encoding='utf-8') as handle:
state = json.load(handle)
except OSError:
unavailable('state-missing')
except ValueError:
unavailable('state-invalid')
if not isinstance(state, dict):
unavailable('state-invalid')
if state.get('phase') not in VALID_STATES:
unavailable('state-invalid')
if state.get('device_id') not in (None, device):
unavailable('identity-mismatch')
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):
unavailable('state-invalid')
try:
with open(public_key_path, encoding='utf-8') as handle:
public_key = handle.read().strip()
except OSError:
unavailable('public-key-missing')
if not public_key:
unavailable('public-key-missing')
idempotency_key = hashlib.sha256((provisioning_id + '|' + device + '|' + public_key).encode()).hexdigest()
print('PHASE=' + state['phase'])
print('PROVISIONING_ID=' + provisioning_id)
print('IDEMPOTENCY_KEY=' + idempotency_key)
PY
}
STATE_OUT="$(read_state)"
PHASE=''
PROVISIONING_ID=''
IDEMPOTENCY=''
while IFS= read -r line; do
case "$line" in
ERROR=state-missing) gate_block 'device has no lifecycle state yet; offline provisioning must complete first' ;;
ERROR=identity-mismatch) gate_block 'lifecycle state belongs to another device (device_id mismatch)' ;;
ERROR=public-key-missing) gate_block 'WireGuard public key is missing; run local provisioning first' ;;
ERROR=*) gate_block 'lifecycle state is unreadable or violates the state contract' ;;
PHASE=*) PHASE="${line#PHASE=}" ;;
PROVISIONING_ID=*) PROVISIONING_ID="${line#PROVISIONING_ID=}" ;;
IDEMPOTENCY_KEY=*) IDEMPOTENCY="${line#IDEMPOTENCY_KEY=}" ;;
esac
done <<<"$STATE_OUT"
[[ -n "$PHASE" && -n "$PROVISIONING_ID" && -n "$IDEMPOTENCY" ]] || gate_block 'lifecycle state is incomplete'
# Forward-only transition gate: no skipping, no backwards moves.
case "$PHASE" in
PROVISIONED_OFFLINE) ;;
ENROLLED)
[[ "$REENROLL" -eq 1 ]] || gate_block 'device is already ENROLLED: refusing to replay an enrollment. Pass --reenroll with a fresh one-time token to re-apply.'
note 're-applying enrollment on an ENROLLED device (--reenroll); the phase never moves backwards'
;;
READY)
gate_block 'device is READY: lifecycle transitions never move backwards. Re-provision from the offline media to enroll again.'
;;
*) gate_block "enrollment is not allowed from lifecycle phase $PHASE" ;;
esac
[[ -s "$WG_DIR/privatekey" && -s "$WG_DIR/publickey" ]] || gate_block 'WireGuard key material is missing; run local provisioning first'
if [[ -n "$(mode_bits "$WG_DIR/privatekey")" ]] && ((8#$(mode_bits "$WG_DIR/privatekey") & 8#077)); then
gate_block "WireGuard private key mode is $(mode_bits "$WG_DIR/privatekey"); it must be root-only (0600)"
fi
WG_PUBLIC_KEY="$(tr -d '[:space:]' < "$WG_DIR/publickey")"
# --- token sources -----------------------------------------------------------
# staged_token_problem reports why a staged token cannot be used. It never
# mutates anything, so --check can call it safely.
staged_token_problem() {
if [[ ! -e "$TOKEN_FILE" ]]; then
printf 'no staged one-time token (%s)\n' "$TOKEN_FILE"
return 0
fi
[[ -f "$TOKEN_FILE" && ! -L "$TOKEN_FILE" ]] && return 1
printf 'staged token path is not a regular file\n'
return 0
}
read_staged_token() {
if [[ ! -e "$TOKEN_FILE" ]]; then
printf 'bf-enroll: PENDING: no staged one-time token; the device waits for a technician (bf-enroll-now) or --stage\n' >&2
exit 0
fi
[[ -f "$TOKEN_FILE" && ! -L "$TOKEN_FILE" ]] || gate_block 'staged token path is not a regular file; refusing to read it'
local owner mode size now mtime
owner="$(stat -c '%u' "$TOKEN_FILE")"
[[ "$owner" == "$(id -u)" ]] || gate_block "staged token must be owned by uid $(id -u); refusing to read it"
mode="$(mode_bits "$TOKEN_FILE")"
if ((8#$mode & 8#077)); then
gate_block "staged token mode is $mode; it must be root-only (0600)"
fi
size="$(stat -c '%s' "$TOKEN_FILE")"
if ((size == 0 || size > 4100)); then
rm -f "$TOKEN_FILE"
gate_block 'staged token file size is invalid; the token was discarded'
fi
now="$(date +%s)"
mtime="$(stat -c '%Y' "$TOKEN_FILE")"
if ((now - mtime > TOKEN_MAX_AGE)); then
rm -f "$TOKEN_FILE"
printf 'bf-enroll: PENDING: staged one-time token expired after %ss and was discarded; stage a fresh token\n' "$TOKEN_MAX_AGE" >&2
exit 0
fi
TOKEN="$(head -n 1 "$TOKEN_FILE" | tr -d '\r\n')"
if ! validate_token "$TOKEN"; then
rm -f "$TOKEN_FILE"
gate_block 'staged token is malformed; it was discarded'
fi
}
drop_staged_token() {
[[ "$MODE" == 'systemd' ]] || return 0
rm -f "$TOKEN_FILE"
}
stage_token() {
[[ -n "$TOKEN" ]] || die 'a non-empty enrollment token is required on stdin'
install -d -m 700 "$STATE_DIR"
local temp
temp="$(mktemp "$STATE_DIR/.enrollment.token.XXXXXX")"
chmod 600 "$temp"
printf '%s\n' "$TOKEN" > "$temp"
unset TOKEN
mv -f "$temp" "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
note "one-time token staged for the boot timer (mode 0600, TTL ${TOKEN_MAX_AGE}s): $TOKEN_FILE"
}
if [[ "$STAGE" -eq 1 ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
note "check: would stage a one-time token for the boot timer at $TOKEN_FILE (stdin is not consumed in --check mode)"
exit 0
fi
IFS= read -r TOKEN || true
validate_token "$TOKEN" || die 'a printable enrollment token of 8-4096 characters without whitespace is required on stdin'
stage_token
note 'staged; the boot attempt enrolls as soon as central is reachable'
exit 0
fi
if [[ "$MODE" == 'stdin' ]]; then
validate_endpoint
if [[ "$CHECK_MODE" -eq 0 ]]; then
IFS= read -r TOKEN || true
validate_token "$TOKEN" || die 'a printable enrollment token of 8-4096 characters without whitespace is required on stdin'
fi
else
if [[ -z "$ENDPOINT" ]]; then
# A device without central configuration is not a failure: it stays
# PENDING and must not spin in a retry loop.
printf 'bf-enroll: PENDING: BF_ENROLLMENT_ENDPOINT is not configured; the boot attempt has nothing to do\n' >&2
exit 0
fi
validate_endpoint
if [[ "$CHECK_MODE" -eq 1 ]]; then
if problem="$(staged_token_problem)"; then
note "check: $problem; the boot attempt would stay PENDING and exit 0"
exit 0
fi
else
read_staged_token
fi
fi
if [[ "$CHECK_MODE" -eq 1 ]]; then
note "check: would enroll $DEVICE_ID ($PHASE -> ENROLLED) against $ENDPOINT; no request sent, nothing written"
note 'check: the WireGuard private key stays local; only the public key and non-secret facts are sent'
exit 0
fi
# --- request -----------------------------------------------------------------
REQUEST_FILE="$(mktemp)"; RESPONSE_FILE="$(mktemp)"; CV_FILE="$(mktemp)"
chmod 600 "$REQUEST_FILE" "$RESPONSE_FILE" "$CV_FILE"
python3 - "$REQUEST_FILE" "$DEVICE_ID" "$WG_PUBLIC_KEY" "$IDEMPOTENCY" <<'PY'
import json
import platform
import sys
def read_if_readable(path):
try:
with open(path, encoding='utf-8') as handle:
return handle.read().strip()
except OSError:
return ''
# Only the public key and non-secret device facts are sent. The WireGuard
# private key, the token and any credential are deliberately absent.
request = {
'device_id': sys.argv[2],
'wireguard_public_key': sys.argv[3],
'idempotency_key': sys.argv[4],
'hardware': {
'machine_id': read_if_readable('/etc/machine-id'),
'product_uuid': read_if_readable('/sys/class/dmi/id/product_uuid'),
'sys_vendor': read_if_readable('/sys/class/dmi/id/sys_vendor'),
},
'release': {
'os_release': read_if_readable('/etc/os-release'),
'kernel': platform.release(),
},
}
with open(sys.argv[1], 'w', encoding='utf-8') as handle:
json.dump(request, handle, separators=(',', ':'))
PY
# The Authorization header arrives through an anonymous process-substitution fd:
# the one-time token is never on argv, on disk, in the state file or in a log.
# --proto/--max-redirs pin the transport: HTTPS only, no redirect can move the
# token to another origin.
if HTTP_CODE="$(curl --proto '=https' --tlsv1.2 --max-redirs 0 \
--request POST --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \
--silent --show-error --fail --write-out '%{http_code}' \
-H 'Content-Type: application/json' \
-H @<(printf 'Authorization: Bearer %s' "$TOKEN") \
--data-binary "@$REQUEST_FILE" --output "$RESPONSE_FILE" "$ENDPOINT")"; then
CURL_RC=0
else
CURL_RC=$?
fi
unset TOKEN
if [[ "$CURL_RC" -ne 0 ]]; then
case "$HTTP_CODE" in
4??)
drop_staged_token
die "central rejected the enrollment request (HTTP $HTTP_CODE, curl exit $CURL_RC); that one-time token is consumed, obtain a fresh token"
;;
*)
die "enrollment request did not complete (curl exit $CURL_RC, HTTP ${HTTP_CODE:-000}); lifecycle stays at its prior state and any staged token is kept for the bounded retry"
;;
esac
fi
# --- response validation and local configuration -----------------------------
STAGED_CONFIG="$(mktemp "$WG_DIR/.wg0.conf.XXXXXX")"
chmod 600 "$STAGED_CONFIG"
python3 - "$RESPONSE_FILE" "$STAGED_CONFIG" "$CV_FILE" "$DEVICE_ID" "$WG_PUBLIC_KEY" "$IDEMPOTENCY" "$WG_DIR" <<'PY'
import json
import os
import re
import sys
response_path, config_path, cv_path, device, public_key, idempotency, wg_dir = sys.argv[1:8]
def bad(message):
sys.stderr.write('Enrollment response invalid: ' + message + '\n')
raise SystemExit(1)
try:
with open(response_path, encoding='utf-8') as handle:
response = json.load(handle)
except ValueError:
bad('not valid JSON')
allowed = {'status', 'device_id', 'idempotency_key', 'enrollment_id', 'wireguard', 'central_verification'}
required = {'status', 'device_id', 'idempotency_key', 'wireguard', 'central_verification'}
if not isinstance(response, dict) or set(response) - allowed:
bad('unexpected top-level field (no private key or extra secret is accepted)')
if not required <= set(response):
bad('required field missing')
if response['status'] != 'accepted' or response['device_id'] != device or response['idempotency_key'] != idempotency:
bad('status or identity mismatch')
if 'enrollment_id' in response and not isinstance(response['enrollment_id'], str):
bad('enrollment_id type')
wireguard = response['wireguard']
if not isinstance(wireguard, dict) or set(wireguard) != {'address', 'endpoint', 'server_public_key', 'allowed_ips'}:
bad('WireGuard fields')
server_public_key = wireguard['server_public_key']
endpoint = wireguard['endpoint']
address = wireguard['address']
allowed_ips = wireguard['allowed_ips']
if not all(isinstance(value, str) for value in (server_public_key, endpoint, address, allowed_ips)):
bad('WireGuard types')
if not re.fullmatch(r'[A-Za-z0-9+/]{43}=', server_public_key):
bad('server public key')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]*:[1-9][0-9]{0,4}', endpoint) \
or any(marker in endpoint.lower() for marker in ('example', 'invalid', '<', '>')):
bad('endpoint')
if int(endpoint.rsplit(':', 1)[1]) > 65535:
bad('endpoint port')
if not re.fullmatch(r'(?:\d{1,3}\.){3}\d{1,3}/(?:[0-9]|[12][0-9]|3[0-2])', address):
bad('address')
if not allowed_ips or '\n' in allowed_ips or '\r' in allowed_ips:
bad('allowed_ips')
central = response['central_verification']
if not isinstance(central, dict) or set(central) != {'status', 'verification_id', 'monitoring', 'remote', 'management'}:
bad('central verification fields')
if central['status'] not in ('pending', 'verified') or not isinstance(central['verification_id'], str) or not central['verification_id']:
bad('central verification status')
for name in ('monitoring', 'remote', 'management'):
item = central[name]
if not isinstance(item, dict) or set(item) != {'status', 'verification_id'} \
or item['status'] not in ('pending', 'verified') \
or not isinstance(item['verification_id'], str) or not item['verification_id']:
bad('central ' + name + ' evidence')
# The device keeps its own private key: it is never accepted from central, never
# sent, and never stored outside /etc/wireguard.
try:
with open(os.path.join(wg_dir, 'privatekey'), encoding='utf-8') as handle:
private_key = handle.read().strip()
except OSError:
private_key = ''
if not private_key:
bad('local WireGuard private key is unreadable')
config = (
'[Interface]\n'
'PrivateKey = ' + private_key + '\n'
'Address = ' + address + '\n'
'\n'
'[Peer]\n'
'PublicKey = ' + server_public_key + '\n'
'Endpoint = ' + endpoint + '\n'
'AllowedIPs = ' + allowed_ips + '\n'
'PersistentKeepalive = 25\n'
)
descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
handle.write(config)
with open(cv_path, 'w', encoding='utf-8') as handle:
json.dump({'central_verification': central}, handle, separators=(',', ':'))
PY
chmod 600 "$STAGED_CONFIG"
# --- activation with rollback ------------------------------------------------
rollback_activation() {
if [[ -n "$BACKUP_CONFIG" && -e "$BACKUP_CONFIG" ]]; then
mv -f "$BACKUP_CONFIG" "$WG_DIR/wg0.conf"
BACKUP_CONFIG=''
chmod 600 "$WG_DIR/wg0.conf" 2>/dev/null || true
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
else
rm -f "$WG_DIR/wg0.conf"
systemctl disable --now wg-quick@wg0 >/dev/null 2>&1 || true
fi
}
if [[ -e "$WG_DIR/wg0.conf" ]]; then
BACKUP_CONFIG="$(mktemp "$WG_DIR/.wg0.conf.backup.XXXXXX")"
cp -p "$WG_DIR/wg0.conf" "$BACKUP_CONFIG"
fi
mv "$STAGED_CONFIG" "$WG_DIR/wg0.conf"
STAGED_CONFIG=''
chmod 600 "$WG_DIR/wg0.conf"
if ! systemctl enable wg-quick@wg0 || ! systemctl restart wg-quick@wg0 \
|| ! systemctl is-enabled --quiet wg-quick@wg0 || ! systemctl is-active --quiet wg-quick@wg0 \
|| ! ip link show wg0 >/dev/null 2>&1; then
rollback_activation
die 'WireGuard configuration could not be activated; lifecycle remains at its prior state (configuration rolled back)'
fi
# --- atomic state commit -----------------------------------------------------
python3 - "$STATE_FILE" "$DEVICE_ID" "$IDEMPOTENCY" "$CV_FILE" "$RESPONSE_FILE" "$REENROLL" <<'PY'
import datetime
import fcntl
import json
import os
import sys
import tempfile
state_path, device, idempotency, cv_path, response_path, reenroll = sys.argv[1:7]
directory = os.path.dirname(state_path) or '.'
with open(cv_path, encoding='utf-8') as handle:
central = json.load(handle)['central_verification']
with open(response_path, encoding='utf-8') as handle:
enrollment_id = json.load(handle).get('enrollment_id') or 'accepted'
# Serialize lifecycle writers (boot timer vs. technician) and re-check the
# transition under the lock so a stale decision can never be committed.
lock = os.open(os.path.join(directory, '.state.lock'), os.O_CREAT | os.O_RDWR | os.O_CLOEXEC, 0o600)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise SystemExit('bf-enroll: another lifecycle update is in progress; nothing was committed')
with open(state_path, encoding='utf-8') as handle:
state = json.load(handle)
current = state.get('phase')
if current == 'READY':
raise SystemExit('bf-enroll: refusing to move a READY device backwards; nothing was committed')
if current not in ('PROVISIONED_OFFLINE', 'ENROLLED'):
raise SystemExit('bf-enroll: device is not PROVISIONED_OFFLINE; nothing was committed')
if current == 'ENROLLED' and reenroll != '1':
raise SystemExit('bf-enroll: device is already ENROLLED; refusing a replay')
if state.get('device_id') not in (None, device):
raise SystemExit('bf-enroll: lifecycle state device_id mismatch; nothing was committed')
# Forward-only transition, exactly one stage: PROVISIONED_OFFLINE -> ENROLLED.
state.update({
'phase': 'ENROLLED',
'enrollment_status': 'ENROLLED',
'fleet_status': 'PENDING',
'device_id': device,
'enrollment_id': enrollment_id,
'idempotency_key': idempotency,
'central_verification': central,
'enrolled_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
})
descriptor, temporary = tempfile.mkstemp(prefix='.state.json.', dir=directory)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, 'w', encoding='utf-8') as handle:
json.dump(state, handle, sort_keys=True)
handle.write('\n')
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, state_path)
except BaseException:
if os.path.exists(temporary):
os.unlink(temporary)
raise
os.chmod(state_path, 0o600)
PY
drop_staged_token
rm -f "$BACKUP_CONFIG"
BACKUP_CONFIG=''
note 'Enrollment accepted and WireGuard is active. Device is ENROLLED; READY requires fresh local checks and central verification evidence.'