mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
feat(support): bundle redacted cached push_status per connected printer
The support bundle shipped support-info.json + bambuddy.log, but the raw
shape of the printer's MQTT push_status — the field that blocks per-model
work like AMS Backup detection (deferred in 85fbd7fc) and every vt_tray /
vir_slot / mapping shape regression — was never captured.
Each connected printer now contributes push-status/printer-{i}.json with
{model, firmware_version, captured_at, raw_data}, indexed against
support-info.json["printers"]. Two-pass redaction: a structural walk
drops user-private keys (subtask_name, gcode_file, subtask_id, task_id,
project_id, design_id, profile_id, model_id, gcode_state,
gcode_file_prepare_percent) and rewrites net.info[*].ip to 0.0.0.0
(matches the #1429 VP bridge fix); then the JSON runs through the same
DB-derived sensitive_strings sanitizer the log path uses, catching any
printer name / serial / access code / cloud email that leaked into a
nested string field.
print.cfg, print.option, ams, vt_tray, vir_slot, mapping,
ams_extruder_map, and hardware fields are all preserved — those are the
fields per-model work needs.
Always-on inside the existing debug-logging-required gate; no opt-in
toggle (the bundle is already user-initiated and downloads locally
before the user chooses to send).
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1140,6 +1140,63 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
|
||||
return content.encode("utf-8")
|
||||
|
||||
|
||||
# Top-level push_status keys that carry user-private data (filenames, BambuCloud
|
||||
# IDs). Dropped from the bundled per-printer snapshot. Keep print.cfg /
|
||||
# print.option / ams / vt_tray / vir_slot / mapping — those are the fields that
|
||||
# make the snapshot worth shipping (per-model AMS Backup detection, tray-shape
|
||||
# research, VP regression baselines).
|
||||
_RAW_DATA_DROP_KEYS = frozenset(
|
||||
{
|
||||
"subtask_name",
|
||||
"gcode_file",
|
||||
"gcode_file_prepare_percent",
|
||||
"subtask_id",
|
||||
"task_id",
|
||||
"project_id",
|
||||
"gcode_state", # not sensitive, but mirrors current_print which we strip
|
||||
"design_id",
|
||||
"profile_id",
|
||||
"model_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _redact_raw_push_status(raw: dict) -> dict:
|
||||
"""Strip user-private keys from a cached push_status snapshot.
|
||||
|
||||
Drops the keys in :data:`_RAW_DATA_DROP_KEYS` anywhere in the tree, then
|
||||
rewrites every entry under ``net.info[*].ip`` to ``"0.0.0.0"``. Mirrors the
|
||||
LAN-topology leak fixed in the virtual-printer bridge (#1429) — the same
|
||||
field exposes the printer's local IP plus the gateway/peers it sees. Returns
|
||||
a NEW dict; the live ``state.raw_data`` is never mutated.
|
||||
"""
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
def _walk(value):
|
||||
if isinstance(value, dict):
|
||||
return {k: _walk(v) for k, v in value.items() if k not in _RAW_DATA_DROP_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [_walk(v) for v in value]
|
||||
return value
|
||||
|
||||
out = _walk(raw)
|
||||
|
||||
# Scrub net.info[*].ip after the structural walk — only meaningful at the
|
||||
# top level; nested "net" blocks don't appear in Bambu push_status payloads.
|
||||
net = out.get("net")
|
||||
if isinstance(net, dict):
|
||||
info_list = net.get("info")
|
||||
if isinstance(info_list, list):
|
||||
net["info"] = [
|
||||
({**entry, "ip": "0.0.0.0"} if isinstance(entry, dict) and "ip" in entry else entry)
|
||||
for entry in info_list
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
|
||||
"""Get recent log lines, sanitized for inclusion in bug reports."""
|
||||
# Collect sensitive strings from DB for redaction
|
||||
@@ -1191,6 +1248,35 @@ async def generate_support_bundle(
|
||||
# Add support info JSON
|
||||
zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
|
||||
|
||||
# Per-printer cached push_status dump. Bambu firmware ships per-model
|
||||
# config in a different shape for every family (the bit-26 / print.cfg
|
||||
# gap that blocked AMS Backup awareness in 85fbd7fc), and shape-of-
|
||||
# vt_tray / mapping / vir_slot has bitten the VP bridge repeatedly.
|
||||
# Including the redacted snapshot turns every future support bundle
|
||||
# into a ground-truth sample for that exact model+firmware. Index
|
||||
# matches the 1-based ordering in support-info.json["printers"] so a
|
||||
# maintainer can cross-reference without re-deriving identifiers.
|
||||
statuses = printer_manager.get_all_statuses()
|
||||
async with async_session() as db:
|
||||
db_printers = (await db.execute(select(Printer))).scalars().all()
|
||||
for i, printer in enumerate(db_printers):
|
||||
state = statuses.get(printer.id)
|
||||
if state is None or not state.raw_data:
|
||||
continue
|
||||
redacted = _redact_raw_push_status(state.raw_data)
|
||||
snapshot = {
|
||||
"model": printer.model or "Unknown",
|
||||
"firmware_version": state.firmware_version,
|
||||
"captured_at": datetime.now(timezone.utc).isoformat(),
|
||||
"raw_data": redacted,
|
||||
}
|
||||
# Belt-and-suspenders: pass the JSON text through the string-based
|
||||
# sanitizer so any user-named string (printer name, serial baked
|
||||
# into a tray uuid) the structural pass missed still gets caught.
|
||||
snapshot_json = json.dumps(snapshot, indent=2, default=str)
|
||||
snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
|
||||
zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
|
||||
|
||||
# Add log file
|
||||
log_content = _get_log_content(sensitive_strings=sensitive_strings)
|
||||
zf.writestr("bambuddy.log", log_content)
|
||||
|
||||
@@ -1116,3 +1116,123 @@ class TestCollectGitHubBackupInfo:
|
||||
assert info["providers_used"] == {"github": 2, "gitea": 1}
|
||||
assert info["schedule_enabled_count"] == 2
|
||||
assert info["last_failure_count"] == 2
|
||||
|
||||
|
||||
class TestRedactRawPushStatus:
|
||||
"""Tests for _redact_raw_push_status() — the bundle dump scrubber."""
|
||||
|
||||
def test_drops_user_filename_and_cloud_ids(self):
|
||||
from backend.app.api.routes.support import _redact_raw_push_status
|
||||
|
||||
raw = {
|
||||
"subtask_name": "private_model.gcode",
|
||||
"gcode_file": "Metadata/private.gcode",
|
||||
"subtask_id": "1234567890",
|
||||
"task_id": "9999",
|
||||
"project_id": "proj-abc",
|
||||
"design_id": "design-1",
|
||||
"profile_id": "p-1",
|
||||
"model_id": "m-1",
|
||||
"gcode_state": "RUNNING",
|
||||
"layer_num": 42, # control: non-sensitive sibling must survive
|
||||
}
|
||||
|
||||
out = _redact_raw_push_status(raw)
|
||||
|
||||
assert "subtask_name" not in out
|
||||
assert "gcode_file" not in out
|
||||
assert "subtask_id" not in out
|
||||
assert "task_id" not in out
|
||||
assert "project_id" not in out
|
||||
assert "design_id" not in out
|
||||
assert "profile_id" not in out
|
||||
assert "model_id" not in out
|
||||
assert "gcode_state" not in out
|
||||
assert out["layer_num"] == 42
|
||||
|
||||
def test_redacts_net_info_ip_addresses(self):
|
||||
from backend.app.api.routes.support import _redact_raw_push_status
|
||||
|
||||
raw = {
|
||||
"net": {
|
||||
"conf": 1,
|
||||
"info": [
|
||||
{"ip": "192.168.1.42", "mask": "255.255.255.0"},
|
||||
{"ip": "10.0.0.1", "mask": "255.0.0.0"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
out = _redact_raw_push_status(raw)
|
||||
|
||||
# LAN topology must be scrubbed (mirrors the #1429 VP fix).
|
||||
assert out["net"]["info"][0]["ip"] == "0.0.0.0"
|
||||
assert out["net"]["info"][1]["ip"] == "0.0.0.0"
|
||||
# Non-IP siblings inside the entry survive so the shape stays
|
||||
# diagnosable (interface count, mask presence, etc.).
|
||||
assert out["net"]["info"][0]["mask"] == "255.255.255.0"
|
||||
assert out["net"]["conf"] == 1
|
||||
|
||||
def test_preserves_print_cfg_and_ams_payloads(self):
|
||||
"""The point of bundling raw_data is keeping these — print.cfg is what
|
||||
unblocks per-model AMS Backup detection (deferred in 85fbd7fc).
|
||||
"""
|
||||
from backend.app.api.routes.support import _redact_raw_push_status
|
||||
|
||||
raw = {
|
||||
"print": {
|
||||
"cfg": 0x4000000, # bit-26 — the H2D AMS Backup bit
|
||||
"option": 12345,
|
||||
},
|
||||
"ams": {
|
||||
"ams": [
|
||||
{
|
||||
"id": "0",
|
||||
"humidity": "3",
|
||||
"tray": [
|
||||
{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
"vt_tray": {"tray_info_idx": "GFA00", "tray_type": "PLA", "tray_color": "00FF00FF"},
|
||||
"vir_slot": [{"id": "0", "tray_type": "PLA"}],
|
||||
"mapping": [0, 1, 2, 3],
|
||||
"ams_extruder_map": {"0": 1},
|
||||
}
|
||||
|
||||
out = _redact_raw_push_status(raw)
|
||||
|
||||
assert out["print"]["cfg"] == 0x4000000
|
||||
assert out["print"]["option"] == 12345
|
||||
assert out["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
|
||||
assert out["vt_tray"]["tray_info_idx"] == "GFA00"
|
||||
assert out["vir_slot"][0]["tray_type"] == "PLA"
|
||||
assert out["mapping"] == [0, 1, 2, 3]
|
||||
assert out["ams_extruder_map"] == {"0": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
"""Live state.raw_data must not be touched — the dispatcher reads it on
|
||||
every tick, mutation would race the next push.
|
||||
"""
|
||||
from backend.app.api.routes.support import _redact_raw_push_status
|
||||
|
||||
raw = {
|
||||
"subtask_name": "secret.gcode",
|
||||
"net": {"info": [{"ip": "192.168.1.5"}]},
|
||||
"print": {"cfg": 1},
|
||||
}
|
||||
original_subtask = raw["subtask_name"]
|
||||
original_ip = raw["net"]["info"][0]["ip"]
|
||||
|
||||
_redact_raw_push_status(raw)
|
||||
|
||||
assert raw["subtask_name"] == original_subtask
|
||||
assert raw["net"]["info"][0]["ip"] == original_ip
|
||||
|
||||
def test_handles_non_dict_gracefully(self):
|
||||
from backend.app.api.routes.support import _redact_raw_push_status
|
||||
|
||||
assert _redact_raw_push_status(None) == {} # type: ignore[arg-type]
|
||||
assert _redact_raw_push_status([]) == {} # type: ignore[arg-type]
|
||||
assert _redact_raw_push_status("") == {} # type: ignore[arg-type]
|
||||
|
||||
Reference in New Issue
Block a user