Files
pxe-server/playbook/blancco-progress.py
cproudlock 44a7eeae95 blancco: CMMC evidence pack, ServiceNow tick list, cloud-upload status
Automates the Blancco parts of the onsite-support CMMC playbook:

- ENT-056/158 evidence pack: /reports/evidence?month=YYYY-MM returns
  ENT-056_<Site>_<YYYY-MM>.zip (summary.csv, README, each report's XML
  and PDF). pxe-blancco-evidence.timer saves last month's pack to the
  reports share's evidence/ folder on the 1st (Persistent).
- ENT-045/159 ServiceNow tick list: /reports/servicenow lists recent
  reports as "Wiped" or "Blancco Wipe failed" with a pre-written
  destruction note, copy buttons and Done (CSRF-protected).
- Cloud upload: blancco-progress.py posts each report's upload result
  ("Sending report document id" -> "Report sent to MC") to
  /reports/cloud-status (CSRF-exempt like /imaging/status); the Reports
  page and the CSVs show it, so a wipe missing from BMP is visible.
- CSV gains failure_reason (exception messages, failed steps, error
  counts), business_location and cloud_upload.
- Preferences: business_location "West Jefferson" (config.BLANCCO_SITE)
  and pdf+xml reports; the report copier now copies the PDF too, named
  after its XML. .part and hidden files are not listed as reports.

State (cloud confirmations, ServiceNow ticks) is a JSON file at
/var/lib/pxe-webapp/blancco-state.json, off the report share.
2026-10-05 13:43:16 -04:00

