blancco: fix silent prefs fallback, suspend trap, display blank + add View

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>
This commit is contained in:
cproudlock
2026-05-13 07:38:54 -04:00
parent adc8d50e66
commit 974accf98a
6 changed files with 462 additions and 15 deletions

View File

@@ -33,7 +33,7 @@ from lxml import etree
from werkzeug.utils import secure_filename
import config
from services import deploy, fs, images, system, unattend, wim
from services import blancco_report, deploy, fs, images, system, unattend, wim
from services.audit import audit
from services.csrf import init_csrf
@@ -390,6 +390,24 @@ def blancco_download_report(filename):
return send_file(fpath, as_attachment=True)
@app.route("/reports/view/<filename>")
def blancco_view_report(filename):
filename = secure_filename(filename)
fpath = os.path.join(config.BLANCCO_REPORTS, filename)
if not os.path.isfile(fpath):
flash(f"Report not found: {filename}", "danger")
return redirect(url_for("blancco_reports"))
if not filename.lower().endswith(".xml"):
flash("Formatted view supports XML reports only.", "warning")
return redirect(url_for("blancco_reports"))
try:
data = blancco_report.parse(fpath)
except Exception as ex:
flash(f"Failed to parse {filename}: {ex}", "danger")
return redirect(url_for("blancco_reports"))
return render_template("report_view.html", filename=filename, data=data)
@app.route("/reports/delete/<filename>", methods=["POST"])
def blancco_delete_report(filename):
filename = secure_filename(filename)

View File

@@ -0,0 +1,105 @@
"""
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

View File

@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}{{ filename }} - Blancco Report{% endblock %}
{% set meta = data.meta or {} %}
{% set company = data.company or {} %}
{% set hw = data.hardware or {} %}
{% set sysinfo = hw.system or {} %}
{% set bios = hw.bios or {} %}
{% set memsum = hw.memory or {} %}
{# Bytes to human-readable GB / TB string, no decimals at MB scale #}
{% macro hbytes(n) -%}
{%- set n = (n | int) -%}
{%- if n >= 1099511627776 -%}
{{ "%.2f"|format(n / 1099511627776) }} TB
{%- elif n >= 1073741824 -%}
{{ "%.2f"|format(n / 1073741824) }} GB
{%- elif n >= 1048576 -%}
{{ "%.1f"|format(n / 1048576) }} MB
{%- else -%}
{{ n }} B
{%- endif -%}
{%- endmacro %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div>
<div class="text-muted small mb-1">
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">&larr; All reports</a>
</div>
<h2 class="mb-0">Drive Erasure Certificate</h2>
<code class="small text-muted">{{ filename }}</code>
</div>
<div class="text-end">
<a href="{{ url_for('blancco_download_report', filename=filename) }}"
class="btn btn-sm btn-outline-primary">Download XML</a>
</div>
</div>
{# Hero summary: state badge + standard + drive count + erasure datetime #}
{% set first = (data.erasures or [{}])[0] %}
{% set overall_state = first.state or 'Unknown' %}
<div class="card border-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }} mb-4">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-3">
<div class="text-muted small">Overall result</div>
<div class="fs-3">
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}">
{{ overall_state }}
</span>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Erasure standard</div>
<div>{{ first.erasure_standard_name or '-' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Drives erased</div>
<div class="fs-5">{{ data.erasures | length }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Elapsed time</div>
<div class="fs-5"><code>{{ first.elapsed_time or '-' }}</code></div>
</div>
<div class="col-md-2">
<div class="text-muted small">Erased at</div>
<div><code class="small">{{ first.timestamp or meta.date or '-' }}</code></div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Customer</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
{% for k, v in company.items() %}
{% if k not in ['business_name','business_location','customer_license'] %}
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
{% endif %}
{% endfor %}
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">System</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code>{{ sysinfo.serial or '-' }}</code></dd>
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge bg-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
</dl>
</div>
</div>
</div>
</div>
{# Per-drive cards: target details + step-by-step timeline #}
{% for er in data.erasures %}
{% set t = er.target or {} %}
{% set state = er.state or 'Unknown' %}
{% set border = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
{% set steps = (er.steps or {}).step %}
{% if steps is not iterable or steps is string or steps is mapping %}
{% set steps = [steps] if steps else [] %}
{% endif %}
<div class="card border-{{ border }} mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
<code class="ms-2 small text-muted">{{ t.serial or '-' }}</code>
</div>
<span class="badge bg-{{ border }}">{{ state }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-3">
<div class="text-muted small">Interface</div>
<div>{{ t.interface_type or '-' }}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Capacity</div>
<div>{{ hbytes(t.capacity or 0) }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Health</div>
<div>{{ t.health or '-' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Errors</div>
<div>{{ er.total_errors or 0 }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Sectors</div>
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
</div>
</div>
{% if steps %}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width:3rem;">#</th>
<th>Step</th>
<th>Pattern</th>
<th>State</th>
<th>Elapsed</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{% for s in steps %}
<tr>
<td>{{ s.number or loop.index }}</td>
<td>{{ s.type or '-' }}</td>
<td class="small text-muted">{{ s.pattern or '-' }}</td>
<td>
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
{{ s.state or '-' }}
</span>
</td>
<td><code class="small">{{ s.elapsed_time or '-' }}</code></td>
<td>{{ s.errors or 0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="card mb-3">
<div class="card-header">Document signing &amp; provenance</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-sm-3">Document ID</dt>
<dd class="col-sm-9"><code class="text-break">{{ meta.document_id or '-' }}</code></dd>
<dt class="col-sm-3">Product</dt>
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
<dt class="col-sm-3">Generated</dt>
<dd class="col-sm-9"><code>{{ meta.date or '-' }}</code></dd>
{% if data.license_consumption_ids %}
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
<dd class="col-sm-9">
{% for lc in data.license_consumption_ids %}<code class="d-block">{{ lc }}</code>{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-3">Integrity hash</dt>
<dd class="col-sm-9">
<details>
<summary class="text-muted">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
<code class="text-break small d-block mt-2">{{ meta.integrity }}</code>
</details>
</dd>
</dl>
</div>
</div>
{% endblock %}

View File

@@ -37,6 +37,12 @@
</td>
<td>{{ r.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
{% if r.filename.lower().endswith('.xml') %}
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-success" title="View formatted report">
View
</a>
{% endif %}
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-primary" title="Download">
Download