End-to-end fixes for Blancco Drive Eraser PXE flow uncovered by chasing
"reports never reach SMB share" across two air-gapped sites:
playbook/blancco-init.sh:
* Drop silent || true on wget of preferences.xml + config.xml. Fail
loud with shell-drop if download or marker grep fails. Background:
airootfs /opt/scripts/validate_preferences.sh restores
/albus/preferences.save (factory defaults, empty network_share) if
xmllint fails. wget failure made every report silently land nowhere.
* Clobber /albus/preferences.save with the same served file so even if
the validator fallback fires, the SMB target survives.
* Bind-mount /dev/null over /sys/power/{state,disk,mem_sleep,autosleep}
before switch_root. Albus's license-retry path writes /sys/power/state
directly (bypassing systemd targets); this is the last-line block.
* /dev/null symlinks for sleep/suspend/hibernate systemd targets in the
airootfs overlay + logind drop-in with IdleAction/Handle*=ignore.
Three independent layers because cmdline systemd.mask alone is bypassed
by direct /sys/power/state writes.
* xinitrc.d/00-no-screen-blank.sh runs xset s off -dpms + setterm
-blank 0 -powerdown 0 so the Blancco GUI doesn't blank during long
erasures.
* Removed the 20-failsafeDriver.conf "modesetting" pin. modesetting
needs DRM/KMS which we disable on kernel cmdline; "vesa" also failed
on NVIDIA. With the pin gone Xorg auto-picks fbdev which uses the
kernel framebuffer from vga=normal - works across Intel, AMD, and
older NVIDIA without nouveau.
playbook/pxe_server_setup.yml:
* dnsmasq.conf: explicit empty-value dhcp-option=3 + dhcp-option=6.
Without them, dnsmasq defaults to sending its own IP as router AND
DNS. Commenting the configured-value lines did NOT disable the push
(root cause of "wired keeps picking up 10.9.100.1 as gateway").
* Split the Blancco config.img extraction and preferences.xml deploy
into separate tasks. The previous shell-with-creates: gate caused
playbook re-runs to skip the prefs deploy entirely after first run.
* Added a validation task that runs python3 xml parse + grep on the
deployed preferences.xml to fail the playbook at deploy time if the
SMB markers are missing.
* Added Environment=TZ=America/New_York to the pxe-webapp systemd
service so report mtimes and audit log render in Eastern time even
if the Python process is started before timedatectl converges.
webapp:
* services/blancco_report.py: parse Blancco's XML report format
(recursive <entries name="..."> walker) into a friendly dict.
* templates/report_view.html: Bootstrap "Drive Erasure Certificate"
layout - hero summary, customer + system cards, per-drive cards with
step-by-step erasure timeline, document signing footer with
integrity hash detail.
* /reports/view/<filename> route + View button on the reports list
(XML reports only; PDFs still download).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.7 KiB
Python
106 lines
3.7 KiB
Python
"""
|
|
Parse Blancco Drive Eraser XML report into a friendly Python dict.
|
|
|
|
Blancco encodes everything as <entries name="X"><entry name="K" type="T">V</entry>...
|
|
which is too noisy to read directly in a template. Walking the tree once into
|
|
nested dicts/lists lets the template render fields by their semantic name
|
|
without any XPath-style noise.
|
|
|
|
Returned shape (best-effort; missing keys are OK in templates):
|
|
{
|
|
"meta": {document_id, date, product_name, product_version,
|
|
product_revision, integrity},
|
|
"company": {business_location, business_name, customer_license, ...},
|
|
"license_consumption_ids": [str, ...],
|
|
"erasures": [ {erasure_id, timestamp, target:{...}, state,
|
|
elapsed_time, erasure_standard_name, step:[...], ...} ],
|
|
"hardware": {system:{...}, bios:{...}, processors:{...}, ...},
|
|
}
|
|
"""
|
|
from __future__ import annotations
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
def _walk(node):
|
|
# Recursive walk over Blancco's <entry|entries name="K"> children.
|
|
# Repeated names collapse to a list.
|
|
result = {}
|
|
for child in node:
|
|
name = child.attrib.get("name") or child.tag
|
|
if child.tag == "entry":
|
|
val = (child.text or "").strip()
|
|
elif child.tag == "entries":
|
|
val = _walk(child)
|
|
else:
|
|
continue
|
|
if name in result:
|
|
existing = result[name]
|
|
if not isinstance(existing, list):
|
|
result[name] = [existing]
|
|
result[name].append(val)
|
|
else:
|
|
result[name] = val
|
|
return result
|
|
|
|
|
|
def _text(parent, path, default=""):
|
|
if parent is None:
|
|
return default
|
|
el = parent.find(path)
|
|
if el is None or el.text is None:
|
|
return default
|
|
return el.text.strip()
|
|
|
|
|
|
def parse(path: str) -> dict:
|
|
tree = ET.parse(path)
|
|
root = tree.getroot()
|
|
rep = root.find(".//report/blancco_data") or root
|
|
|
|
out = {
|
|
"meta": {},
|
|
"company": {},
|
|
"license_consumption_ids": [],
|
|
"erasures": [],
|
|
"hardware": {},
|
|
}
|
|
|
|
desc = rep.find("description")
|
|
if desc is not None:
|
|
out["meta"]["document_id"] = _text(desc, "document_id")
|
|
log_entry = desc.find(".//log_entry")
|
|
if log_entry is not None:
|
|
author = log_entry.find("author")
|
|
if author is not None:
|
|
out["meta"]["product_name"] = _text(author, "product_name")
|
|
out["meta"]["product_version"] = _text(author, "product_version")
|
|
out["meta"]["product_revision"] = _text(author, "product_revision")
|
|
out["meta"]["date"] = _text(log_entry, "date")
|
|
out["meta"]["integrity"] = _text(log_entry, "integrity")
|
|
|
|
company = desc.find(".//entry[@name='description_entries']/entries[@name='company_information']")
|
|
if company is not None:
|
|
for e in company.findall("entry"):
|
|
k = e.attrib.get("name", "")
|
|
if k:
|
|
out["company"][k] = (e.text or "").strip()
|
|
|
|
lcids = desc.find(".//entry[@name='description_entries']/entries[@name='license_consumption_ids']")
|
|
if lcids is not None:
|
|
for e in lcids.findall("entry"):
|
|
v = (e.text or "").strip()
|
|
if v:
|
|
out["license_consumption_ids"].append(v)
|
|
|
|
for er in rep.findall(".//blancco_erasure_report//entries[@name='erasure']"):
|
|
out["erasures"].append(_walk(er))
|
|
|
|
hw = rep.find("blancco_erasure_report/../blancco_hardware_report") or rep.find(".//blancco_hardware_report")
|
|
if hw is not None:
|
|
for sub in hw:
|
|
if sub.tag != "entries":
|
|
continue
|
|
out["hardware"][sub.attrib.get("name", "")] = _walk(sub)
|
|
|
|
return out
|