199 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Report Blancco erase progress to the PXE webapp's /imaging dashboard.
Runs inside the Blancco rootfs (installed by blancco-init.sh as a systemd
service). Follows the AlbusSeven journal and maps its log lines onto the
same /imaging/status payload the WinPE imaging clients post, so each
wiping PC gets a tile keyed by BIOS serial with pctype "blancco".
Stages (stage_total 7): 1 booting and 2 starting are posted by
blancco-init.sh; this script posts 3 waiting for licence, 4 erasing,
5 report saved, 6 report sent / on share, 7 done. A failed erasure step
marks the session failed.
Usage: blancco-progress.py [--replay FILE] [--dry-run]
"""
import json
import re
import subprocess
import sys
import time
import urllib.request
STATUS_URL = "http://172.16.9.1:9009/imaging/status"
CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status"
STAGE_TOTAL = 7
# Progress lines arrive in bursts; post at most this often unless the
# stage itself changes.
MIN_POST_INTERVAL = 3.0
def read(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return ""
def pxe_iface():
"""(mac, ip) of the interface on the PXE LAN, or blanks."""
try:
out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True,
text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return "", ""
for line in out.splitlines():
m = re.search(r"^\d+:\s+(\S+)\s+inet (172\.16\.9\.\d+)/", line)
if m:
return read(f"/sys/class/net/{m.group(1)}/address"), m.group(2)
return "", ""
SERIAL = read("/sys/class/dmi/id/product_serial") or read("/etc/machine-id")
MODEL = " ".join(x for x in (read("/sys/class/dmi/id/sys_vendor"),
read("/sys/class/dmi/id/product_name")) if x)
# (pattern, stage_index, stage text or callable(match) -> text, status)
RULES = [
(re.compile(r"Waiting for MC connectivity"), 3,
"Waiting for licence (cloud)", None),
(re.compile(r"ClurichaunLicenseProvider: ([1-9]\d*) licenses available for product id 1\b"), 3,
lambda m: f"Licence OK ({m.group(1)} left)", None),
(re.compile(r"startErasure: success"), 4, "Erasing", None),
(re.compile(r"About to start Step (\d+) \((.*?)\)\.?$"), 4,
lambda m: f"Erasing: step {m.group(1)} - {m.group(2).split(' or ')[0][:60]}", None),
(re.compile(r"Step \d+ \(.*\) failed|Erasure step .* failed\b|erasure failed", re.I), 4,
"Erasure FAILED", "failed"),
(re.compile(r"erasureFinishedForEntity"), 5, "Erasure finished", None),
(re.compile(r"\[report\.manager\].*Report saving success"), 5, "Report saved", None),
(re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None),
(re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"),
]
# Cloud upload of a report: "[comm.manager] Sending report document id: <id>"
# then "Report sent to MC" (or an upload failure).
SENDING = re.compile(r"Sending report document id: ([0-9a-fA-F-]{36})")
SENT = re.compile(r"Report sent to MC")
SEND_FAILED = re.compile(r"(report (upload|send|sending)[^\n]*fail|failed to (send|upload) report)", re.I)
# Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test
# completion and volume lines that also carry a percentage.
PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I)
class Reporter:
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.mac, self.ip = pxe_iface()
self.stage_index = 2
self.stage = "Blancco started"
self.status = "in_progress"
self.last_post = 0.0
self.pending = []
def post(self, force=False):
now = time.monotonic()
if not force and now - self.last_post < MIN_POST_INTERVAL:
return
payload = {
"serial": SERIAL,
"mac": self.mac,
"ip": self.ip,
"pctype": "blancco",
"hostname_target": MODEL,
"current_stage": self.stage,
"stage_index": self.stage_index,
"stage_total": STAGE_TOTAL,
"status": self.status,
"source": "blancco",
}
if self.pending:
payload["log_lines"] = self.pending[-20:]
if self.dry_run:
print(json.dumps(payload))
else:
req = urllib.request.Request(
STATUS_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5).read()
except OSError:
return # webapp unreachable: keep the lines for the next post
self.pending = []
self.last_post = now
def cloud(self, document_id, sent, detail):
"""Tell the webapp whether a report reached the Blancco cloud."""
payload = {"document_id": document_id, "serial": SERIAL,
"sent": sent, "detail": detail[:200]}
if self.dry_run:
print(json.dumps({"cloud": payload}))
return
req = urllib.request.Request(
CLOUD_URL, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5).read()
except OSError:
pass
def line(self, text):
text = text.strip()
if not text:
return
m = SENDING.search(text)
if m:
self.sending = m.group(1)
elif getattr(self, "sending", None):
if SENT.search(text):
self.cloud(self.sending, True, text)
self.sending = None
elif SEND_FAILED.search(text):
self.cloud(self.sending, False, text)
self.sending = None
for pat, idx, stage, status in RULES:
m = pat.search(text)
if not m:
continue
if self.status == "failed" and status != "failed":
return # a failure sticks for the rest of the run
if idx < self.stage_index and status != "failed":
return # late licence chatter must not rewind the tile
new_stage = stage(m) if callable(stage) else stage
changed = (idx, new_stage) != (self.stage_index, self.stage)
self.stage_index = max(self.stage_index, idx) if status != "failed" else idx
self.stage = new_stage
if status:
self.status = status
self.pending.append(text[:200])
if changed:
self.post(force=True)
return
m = PROGRESS.search(text)
if m and self.stage_index == 4 and "Self-Test" not in text:
base = self.stage.split(" [")[0]
self.stage = f"{base} [{m.group(1)}%]"
self.post()
def main():
dry_run = "--dry-run" in sys.argv
rep = Reporter(dry_run=dry_run)
if "--replay" in sys.argv:
with open(sys.argv[sys.argv.index("--replay") + 1]) as f:
for text in f:
rep.line(text)
rep.post(force=True)
return
rep.post(force=True)
proc = subprocess.Popen(
["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"],
stdout=subprocess.PIPE, text=True, bufsize=1)
for text in proc.stdout:
rep.line(text)
if rep.status in ("succeeded", "failed") and rep.stage_index == STAGE_TOTAL:
rep.post(force=True)
if __name__ == "__main__":
main()