diff --git a/playbook/blancco-init.sh b/playbook/blancco-init.sh index d55605a..55f2791 100644 --- a/playbook/blancco-init.sh +++ b/playbook/blancco-init.sh @@ -105,22 +105,96 @@ wget -O /tmp/kmod.tar.gz http://$SERVER/blancco/kmod.tar.gz 2>&1 [ -s /tmp/kmod.tar.gz ] && (cd /run/newroot && gunzip -c /tmp/kmod.tar.gz | tar xf - && rm -f /tmp/kmod.tar.gz) mkdir -p /run/newroot/albus -wget -O /run/newroot/albus/config.xml http://$SERVER/blancco/config-clean.xml 2>&1 || true -wget -O /run/newroot/albus/preferences.xml http://$SERVER/blancco/preferences.xml 2>&1 || true -cp -f /run/newroot/albus/preferences.xml /run/newroot/albus/preferences.save 2>/dev/null || true + +# preferences.xml REQUIRED. Without valid file, Blancco airootfs's +# /opt/scripts/validate_preferences.sh silently restores +# /albus/preferences.save (factory defaults, empty network_share) so +# erasure reports never reach SMB. Fail loud here instead. +PREFS=/run/newroot/albus/preferences.xml +if ! wget -O "$PREFS" "http://$SERVER/blancco/preferences.xml"; then + echo "ERROR: preferences.xml download failed from http://$SERVER/blancco/preferences.xml" + echo "Reports would fall back to factory defaults (no SMB target)." + echo "Dropping to shell - check Apache + network." + exec /bin/sh +fi +# Busybox initramfs has no xmllint. Grep for required SMB markers instead; +# if either is missing, the prefs file in web_root is stale or corrupted. +if [ ! -s "$PREFS" ] || ! grep -q "$SERVER" "$PREFS" \ + || ! grep -q 'blancco-reports' "$PREFS"; then + echo "ERROR: preferences.xml missing required network_share entries." + echo "Expected $SERVER + blancco-reports." + echo "=== first 60 lines of $PREFS ===" + head -60 "$PREFS" + exec /bin/sh +fi +# Clobber preferences.save too. validate_preferences.sh in airootfs falls +# back to preferences.save on any future xmllint failure; if that ever +# fires, we want the fallback to still have the right SMB target. +cp -f "$PREFS" /run/newroot/albus/preferences.save + +if ! wget -O /run/newroot/albus/config.xml "http://$SERVER/blancco/config-clean.xml"; then + echo "ERROR: config.xml (license container) download failed." + exec /bin/sh +fi mkdir -p /run/newroot/etc/X11/xorg.conf.d -cat > /run/newroot/etc/X11/xorg.conf.d/20-failsafeDriver.conf << XEOF -Section "Device" - Identifier "Failsafe Video Device" - Driver "modesetting" -EndSection +# Don't pin a single Xorg driver - hardware varies per site (Intel iGPU, +# NVIDIA GK208, AMD, etc). Let Xorg auto-pick. With nomodeset + KMS +# blacklist on kernel cmdline, Xorg falls back to fbdev (uses +# kernel-provided framebuffer from vga=normal) which works on most boxes. +# Previous "modesetting" pin needed KMS we disabled; "vesa" pin also +# didn't drive NVIDIA cards. Removing the pin entirely. +rm -f /run/newroot/etc/X11/xorg.conf.d/20-failsafeDriver.conf 2>/dev/null || true + +# Hard-mask sleep/suspend/hibernate targets in the airootfs overlay. systemd +# kernel cmdline systemd.mask= only blocks systemd-side activation; userspace +# can still write /sys/power/state directly. /dev/null symlinks under +# /etc/systemd/system/ block ALL systemd-mediated paths AND stop logind from +# advertising sleep capability to userland. Combined with the logind drop-in +# below, /sys/power/state writes from non-root userland (Albus) also fail. +mkdir -p /run/newroot/etc/systemd/system +for tgt in sleep.target suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target; do + ln -sf /dev/null "/run/newroot/etc/systemd/system/$tgt" +done + +mkdir -p /run/newroot/etc/systemd/logind.conf.d +cat > /run/newroot/etc/systemd/logind.conf.d/no-suspend.conf << XEOF +[Login] +IdleAction=ignore +HandleSuspendKey=ignore +HandleHibernateKey=ignore +HandleLidSwitch=ignore +HandleLidSwitchDocked=ignore +HandlePowerKey=ignore XEOF +# Disable Xorg screensaver + DPMS so display stays on during long erasures. +# Numbered 00-* to run before vendor xinitrc.d hooks. +mkdir -p /run/newroot/etc/X11/xinit/xinitrc.d +cat > /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh << 'XEOF' +#!/bin/sh +xset s off -dpms 2>/dev/null || true +xset s noblank 2>/dev/null || true +setterm -blank 0 -powerdown 0 2>/dev/null || true +XEOF +chmod +x /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh + mkdir -p /run/newroot/proc /run/newroot/sys /run/newroot/dev /run/newroot/run /run/newroot/tmp mount --move /proc /run/newroot/proc mount --move /sys /run/newroot/sys mount --move /dev /run/newroot/dev +# Hard-block kernel suspend/hibernate path. Bind-mount /dev/null over the +# sysfs power-control files so any userland write (Albus auto-suspend, +# pm-utils, etc) becomes a no-op. This is the LAST line of defense - kernel +# cmdline systemd.mask, /dev/null symlinks for sleep targets, and logind +# drop-ins were all bypassed by Albus writing /sys/power/state directly. +# Bind-mount works at the VFS layer below sysfs, so even kernel-side mount +# remounts wouldn't undo it. +mount --bind /dev/null /run/newroot/sys/power/state 2>/dev/null || true +mount --bind /dev/null /run/newroot/sys/power/disk 2>/dev/null || true +mount --bind /dev/null /run/newroot/sys/power/mem_sleep 2>/dev/null || true +mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true + echo "Switching root..." exec switch_root /run/newroot /sbin/init diff --git a/playbook/pxe_server_setup.yml b/playbook/pxe_server_setup.yml index 77ac870..d52323e 100644 --- a/playbook/pxe_server_setup.yml +++ b/playbook/pxe_server_setup.yml @@ -159,8 +159,14 @@ # the wired NIC much later). Removing these options entirely lets # Windows route internet via WiFi and same-subnet PXE/SMB traffic # via wired, no migration script needed. - # dhcp-option=3,10.9.100.1 - # dhcp-option=6,8.8.8.8 + # + # Important: dnsmasq DEFAULTS to sending its own listening address as + # both router and DNS when these options are unset. Commenting them + # out is NOT the same as disabling - imaged PCs (and Blancco PXE + # clients) end up with 10.9.100.1 as gateway. The empty-value form + # below explicitly suppresses both options. + dhcp-option=3 + dhcp-option=6 enable-tftp tftp-root={{ tftp_dir }} # Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run @@ -863,9 +869,8 @@ args: creates: "{{ web_root }}/blancco/kmod.tar.gz" - - name: "Deploy Blancco config and preferences (null-stripped)" + - name: "Extract config-clean.xml from config.img (strip null bytes)" shell: | - # Strip null bytes from config.img files and deploy if [ -f "{{ web_root }}/blancco/config.img" ]; then WORK=$(mktemp -d) cd "$WORK" @@ -873,11 +878,30 @@ tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml" rm -rf "$WORK" fi - # Deploy preferences from playbook (pre-configured with network share) - cp "{{ usb_root }}/playbook/blancco-preferences.xml" "{{ web_root }}/blancco/preferences.xml" args: creates: "{{ web_root }}/blancco/config-clean.xml" + # Idempotent file copy (was bundled into the previous shell task with a + # 'creates:' gate, so playbook re-runs never picked up edits to the + # source XML - stale prefs survived deploys). + - name: "Deploy Blancco preferences.xml from playbook source" + copy: + src: "{{ usb_root }}/playbook/blancco-preferences.xml" + dest: "{{ web_root }}/blancco/preferences.xml" + mode: '0644' + + # Hard gate: blancco-init.sh in initramfs drops to shell if the served + # preferences.xml is malformed or missing the SMB target. Fail the + # playbook here too so the problem surfaces at deploy time, not at the + # next erasure attempt. + - name: "Validate deployed preferences.xml is well-formed + targets the right SMB share" + shell: | + set -e + python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")' + grep -q '10.9.100.1' "{{ web_root }}/blancco/preferences.xml" + grep -q 'blancco-reports' "{{ web_root }}/blancco/preferences.xml" + changed_when: false + - name: "Ensure Samba user for Blancco reports exists (idempotent)" shell: | id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco @@ -994,6 +1018,11 @@ Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports Environment=ENROLLMENT_SHARE=/srv/samba/enrollment Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log + # Lock TZ so report/backup mtimes and audit-log timestamps render + # in Eastern time regardless of how the host's /etc/localtime ends + # up. Without this, a Python process started before timedatectl + # finishes can cache UTC for its lifetime. + Environment=TZ=America/New_York ExecStart=/usr/bin/python3 app.py Restart=always RestartSec=5 diff --git a/webapp/app.py b/webapp/app.py index 6226216..96d39a4 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -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/") +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/", methods=["POST"]) def blancco_delete_report(filename): filename = secure_filename(filename) diff --git a/webapp/services/blancco_report.py b/webapp/services/blancco_report.py new file mode 100644 index 0000000..e3ab4e8 --- /dev/null +++ b/webapp/services/blancco_report.py @@ -0,0 +1,105 @@ +""" +Parse Blancco Drive Eraser XML report into a friendly Python dict. + +Blancco encodes everything as V... +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 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 diff --git a/webapp/templates/report_view.html b/webapp/templates/report_view.html new file mode 100644 index 0000000..a6badd4 --- /dev/null +++ b/webapp/templates/report_view.html @@ -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 %} + +
+
+ +

