The bundle pool is ~884 MB and now travels through Git LFS, so a plain clone materialises all of it. Measured on this repository: 2.7 GB working tree, against 20 MB with smudge disabled -- the same 135x that decides whether one device or seven hundred can be provisioned from a git remote. bf-bootstrap exports GIT_LFS_SKIP_SMUDGE=1 for every git operation it performs (clone and fetch), so provisioning a device no longer implies downloading the package payload. The trade is explicit and documented rather than hidden: with smudge off the pool files on disk are 131-byte pointer texts, and the installer must not mistake one for a package. So the installer gained a guard. A pointer text satisfies every existence check, so without it the run died later inside `sha256sum -c` and reported a checksum mismatch -- a misdiagnosis that sends an operator looking for corruption when the payload was simply never fetched. It now recognises the pointer by content and names the remedy instead. Getting the payload is a deliberate second step, and both documented routes are real: a local mirror (the offline-correct path, and the one the fleet uses) or `git lfs pull --include='provisioning/offline-repo/built/pool/*'`, which fetches only the delivery pool and leaves the intake copy under packages/ unfetched -- verified: 1.8 GB and a real .deb in built/pool, 131-byte stub still in packages/. Tests: test-field-os-lifecycle.sh gains a scenario that lays down a pointer text where a .deb belongs and asserts three things -- the run refuses, the message names the cause, and it does not blame a checksum. Also asserts apt-get is never invoked. 16 scenarios, all passing. docs/27 is corrected where it had gone stale: it claimed a clone carries the packages and the installer downloads nothing, which stopped being true the moment the payload moved to LFS.
424 lines
30 KiB
Bash
Executable file
424 lines
30 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Exercise the Field OS lifecycle, enrollment and readiness contracts with mocked local services.
|
|
#
|
|
# This host has no systemd, no apt and no internet, so every external dependency
|
|
# (systemctl, wg, ip, dpkg-query, curl) is a mock in $TMP/bin. Scenarios:
|
|
#
|
|
# 1 secret-safe enrollment request/header/response path
|
|
# 2 offline installer fails closed without a local repository
|
|
# 3 failed WireGuard activation rolls back and keeps the prior phase
|
|
# 4 ROLLBACK/READY: independent proof, fail-closed without central evidence
|
|
# 5 skeleton release manifest is never a production release
|
|
# 6 state machine: forward-only, no stage skipping, contract validation
|
|
# 7 bf-check-local passes without internet and fails closed on key hygiene
|
|
# 8 bf-check-enrollment requires central evidence, peer config and handshake
|
|
# 9 replay/backwards protection and atomic 0600 state writes
|
|
# 10 systemd attempt: PENDING without a token, bounded retry with one staged
|
|
# 11 --check dry run never mutates state or sends a request
|
|
# 12 enrollment schemas match the real client behaviour
|
|
# 13 boot unit cannot loop, carries no credential and stays bounded
|
|
set -euo pipefail
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
BIN="$REPO_ROOT/scripts/diagnostics"
|
|
TMP="$(mktemp -d)"
|
|
trap 'rm -rf "$TMP"' EXIT
|
|
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
pass() { printf 'OK: %s\n' "$*"; }
|
|
|
|
PUBLIC_KEY='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
|
SERVER_KEY='BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
|
|
DEALER='12345678'
|
|
ENDPOINT='https://control.blueforce.test/enroll'
|
|
|
|
make_mock_bin() {
|
|
local d="$1"
|
|
mkdir -p "$d"
|
|
cat > "$d/systemctl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
printf '%s\n' "$*" >> "$MOCK_CALLS"
|
|
case "${1:-}" in
|
|
is-active) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;;
|
|
is-enabled) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;;
|
|
enable|restart) [[ "${MOCK_SYSTEMCTL_FAIL:-0}" != 1 ]] ;;
|
|
get-default) printf 'multi-user.target\n' ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
EOF
|
|
cat > "$d/curl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# Mock curl: records argv, captures the request file and the header fd, then
|
|
# either writes the canned response or fails like a transport/protocol error.
|
|
set -euo pipefail
|
|
request='' output='' header='' code=''
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--data-binary) request="$2"; shift 2 ;;
|
|
--output) output="$2"; shift 2 ;;
|
|
--write-out) code="$2"; shift 2 ;;
|
|
-H|--header) header="$2"; shift 2 ;;
|
|
*) printf 'argv=%s\n' "$1" >> "$MOCK_CURL_LOG"; shift ;;
|
|
esac
|
|
done
|
|
[[ "$request" == @* ]] || exit 91
|
|
cat "${request#@}" > "$MOCK_REQUEST_CAPTURE"
|
|
[[ "$header" == @* ]] || exit 92
|
|
cat "${header#@}" > "$MOCK_HEADER_CAPTURE"
|
|
if [[ "${MOCK_CURL_FAIL:-0}" == 1 ]]; then
|
|
printf '%s' "${MOCK_HTTP_CODE:-000}"
|
|
exit "${MOCK_CURL_RC:-7}"
|
|
fi
|
|
printf '%s' "$MOCK_RESPONSE" > "$output"
|
|
[[ -z "$code" ]] || printf '%s' "${MOCK_HTTP_CODE:-200}"
|
|
EOF
|
|
cat > "$d/wg" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
case "${1:-}" in
|
|
show) printf 'peer %s\n' "${MOCK_HANDSHAKE:-0}" ;;
|
|
pubkey) IFS= read -r _key || true; printf '%s\n' "${MOCK_PUBLIC_KEY:-}" ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
EOF
|
|
cat > "$d/ip" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
[[ "${MOCK_IP_FAIL:-0}" == 1 ]] && exit 1
|
|
exit 0
|
|
EOF
|
|
cat > "$d/dpkg-query" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'install ok installed'
|
|
EOF
|
|
chmod +x "$d"/*
|
|
}
|
|
|
|
MOCK_BIN="$TMP/bin"; make_mock_bin "$MOCK_BIN"
|
|
export PATH="$MOCK_BIN:$PATH" MOCK_CALLS="$TMP/calls" MOCK_CURL_LOG="$TMP/curl-argv"
|
|
export MOCK_REQUEST_CAPTURE="$TMP/request" MOCK_HEADER_CAPTURE="$TMP/header"
|
|
export MOCK_PUBLIC_KEY="$PUBLIC_KEY"
|
|
STATE_DIR="$TMP/state"; WG_DIR="$TMP/wg"; mkdir -p "$STATE_DIR" "$WG_DIR"
|
|
printf '%s\n' "$DEALER" > "$STATE_DIR/dealer-id"
|
|
printf '%s\n' 'private-test-key' > "$WG_DIR/privatekey"
|
|
printf '%s\n' "$PUBLIC_KEY" > "$WG_DIR/publickey"
|
|
chmod 600 "$WG_DIR/privatekey" "$WG_DIR/publickey" "$STATE_DIR/dealer-id"
|
|
export STATE_DIR WG_DIR BF_STATE_FILE="$STATE_DIR/state.json"
|
|
IDEM="$(python3 -c "import hashlib,sys;print(hashlib.sha256(('p-1|BF-'+sys.argv[1]+'|'+sys.argv[2]).encode()).hexdigest())" "$DEALER" "$PUBLIC_KEY")"
|
|
|
|
write_state() { printf '%s\n' "$1" > "$BF_STATE_FILE"; chmod 600 "$BF_STATE_FILE"; }
|
|
state_field() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$BF_STATE_FILE" "$1"; }
|
|
central_pending='{"status":"pending","verification_id":"cv-1","monitoring":{"status":"pending","verification_id":"mon-1"},"remote":{"status":"pending","verification_id":"rem-1"},"management":{"status":"pending","verification_id":"mgmt-1"}}'
|
|
central_verified='{"status":"verified","verification_id":"cv-1","monitoring":{"status":"verified","verification_id":"mon-1"},"remote":{"status":"verified","verification_id":"rem-1"},"management":{"status":"verified","verification_id":"mgmt-1"}}'
|
|
pending_state="{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}"
|
|
enrolled_state() { printf '{"phase":"ENROLLED","enrollment_status":"ENROLLED","fleet_status":"PENDING","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; }
|
|
ready_state() { printf '{"phase":"READY","enrollment_status":"ENROLLED","fleet_status":"READY","device_id":"BF-%s","provisioning_id":"p-1","central_verification":%s}' "$DEALER" "$1"; }
|
|
mocked_response() { printf '{"status":"accepted","device_id":"BF-%s","idempotency_key":"%s","enrollment_id":"enr-1","wireguard":{"address":"10.20.0.17/32","endpoint":"wg.blueforce.test:51820","server_public_key":"%s","allowed_ips":"10.20.0.0/24"},"central_verification":%s}' "$DEALER" "$IDEM" "$SERVER_KEY" "$1"; }
|
|
|
|
# --- scenario 1: the secret-safe enrollment path ----------------------------
|
|
write_state "$pending_state"
|
|
export MOCK_RESPONSE="$(mocked_response "$central_verified")"
|
|
printf '%s\n' 'super-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null
|
|
[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'successful enrollment must commit ENROLLED'
|
|
[[ -s "$WG_DIR/wg0.conf" ]] || fail 'successful enrollment must install wg0.conf'
|
|
[[ "$(stat -c '%a' "$WG_DIR/wg0.conf")" == '600' ]] || fail 'wg0.conf must be mode 600'
|
|
[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state.json must be mode 600'
|
|
grep -q "BF-$DEALER" "$MOCK_REQUEST_CAPTURE" || fail 'curl must consume the generated request file'
|
|
grep -Fqx 'Authorization: Bearer super-secret-token' "$MOCK_HEADER_CAPTURE" || fail 'curl must receive the exact supplied token through process substitution'
|
|
! grep -R -F 'super-secret-token' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'token persisted outside the transient curl fixture'
|
|
! grep -R -F 'super-secret-token' "$MOCK_CURL_LOG" "$STATE_DIR" 2>/dev/null || fail 'token leaked to curl argv, output, or state'
|
|
pass 'curl request/header/response path is secret-safe and functional'
|
|
|
|
# --- scenario 12 (early check): request body carries public facts only -------
|
|
python3 - "$MOCK_REQUEST_CAPTURE" "$PUBLIC_KEY" <<'PY'
|
|
import json, sys
|
|
request = json.load(open(sys.argv[1], encoding='utf-8'))
|
|
if set(request) != {'device_id', 'wireguard_public_key', 'idempotency_key', 'hardware', 'release'}:
|
|
raise SystemExit('unexpected request field set: %s' % sorted(request))
|
|
if request['wireguard_public_key'] != sys.argv[2]:
|
|
raise SystemExit('request must carry the local public key')
|
|
blob = json.dumps(request).lower()
|
|
for banned in ('private', 'secret', 'password', 'passwd', 'bearer', 'authorization'):
|
|
if banned in blob:
|
|
raise SystemExit('request carries a secret-like field: ' + banned)
|
|
PY
|
|
! grep -q 'private-test-key' "$MOCK_REQUEST_CAPTURE" || fail 'the WireGuard private key must never be sent'
|
|
! grep -q 'super-secret-token' "$MOCK_REQUEST_CAPTURE" || fail 'the one-time token must never be sent in the request body'
|
|
pass 'request carries public facts only: no private key, no token, no credential'
|
|
|
|
# --- scenario 2: offline installer fails closed -----------------------------
|
|
cat > "$MOCK_BIN/apt-get" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >> "$MOCK_APT_CALLS"
|
|
exit 90
|
|
EOF
|
|
chmod +x "$MOCK_BIN/apt-get"
|
|
export MOCK_APT_CALLS="$TMP/apt-calls"
|
|
if STATE_DIR="$TMP/offline-state" LOG_FILE="$TMP/offline.log" BF_OFFLINE_REPO="$TMP/missing-offline-repo" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/offline.out" 2>&1; then fail 'offline installer must reject a missing local repository before modules'; fi
|
|
[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'offline repository rejection must not invoke apt-get'
|
|
! grep -Eqi '(curl|wget|https?://|apt[[:space:]]+download)' "$TMP/offline.out" "$TMP/offline.log" 2>/dev/null || fail 'offline rejection must not run or report network package acquisition'
|
|
pass 'offline installer fails closed before package modules when local repository is absent'
|
|
|
|
# --- scenario 2b: a Git-LFS pointer is not a package -------------------------
|
|
# The bundle pool travels through Git LFS. A checkout made with smudge disabled
|
|
# (which bf-bootstrap does deliberately, to avoid an 884 MB download) leaves
|
|
# small pointer texts where the .deb files belong. They satisfy every existence
|
|
# test, so the installer must recognise them by content and say what to run --
|
|
# otherwise the run dies later inside `sha256sum -c` and reads as a corrupt
|
|
# bundle rather than unfetched payload.
|
|
STUB_REPO="$TMP/stub-offline-repo"
|
|
mkdir -p "$STUB_REPO/pool"
|
|
printf 'stub index\n' > "$STUB_REPO/Packages"
|
|
printf 'stub index\n' > "$STUB_REPO/Packages.gz"
|
|
printf 'FAKE-SHA256SUMS\n' > "$STUB_REPO/SHA256SUMS"
|
|
printf 'package\tversion\tarchitecture\tsha256\tsource\n' > "$STUB_REPO/packages.lock.tsv"
|
|
printf 'curl\t8.5.0-2ubuntu10\tamd64\t%s\tcurl_8.5.0-2ubuntu10_amd64.deb\n' \
|
|
'0000000000000000000000000000000000000000000000000000000000000000' >> "$STUB_REPO/packages.lock.tsv"
|
|
cat > "$STUB_REPO/pool/curl_8.5.0-2ubuntu10_amd64.deb" <<'POINTER'
|
|
version https://git-lfs.github.com/spec/v1
|
|
oid sha256:0000000000000000000000000000000000000000000000000000000000000000
|
|
size 285342
|
|
POINTER
|
|
rm -f "$MOCK_APT_CALLS"
|
|
if STATE_DIR="$TMP/stub-state" LOG_FILE="$TMP/stub.log" BF_OFFLINE_REPO="$STUB_REPO" "$REPO_ROOT/scripts/install/blueforce-install.sh" --offline --dealer-id "$DEALER" --only 02-system --yes > "$TMP/stub.out" 2>&1; then fail 'offline installer must reject a bundle whose pool holds LFS pointers, not packages'; fi
|
|
grep -qi 'indirilmemiş\|işaretçi' "$TMP/stub.out" || fail 'the LFS-pointer refusal must name the cause (pointer text, not a .deb)'
|
|
grep -q 'git lfs pull' "$TMP/stub.out" || fail 'the LFS-pointer refusal must tell the operator the command that fixes it'
|
|
grep -qi 'checksum' "$TMP/stub.out" && fail 'an LFS pointer must not be reported as a checksum failure; that misdiagnoses unfetched payload as corruption'
|
|
[[ ! -e "$MOCK_APT_CALLS" ]] || fail 'an LFS-pointer rejection must not invoke apt-get'
|
|
pass 'offline installer recognises a Git-LFS pointer and names the remedy'
|
|
|
|
# --- scenario 3: activation failure rolls back, phase is preserved ----------
|
|
write_state "$pending_state"
|
|
rm -f "$WG_DIR/wg0.conf"
|
|
export MOCK_SYSTEMCTL_FAIL=1
|
|
if printf '%s\n' 'another-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin; then fail 'activation failure must fail enrollment'; fi
|
|
[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'activation failure must not commit ENROLLED'
|
|
[[ "$(state_field enrollment_status)" == 'PENDING' ]] || fail 'activation failure must leave enrollment PENDING'
|
|
[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'activation failure must roll back a new config'
|
|
[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic state writes must leave no temporary files'
|
|
unset MOCK_SYSTEMCTL_FAIL
|
|
pass 'state transition rolls back on WireGuard activation failure and stays PENDING'
|
|
|
|
# --- scenario 4: READY proof is independent and fails closed ----------------
|
|
printf '%s\n' 'third-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null
|
|
[[ -s "$WG_DIR/wg0.conf" ]] || fail 're-enrollment after a rolled-back attempt must install wg0.conf'
|
|
RUST_SYSTEM="$TMP/RustDesk-system.toml"; RUST_USER="$TMP/RustDesk-user.toml"
|
|
printf "rendezvous-server = 'rustdesk.blueforce.test'\nrelay-server = 'rustdesk.blueforce.test'\n" > "$RUST_SYSTEM"
|
|
cp "$RUST_SYSTEM" "$RUST_USER"
|
|
export MOCK_HANDSHAKE="$(date +%s)" BF_RUSTDESK_SYSTEM_CONFIG="$RUST_SYSTEM" BF_RUSTDESK_USER_CONFIG="$RUST_USER" BF_MESH_CONFIG="$TMP/no-mesh"
|
|
python3 - "$BF_STATE_FILE" "$central_verified" <<'PY'
|
|
import json, sys
|
|
path, verified = sys.argv[1], json.loads(sys.argv[2])
|
|
state = json.load(open(path, encoding='utf-8'))
|
|
state['central_verification'] = verified
|
|
json.dump(state, open(path, 'w', encoding='utf-8'))
|
|
PY
|
|
"$BIN/bf-check-ready" >/dev/null || fail 'ENROLLED state with independent local and central evidence must become ready-check eligible'
|
|
python3 - "$BF_STATE_FILE" <<'PY'
|
|
import json, sys
|
|
path = sys.argv[1]
|
|
state = json.load(open(path, encoding='utf-8'))
|
|
del state['central_verification']
|
|
json.dump(state, open(path, 'w', encoding='utf-8'))
|
|
PY
|
|
if "$BIN/bf-check-ready" > "$TMP/ready-failclosed.out" 2>&1; then fail 'missing central verification evidence must fail closed'; fi
|
|
if "$BIN/bf-check-enrollment" > "$TMP/enroll-failclosed.out" 2>&1; then fail 'the ENROLLED gate must fail closed without central evidence'; fi
|
|
grep -q 'central_verification is missing' "$TMP/enroll-failclosed.out" || fail 'the ENROLLED gate must name the missing central evidence'
|
|
pass 'READY proof is independent of persisted READY and fails closed without central evidence'
|
|
|
|
# --- scenario 5: skeleton release manifest ----------------------------------
|
|
RELEASE="$TMP/release.yaml"
|
|
printf 'release:\n name: blueforce-provisioning-skeleton\n version: 0.1.0\n status: skeleton\n commit: deadbeef\n' > "$RELEASE"
|
|
if BF_RELEASE_MANIFEST="$RELEASE" "$BIN/bf-release" > "$TMP/release.out"; then fail 'skeleton release must be nonzero'; fi
|
|
grep -q 'UNAPPROVED_SKELETON' "$TMP/release.out" || fail 'skeleton release must be labelled unapproved'
|
|
pass 'nested skeleton manifest is not represented as a production release'
|
|
|
|
# --- scenario 6: state machine contract -------------------------------------
|
|
write_state "$pending_state"
|
|
[[ "$("$BIN/bf-enrollment-status" --field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'canonical reader must report the provisioned phase'
|
|
write_state "$(enrolled_state "$central_pending")"
|
|
"$BIN/bf-enrollment-status" | grep -qx 'Enrollment: ENROLLED' || fail 'ENROLLED stage must be visible before READY'
|
|
write_state "$(ready_state "$central_verified")"
|
|
"$BIN/bf-enrollment-status" > "$TMP/ready-status.out" || fail 'READY with verified central evidence must be contract-valid'
|
|
grep -qx 'Lifecycle: READY' "$TMP/ready-status.out" || fail 'READY stage must be reported'
|
|
grep -qx 'Enrollment: COMPLETE' "$TMP/ready-status.out" || fail 'READY implies a complete enrollment'
|
|
grep -qx 'Fleet: READY' "$TMP/ready-status.out" || fail 'READY implies fleet READY'
|
|
write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}"
|
|
if "$BIN/bf-enrollment-status" > "$TMP/skip.out" 2>/dev/null; then fail 'a skipped stage (PROVISIONED_OFFLINE -> READY) must be INVALID'; fi
|
|
grep -qx 'Lifecycle: INVALID' "$TMP/skip.out" || fail 'skipped stage must be reported as INVALID'
|
|
write_state "$(ready_state "$central_pending")"
|
|
if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'READY without verified channels must be INVALID'; fi
|
|
write_state "{\"phase\":\"PROVISIONED_OFFLINE\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"PENDING\",\"device_id\":\"BF-$DEALER\"}"
|
|
if "$BIN/bf-enrollment-status" >/dev/null 2>&1; then fail 'a state without provisioning_id must be INVALID'; fi
|
|
if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-enrollment-status" > "$TMP/absent.out" 2>/dev/null; then fail 'a missing state file must exit non-zero'; fi
|
|
grep -qx 'Lifecycle: PENDING' "$TMP/absent.out" || fail 'a missing state file must report PENDING'
|
|
pass 'state machine is forward-only, never skips a stage and validates its contract'
|
|
|
|
# --- scenario 6b: bf-status lifecycle block ---------------------------------
|
|
write_state "$(enrolled_state "$central_pending")"
|
|
BF_STATE_FILE="$BF_STATE_FILE" "$BIN/bf-status" > "$TMP/status.out" 2>/dev/null || fail 'bf-status must always exit 0'
|
|
grep -qx 'Provisioning : COMPLETE' "$TMP/status.out" || fail 'bf-status must show Provisioning : COMPLETE once central accepted the device'
|
|
grep -qx 'Enrollment : ENROLLED' "$TMP/status.out" || fail 'bf-status must show Enrollment : ENROLLED'
|
|
grep -qx 'Fleet State : NOT READY' "$TMP/status.out" || fail 'bf-status must show Fleet State : NOT READY before READY'
|
|
write_state "$pending_state"
|
|
"$BIN/bf-status" > "$TMP/status-pending.out" 2>/dev/null
|
|
grep -qx 'Provisioning : PROVISIONED_OFFLINE' "$TMP/status-pending.out" || fail 'bf-status must show the offline provisioning stage'
|
|
grep -qx 'Enrollment : PENDING' "$TMP/status-pending.out" || fail 'bf-status must show a pending enrollment'
|
|
write_state "$(ready_state "$central_verified")"
|
|
"$BIN/bf-status" > "$TMP/status-ready.out" 2>/dev/null
|
|
grep -qx 'Fleet State : READY' "$TMP/status-ready.out" || fail 'bf-status must show Fleet State : READY'
|
|
write_state "{\"phase\":\"READY\",\"enrollment_status\":\"PENDING\",\"fleet_status\":\"READY\",\"device_id\":\"BF-$DEALER\",\"provisioning_id\":\"p-1\"}"
|
|
"$BIN/bf-status" > "$TMP/status-invalid.out" 2>/dev/null
|
|
grep -q 'lifecycle state invalid' "$TMP/status-invalid.out" || fail 'bf-status must raise a critical alert for an invalid lifecycle state'
|
|
pass 'bf-status prints the Provisioning/Enrollment/Fleet State lifecycle block'
|
|
|
|
# --- scenario 7: local gate is offline-safe ---------------------------------
|
|
write_state "$pending_state"
|
|
rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token"
|
|
"$BIN/bf-check-local" > "$TMP/local.out" 2>&1 || fail 'bf-check-local must pass offline with no handshake and no wg0.conf'
|
|
grep -q 'local gates PASS' "$TMP/local.out" || fail 'bf-check-local must report a passing local gate'
|
|
! grep -q '^FAIL' "$TMP/local.out" || fail 'bf-check-local must not fail any local gate offline'
|
|
"$BIN/bf-check-local" --check >/dev/null 2>&1 || fail 'bf-check-local --check must behave like the default run'
|
|
[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'bf-check-local must not create enrollment artifacts'
|
|
! grep -Eq '(^|/)(curl|wget|apt|apt-get)( |$)' "$TMP/calls" 2>/dev/null || fail 'bf-check-local must not call network tools'
|
|
chmod 644 "$WG_DIR/privatekey"
|
|
if "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a world-readable WireGuard private key must fail the local gate'; fi
|
|
chmod 600 "$WG_DIR/privatekey"
|
|
MOCK_PUBLIC_KEY='CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=' "$BIN/bf-check-local" >/dev/null 2>&1 && fail 'a mismatched WireGuard keypair must fail the local gate'
|
|
if BF_STATE_FILE="$TMP/absent.json" "$BIN/bf-check-local" >/dev/null 2>&1; then fail 'a missing state file must fail the local gate'; fi
|
|
pass 'bf-check-local passes offline and fails closed on key hygiene'
|
|
|
|
# --- scenario 8: ENROLLED gate needs evidence and a live tunnel -------------
|
|
write_state "$pending_state"
|
|
rm -f "$WG_DIR/wg0.conf"
|
|
printf '%s\n' 'enrolled-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null
|
|
export MOCK_HANDSHAKE="$(date +%s)"
|
|
[[ -s "$WG_DIR/wg0.conf" ]] || fail 'a successful enrollment must install the assigned peer configuration'
|
|
[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'a successful enrollment must commit ENROLLED'
|
|
"$BIN/bf-check-enrollment" >/dev/null || fail 'ENROLLED gates must pass with peer config, live tunnel and a fresh handshake'
|
|
MOCK_HANDSHAKE=0 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing WireGuard handshake must fail the ENROLLED gate'
|
|
export MOCK_HANDSHAKE="$(date +%s)"
|
|
MOCK_IP_FAIL=1 "$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the ENROLLED gate'
|
|
MOCK_IP_FAIL=1 "$BIN/bf-check-ready" >/dev/null 2>&1 && fail 'a missing wg0 interface must fail the READY gate'
|
|
mv "$WG_DIR/wg0.conf" "$TMP/wg0.conf.keep"
|
|
"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'a missing peer configuration must fail the ENROLLED gate'
|
|
mv "$TMP/wg0.conf.keep" "$WG_DIR/wg0.conf"
|
|
write_state "$pending_state"
|
|
"$BIN/bf-check-enrollment" >/dev/null 2>&1 && fail 'PROVISIONED_OFFLINE must never pass the ENROLLED gate'
|
|
write_state "$(enrolled_state "$central_pending")"
|
|
"$BIN/bf-check-enrollment" >/dev/null || fail 'a device with pending central channels is still ENROLLED'
|
|
if "$BIN/bf-check-ready" >/dev/null 2>&1; then fail 'pending central channels must never produce READY'; fi
|
|
pass 'bf-check-enrollment requires central evidence, peer configuration and a fresh handshake'
|
|
|
|
# --- scenario 9: replay and backwards protection ----------------------------
|
|
write_state "$pending_state"
|
|
printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin >/dev/null
|
|
REPLAY_BEFORE="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")"
|
|
if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin > "$TMP/replay.out" 2>&1; then fail 'a second enrollment without --reenroll must be refused'; fi
|
|
grep -q 'already ENROLLED' "$TMP/replay.out" || fail 'the replay refusal must explain the ENROLLED state'
|
|
REPLAY_AFTER="$(python3 -c "import json,sys;print(json.dumps(json.load(open(sys.argv[1])),sort_keys=True))" "$BF_STATE_FILE")"
|
|
[[ "$REPLAY_BEFORE" == "$REPLAY_AFTER" ]] || fail 'a refused replay must not touch the state file'
|
|
printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll >/dev/null || fail '--reenroll must re-apply enrollment'
|
|
[[ "$(state_field phase)" == 'ENROLLED' ]] || fail '--reenroll must never move the phase backwards'
|
|
write_state "$(ready_state "$central_verified")"
|
|
if printf '%s\n' 'replay-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --reenroll > "$TMP/ready-refusal.out" 2>&1; then fail 'a READY device must refuse enrollment even with --reenroll'; fi
|
|
grep -q 'never move backwards' "$TMP/ready-refusal.out" || fail 'the READY refusal must explain the forward-only rule'
|
|
[[ "$(state_field phase)" == 'READY' ]] || fail 'the READY refusal must not change the phase'
|
|
[[ "$(stat -c '%a' "$BF_STATE_FILE")" == '600' ]] || fail 'state writes must stay at mode 600'
|
|
[[ -z "$(find "$STATE_DIR" -name '.state.json.*' -print -quit)" ]] || fail 'atomic writes must not leave temporary files'
|
|
pass 'replay and backwards transitions are refused, state writes are atomic and 0600'
|
|
|
|
# --- scenario 10: systemd attempt and staged token --------------------------
|
|
write_state "$pending_state"
|
|
rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token"
|
|
BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/pending.out" 2>&1 || fail 'the boot attempt must exit 0 while nothing is staged'
|
|
grep -q 'PENDING' "$TMP/pending.out" || fail 'the boot attempt must report PENDING'
|
|
[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a pending boot attempt must not install a config'
|
|
BF_STATE_FILE="$TMP/absent.json" BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/unprovisioned.out" 2>&1 || fail 'an unprovisioned device must exit 0 so the unit never loops'
|
|
grep -q 'PENDING' "$TMP/unprovisioned.out" || fail 'an unprovisioned device must report PENDING'
|
|
BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 || fail 'a boot attempt without central configuration must exit 0'
|
|
printf '%s\n' 'staged-secret-token' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null || fail '--stage must store the token'
|
|
[[ "$(stat -c '%a' "$STATE_DIR/enrollment.token")" == '600' ]] || fail 'the staged token must be mode 600'
|
|
MOCK_CURL_FAIL=1 MOCK_CURL_RC=7 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd > "$TMP/offline-attempt.out" 2>&1 && fail 'an unreachable endpoint must fail the attempt so the bounded retry can run'
|
|
[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'a transient failure must keep the staged token for the bounded retry'
|
|
[[ "$(state_field phase)" == 'PROVISIONED_OFFLINE' ]] || fail 'a failed attempt must keep the lifecycle at PROVISIONED_OFFLINE'
|
|
[[ ! -e "$WG_DIR/wg0.conf" ]] || fail 'a failed attempt must not leave a WireGuard config behind'
|
|
unset MOCK_CURL_FAIL MOCK_CURL_RC
|
|
MOCK_CURL_FAIL=1 MOCK_CURL_RC=22 MOCK_HTTP_CODE=403 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null 2>&1 && fail 'a definitive rejection must fail the attempt'
|
|
[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'a definitive rejection must discard the consumed token'
|
|
unset MOCK_CURL_FAIL MOCK_CURL_RC MOCK_HTTP_CODE
|
|
printf '%s\n' 'staged-secret-token-two' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --stage >/dev/null
|
|
touch -d '2 hours ago' "$STATE_DIR/enrollment.token"
|
|
BF_ENROLLMENT_TOKEN_MAX_AGE=1800 BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'an expired staged token must exit 0 as PENDING'
|
|
[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'an expired staged token must be discarded'
|
|
printf '%s\n' 'staged-secret-token-three' | BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll-now" --stage >/dev/null || fail 'the technician wrapper must stage a token'
|
|
[[ -e "$STATE_DIR/enrollment.token" ]] || fail 'bf-enroll-now --stage must leave the token staged'
|
|
BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --systemd >/dev/null || fail 'the boot attempt must enroll when a valid token is staged and central is reachable'
|
|
[[ "$(state_field phase)" == 'ENROLLED' ]] || fail 'the boot attempt must commit ENROLLED'
|
|
[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail 'the staged token must be deleted after use'
|
|
! grep -R -F 'staged-secret-token-three' "$TMP" --exclude=header --exclude=request --exclude=enrollment.token || fail 'the staged token must not leak into state, logs or argv'
|
|
pass 'the boot attempt stays PENDING without a token and joins automatically with one'
|
|
|
|
# --- scenario 11: --check dry run -------------------------------------------
|
|
write_state "$pending_state"
|
|
rm -f "$WG_DIR/wg0.conf" "$STATE_DIR/enrollment.token"
|
|
BEFORE="$(cat "$BF_STATE_FILE")"
|
|
BF_ENROLLMENT_ENDPOINT="$ENDPOINT" "$BIN/bf-enroll" --token-stdin --check > "$TMP/check.out" 2>&1 || fail '--check must succeed on a valid pre-enrollment device'
|
|
grep -q 'would enroll' "$TMP/check.out" || fail '--check must describe the planned transition'
|
|
[[ "$BEFORE" == "$(cat "$BF_STATE_FILE")" ]] || fail '--check must not touch the state file'
|
|
[[ ! -e "$WG_DIR/wg0.conf" ]] || fail '--check must not write a WireGuard config'
|
|
[[ ! -e "$STATE_DIR/enrollment.token" ]] || fail '--check must not stage a token'
|
|
"$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1 && fail '--check must fail without an endpoint'
|
|
if BF_ENROLLMENT_ENDPOINT='https://example.invalid/enroll' "$BIN/bf-enroll" --token-stdin --check >/dev/null 2>&1; then fail '--check must reject placeholder endpoints'; fi
|
|
pass '--check validates without mutating or sending anything'
|
|
|
|
# --- scenario 12: schemas match the client behaviour ------------------------
|
|
python3 - "$REPO_ROOT" "$MOCK_REQUEST_CAPTURE" "$MOCK_RESPONSE" <<'PY'
|
|
import json, sys
|
|
root, request_capture, response_sample = sys.argv[1:4]
|
|
request_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-request.schema.json', encoding='utf-8'))
|
|
response_schema = json.load(open(root + '/provisioning/enrollment/schemas/enrollment-response.schema.json', encoding='utf-8'))
|
|
request = json.load(open(request_capture, encoding='utf-8'))
|
|
response = json.loads(response_sample)
|
|
if set(request_schema['properties']) != set(request_schema['required']):
|
|
raise SystemExit('request schema must close every property it requires')
|
|
if set(request) != set(request_schema['required']):
|
|
raise SystemExit('captured request does not match the request schema')
|
|
for section, keys in (('hardware', ('machine_id', 'product_uuid', 'sys_vendor')), ('release', ('os_release', 'kernel'))):
|
|
if set(request_schema['properties'][section]['required']) != set(keys):
|
|
raise SystemExit('%s schema fields drifted' % section)
|
|
if set(request[section]) != set(keys):
|
|
raise SystemExit('captured %s does not match the schema' % section)
|
|
required_response = set(response_schema['required'])
|
|
if set(response) - set(response_schema['properties']):
|
|
raise SystemExit('canned response carries fields the schema forbids')
|
|
if not required_response <= set(response):
|
|
raise SystemExit('canned response is missing required fields')
|
|
if set(response['wireguard']) != set(response_schema['properties']['wireguard']['required']):
|
|
raise SystemExit('WireGuard response fields drifted')
|
|
if set(response['central_verification']) != set(response_schema['properties']['central_verification']['required']):
|
|
raise SystemExit('central verification fields drifted')
|
|
blob = json.dumps(response_schema).lower()
|
|
for banned in ('private_key', 'privkey', 'password', 'bearer'):
|
|
if banned in blob:
|
|
raise SystemExit('response schema must not define a secret field: ' + banned)
|
|
PY
|
|
! grep -q 'BF-12345678' "$REPO_ROOT/provisioning/enrollment/README.md" || fail 'provisioning docs must not embed a device identity'
|
|
pass 'enrollment schemas match the implemented request and response contract'
|
|
|
|
# --- scenario 13: boot unit cannot loop and carries no credential -----------
|
|
UNIT="$REPO_ROOT/config/systemd/blueforce-enroll.service"
|
|
TIMER="$REPO_ROOT/config/systemd/blueforce-enroll.timer"
|
|
grep -q '^Restart=on-failure$' "$UNIT" || fail 'the boot unit must retry only failures'
|
|
grep -q '^Restart=always' "$UNIT" && fail 'the boot unit must never restart unconditionally'
|
|
grep -q '^RestartSec=' "$UNIT" || fail 'the boot unit must delay its bounded retries'
|
|
grep -q '^StartLimitBurst=' "$UNIT" || fail 'the boot unit must bound its retries'
|
|
grep -q '^StartLimitIntervalSec=' "$UNIT" || fail 'the boot unit must bound its retry window'
|
|
grep -q 'ExecStart=/usr/local/bin/bf-enroll --systemd' "$UNIT" || fail 'the boot unit must run the controlled attempt'
|
|
grep -q 'Unit=blueforce-enroll.service' "$TIMER" || fail 'the timer must trigger the enrollment attempt'
|
|
grep -q 'OnBootSec=' "$TIMER" || fail 'the timer must attempt shortly after boot'
|
|
! grep -Eqi '(token|password|secret|bearer)[[:space:]]*=' "$UNIT" "$TIMER" || fail 'the units must not embed a credential'
|
|
exec_lines="$(grep -E '^Exec[A-Za-z]*=' "$UNIT" || true)"
|
|
[[ -n "$exec_lines" ]] || fail 'the boot unit must define its attempt command'
|
|
! printf '%s\n' "$exec_lines" | grep -Eqi 'reboot|shutdown' || fail 'the boot unit must never reboot or shut down the device'
|
|
grep -q 'never reboots' "$UNIT" || fail 'the boot unit must document that it never reboots the device'
|
|
pass 'the enrollment units retry in a bounded way and carry no credential'
|
|
|
|
printf 'test-field-os-lifecycle: all scenarios passed\n'
|