Fix review findings: offline assets, security, audit logging
- Bundle Bootstrap CSS/JS/icons locally for air-gapped operation - Add path traversal validation on image import source - Disable Flask debug mode in production - Fix file handle leaks, remove unused import - Add python3-pip, python3-venv, p7zip-full to offline packages - Add pip wheel download/bundling for offline Flask install - Change UFW default policy from allow to deny - Fix wrong path displayed in unattend editor template - Dynamic sidebar image lists from all_image_types - Add audit logging for all write operations - Audit log viewer page with activity history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,6 +25,9 @@ offline-packages/
|
||||
# Boot tool binaries (built by prepare-boot-tools.sh)
|
||||
boot-tools/
|
||||
|
||||
# Python wheels for offline install (built by download-packages.sh)
|
||||
pip-wheels/
|
||||
|
||||
# Deployment images (imported via webapp or USB)
|
||||
geastandardpbr/
|
||||
|
||||
|
||||
@@ -185,6 +185,15 @@ if [ -d "$WEBAPP_DIR" ]; then
|
||||
echo " Copied webapp/"
|
||||
fi
|
||||
|
||||
# Copy pip wheels for offline Flask install
|
||||
PIP_WHEELS_DIR="$SCRIPT_DIR/pip-wheels"
|
||||
if [ -d "$PIP_WHEELS_DIR" ]; then
|
||||
cp -r "$PIP_WHEELS_DIR" "$MOUNT_POINT/pip-wheels"
|
||||
echo " Copied pip-wheels/"
|
||||
else
|
||||
echo " No pip-wheels/ found (run download-packages.sh first)"
|
||||
fi
|
||||
|
||||
# Copy boot tools (Clonezilla, Blancco, Memtest) if prepared
|
||||
BOOT_TOOLS_DIR="$SCRIPT_DIR/boot-tools"
|
||||
if [ -d "$BOOT_TOOLS_DIR" ]; then
|
||||
|
||||
@@ -26,6 +26,9 @@ PLAYBOOK_PACKAGES=(
|
||||
ufw
|
||||
cron
|
||||
wimtools
|
||||
python3-pip
|
||||
python3-venv
|
||||
p7zip-full
|
||||
)
|
||||
|
||||
# Packages installed during autoinstall late-commands (NetworkManager, WiFi, etc.)
|
||||
@@ -64,21 +67,28 @@ DEP_COUNT=$(echo "$DEPS" | wc -l)
|
||||
echo " Found $DEP_COUNT packages (including dependencies)"
|
||||
|
||||
# Download all packages
|
||||
echo "[3/3] Downloading packages to $OUT_DIR..."
|
||||
echo "[3/4] Downloading .deb packages to $OUT_DIR..."
|
||||
cd "$OUT_DIR"
|
||||
apt-get download $DEPS 2>&1 | tail -5
|
||||
|
||||
DEB_COUNT=$(ls -1 *.deb 2>/dev/null | wc -l)
|
||||
TOTAL_SIZE=$(du -sh . | cut -f1)
|
||||
|
||||
echo " $DEB_COUNT packages ($TOTAL_SIZE)"
|
||||
|
||||
# Download pip wheels for Flask webapp (offline install)
|
||||
echo "[4/4] Downloading Python wheels for webapp..."
|
||||
PIP_DIR="$(dirname "$OUT_DIR")/pip-wheels"
|
||||
mkdir -p "$PIP_DIR"
|
||||
pip3 download -d "$PIP_DIR" flask lxml 2>&1 | tail -5
|
||||
|
||||
WHL_COUNT=$(ls -1 "$PIP_DIR"/*.whl "$PIP_DIR"/*.tar.gz 2>/dev/null | wc -l)
|
||||
echo " $WHL_COUNT Python packages downloaded to pip-wheels/"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "Download complete!"
|
||||
echo "============================================"
|
||||
echo " Packages: $DEB_COUNT"
|
||||
echo " Total size: $TOTAL_SIZE"
|
||||
echo " Location: $OUT_DIR/"
|
||||
echo ""
|
||||
echo "Next: copy these into your ubuntu_playbook/ directory"
|
||||
echo " cp $OUT_DIR/*.deb /path/to/ubuntu_playbook/"
|
||||
echo " .deb packages: $DEB_COUNT ($TOTAL_SIZE) in $OUT_DIR/"
|
||||
echo " Python wheels: $WHL_COUNT in $PIP_DIR/"
|
||||
echo ""
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
- name: "Enable UFW firewall"
|
||||
ufw:
|
||||
state: enabled
|
||||
policy: allow
|
||||
policy: deny
|
||||
|
||||
- name: "Schedule dnsmasq restart 15s after reboot"
|
||||
cron:
|
||||
@@ -346,11 +346,6 @@
|
||||
job: "/bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service"
|
||||
|
||||
# --- Web Management App (Flask) ---
|
||||
- name: "Install pip for Python package management"
|
||||
command: apt-get install -y python3-pip python3-venv
|
||||
args:
|
||||
creates: /usr/bin/pip3
|
||||
|
||||
- name: "Create webapp directory"
|
||||
file:
|
||||
path: /opt/pxe-webapp
|
||||
@@ -369,10 +364,13 @@
|
||||
args:
|
||||
creates: /opt/pxe-webapp/venv/bin/python
|
||||
|
||||
- name: "Install webapp Python dependencies"
|
||||
pip:
|
||||
requirements: /opt/pxe-webapp/requirements.txt
|
||||
virtualenv: /opt/pxe-webapp/venv
|
||||
- name: "Install webapp Python dependencies (offline wheels)"
|
||||
shell: >
|
||||
/opt/pxe-webapp/venv/bin/pip install --no-index
|
||||
--find-links="{{ usb_mount }}/../pip-wheels/"
|
||||
--find-links="{{ usb_mount }}/pip-wheels/"
|
||||
-r /opt/pxe-webapp/requirements.txt 2>/dev/null ||
|
||||
/opt/pxe-webapp/venv/bin/pip install -r /opt/pxe-webapp/requirements.txt
|
||||
|
||||
- name: "Create systemd service for PXE webapp"
|
||||
copy:
|
||||
@@ -390,6 +388,7 @@
|
||||
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
|
||||
Environment=WEB_ROOT={{ web_root }}
|
||||
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
||||
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
|
||||
ExecStart=/opt/pxe-webapp/venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Flask web application for managing a GE Aerospace PXE server."""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
@@ -24,6 +26,22 @@ app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
||||
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 * 1024 # 16 GB max upload
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit logging
|
||||
# ---------------------------------------------------------------------------
|
||||
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
||||
audit_logger = logging.getLogger("pxe_audit")
|
||||
audit_logger.setLevel(logging.INFO)
|
||||
_audit_handler = logging.FileHandler(AUDIT_LOG, mode="a")
|
||||
_audit_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
||||
audit_logger.addHandler(_audit_handler)
|
||||
|
||||
|
||||
def audit(action, detail=""):
|
||||
"""Write an entry to the audit log."""
|
||||
ip = request.remote_addr if request else "system"
|
||||
audit_logger.info(f"[{ip}] {action}: {detail}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -190,7 +208,8 @@ def parse_unattend(xml_path):
|
||||
data["raw_xml"] = UNATTEND_TEMPLATE
|
||||
return data
|
||||
|
||||
raw = open(xml_path, "r", encoding="utf-8").read()
|
||||
with open(xml_path, "r", encoding="utf-8") as fh:
|
||||
raw = fh.read()
|
||||
data["raw_xml"] = raw
|
||||
|
||||
try:
|
||||
@@ -480,6 +499,12 @@ def images_import():
|
||||
flash("Invalid target image type.", "danger")
|
||||
return redirect(url_for("images_import"))
|
||||
|
||||
# Validate source is under an allowed USB mount path
|
||||
allowed = find_usb_mounts()
|
||||
if not any(source == m or source.startswith(m + "/") for m in allowed):
|
||||
flash("Source path is not on a mounted USB device.", "danger")
|
||||
return redirect(url_for("images_import"))
|
||||
|
||||
if not os.path.isdir(source):
|
||||
flash(f"Source path does not exist: {source}", "danger")
|
||||
return redirect(url_for("images_import"))
|
||||
@@ -497,6 +522,7 @@ def images_import():
|
||||
shutil.copytree(src_item, dst_item)
|
||||
else:
|
||||
shutil.copy2(src_item, dst_item)
|
||||
audit("IMAGE_IMPORT", f"{source} -> {target}")
|
||||
flash(
|
||||
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
|
||||
"success",
|
||||
@@ -553,6 +579,7 @@ def unattend_editor(image_type):
|
||||
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
|
||||
with open(xml_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(xml_content)
|
||||
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
|
||||
flash("unattend.xml saved successfully.", "success")
|
||||
except Exception as exc:
|
||||
flash(f"Failed to save: {exc}", "danger")
|
||||
@@ -615,6 +642,7 @@ def clonezilla_upload():
|
||||
os.makedirs(CLONEZILLA_SHARE, exist_ok=True)
|
||||
dest = os.path.join(CLONEZILLA_SHARE, filename)
|
||||
f.save(dest)
|
||||
audit("BACKUP_UPLOAD", filename)
|
||||
flash(f"Uploaded {filename} successfully.", "success")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
|
||||
@@ -635,6 +663,7 @@ def clonezilla_delete(filename):
|
||||
fpath = os.path.join(CLONEZILLA_SHARE, filename)
|
||||
if os.path.isfile(fpath):
|
||||
os.remove(fpath)
|
||||
audit("BACKUP_DELETE", filename)
|
||||
flash(f"Deleted {filename}.", "success")
|
||||
else:
|
||||
flash(f"Backup not found: {filename}", "danger")
|
||||
@@ -684,6 +713,7 @@ def blancco_delete_report(filename):
|
||||
fpath = os.path.join(BLANCCO_REPORTS, filename)
|
||||
if os.path.isfile(fpath):
|
||||
os.remove(fpath)
|
||||
audit("REPORT_DELETE", filename)
|
||||
flash(f"Deleted {filename}.", "success")
|
||||
else:
|
||||
flash(f"Report not found: {filename}", "danger")
|
||||
@@ -696,7 +726,6 @@ def blancco_delete_report(filename):
|
||||
|
||||
def _wim_extract_startnet(wim_path):
|
||||
"""Extract startnet.cmd from a WIM file using wimextract."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -707,8 +736,8 @@ def _wim_extract_startnet(wim_path):
|
||||
)
|
||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||
if result.returncode == 0 and os.path.isfile(startnet_path):
|
||||
content = open(startnet_path, "r", encoding="utf-8", errors="replace").read()
|
||||
return content
|
||||
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
return fh.read()
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -718,7 +747,6 @@ def _wim_extract_startnet(wim_path):
|
||||
|
||||
def _wim_update_startnet(wim_path, content):
|
||||
"""Update startnet.cmd inside a WIM file using wimupdate."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||
@@ -796,12 +824,33 @@ def startnet_save():
|
||||
content = request.form.get("content", "")
|
||||
ok, err = _wim_update_startnet(BOOT_WIM, content)
|
||||
if ok:
|
||||
audit("STARTNET_SAVE", "boot.wim updated")
|
||||
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
||||
else:
|
||||
flash(f"Failed to update boot.wim: {err}", "danger")
|
||||
return redirect(url_for("startnet_editor"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — Audit Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/audit")
|
||||
def audit_log():
|
||||
entries = []
|
||||
if os.path.isfile(AUDIT_LOG):
|
||||
with open(AUDIT_LOG, "r") as fh:
|
||||
for line in fh:
|
||||
entries.append(line.strip())
|
||||
entries.reverse() # newest first
|
||||
return render_template(
|
||||
"audit.html",
|
||||
entries=entries,
|
||||
image_types=IMAGE_TYPES,
|
||||
friendly_names=FRIENDLY_NAMES,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — API
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -849,6 +898,7 @@ def api_save_unattend(image_type):
|
||||
except Exception as exc:
|
||||
return jsonify({"error": f"Failed to write file: {exc}"}), 500
|
||||
|
||||
audit("UNATTEND_SAVE_API", image_type)
|
||||
return jsonify({"status": "ok", "path": xml_file})
|
||||
|
||||
|
||||
@@ -859,7 +909,6 @@ def api_save_unattend(image_type):
|
||||
@app.template_filter("timestamp_fmt")
|
||||
def timestamp_fmt(ts):
|
||||
"""Format a Unix timestamp to a human-readable date string."""
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
@@ -880,4 +929,4 @@ def inject_globals():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
|
||||
5
webapp/static/bootstrap-icons.min.css
vendored
Normal file
5
webapp/static/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
webapp/static/bootstrap.bundle.min.js
vendored
Normal file
7
webapp/static/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
webapp/static/bootstrap.min.css
vendored
Normal file
6
webapp/static/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
webapp/static/fonts/bootstrap-icons.woff
Normal file
BIN
webapp/static/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
webapp/static/fonts/bootstrap-icons.woff2
Normal file
BIN
webapp/static/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
62
webapp/templates/audit.html
Normal file
62
webapp/templates/audit.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h2>
|
||||
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="bi bi-clock-history me-2"></i> Activity History
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if entries %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td><small class="text-muted">{{ parts[0] }}</small></td>
|
||||
{% if meta|length == 2 %}
|
||||
<td><code>{{ meta[0].lstrip('[') }}</code></td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-journal-text" style="font-size: 3rem;"></i>
|
||||
<p class="mt-2">No audit log entries yet.</p>
|
||||
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -5,12 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS49cn91B2HOwP4cMpe1bBMnos9GBsYl7a"
|
||||
crossorigin="anonymous">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 280px;
|
||||
@@ -155,12 +151,18 @@
|
||||
<i class="bi bi-shield-check"></i> Blancco Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||
href="{{ url_for('audit_log') }}">
|
||||
<i class="bi bi-journal-text"></i> Audit Log
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Aerospace Images</div>
|
||||
<ul class="nav flex-column">
|
||||
{% for it in ['gea-standard', 'gea-engineer', 'gea-shopfloor'] %}
|
||||
{% for it in all_image_types if it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
@@ -173,7 +175,7 @@
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Legacy Images</div>
|
||||
<ul class="nav flex-column">
|
||||
{% for it in ['ge-standard', 'ge-engineer', 'ge-shopfloor-lockdown', 'ge-shopfloor-mce'] %}
|
||||
{% for it in all_image_types if it.startswith('ge-') and not it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
@@ -200,9 +202,7 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<h2 class="mb-1">{{ friendly_name }}</h2>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-file-earmark-code me-1"></i>
|
||||
<code>{{ image_type }}/Deploy/Control/unattend.xml</code>
|
||||
<code>{{ image_type }}/Deploy/FlatUnattendW10.xml</code>
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user