Drive Erasure Certificate

+ {{ filename }} +
+ +
+ +{# Hero summary: state badge + standard + drive count + erasure datetime #} +{% set first = (data.erasures or [{}])[0] %} +{% set overall_state = first.state or 'Unknown' %} +
+
+
+
+
Overall result
+
+ + {{ overall_state }} + +
+
+
+
Erasure standard
+
{{ first.erasure_standard_name or '-' }}
+
+
+
Drives erased
+
{{ data.erasures | length }}
+
+
+
Elapsed time
+
{{ first.elapsed_time or '-' }}
+
+
+
Erased at
+
{{ first.timestamp or meta.date or '-' }}
+
+
+
+
+ +
+
+
+
Customer
+
+
+
Business name
{{ company.business_name or '-' }}
+
Location
{{ company.business_location or '-' }}
+
License holder
{{ company.customer_license or '-' }}
+ {% for k, v in company.items() %} + {% if k not in ['business_name','business_location','customer_license'] %} +
{{ k|replace('_',' ')|title }}
{{ v }}
+ {% endif %} + {% endfor %} +
+
+
+
+
+
+
System
+
+
+
Manufacturer
{{ sysinfo.manufacturer or '-' }}
+
Model
{{ sysinfo.model or '-' }}
+
Serial
{{ sysinfo.serial or '-' }}
+
Chassis
{{ sysinfo.chassis_type or '-' }}
+
BIOS mode
{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}SecureBoot {{ sysinfo.secure_boot_state }}{% endif %}
+
BIOS vendor
{{ bios.vendor or '-' }} {{ bios.version or '' }}
+
+
+
+
+
+ +{# 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 %} +
+
+
+ Drive #{{ er.erasure_id or loop.index }}: + {{ t.vendor or '' }} {{ t.model or '(unknown drive)' }} + {{ t.serial or '-' }} +
+ {{ state }} +
+
+
+
+
Interface
+
{{ t.interface_type or '-' }}
+
+
+
Capacity
+
{{ hbytes(t.capacity or 0) }}
+
+
+
Health
+
{{ t.health or '-' }}
+
+
+
Errors
+
{{ er.total_errors or 0 }}
+
+
+
Sectors
+
{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}
+
+
+ + {% if steps %} +
+ + + + + + + + + + + + + {% for s in steps %} + + + + + + + + + {% endfor %} + +
#StepPatternStateElapsedErrors
{{ s.number or loop.index }}{{ s.type or '-' }}{{ s.pattern or '-' }} + + {{ s.state or '-' }} + + {{ s.elapsed_time or '-' }}{{ s.errors or 0 }}
+
+ {% endif %} +
+
+{% endfor %} + +
+
Document signing & provenance
+
+
+
Document ID
+
{{ meta.document_id or '-' }}
+
Product
+
{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})
+
Generated
+
{{ meta.date or '-' }}
+ {% if data.license_consumption_ids %} +
License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}
+
+ {% for lc in data.license_consumption_ids %}{{ lc }}{% endfor %} +
+ {% endif %} +
Integrity hash
+
+
+ Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars) + {{ meta.integrity }} +
+
+
+
+
+ +{% endblock %} diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index ce4e06f..c1e5e1a 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -37,6 +37,12 @@ {{ r.modified | timestamp_fmt }} + {% if r.filename.lower().endswith('.xml') %} + + View + + {% endif %} Download