Bladeren bron

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).
maziggy 2 maanden geleden
bovenliggende
commit
1bcd5c8ba5
3 gewijzigde bestanden met toevoegingen van 206 en 0 verwijderingen
  1. 0 0
      CHANGELOG.md
  2. 86 0
      backend/app/api/routes/support.py
  3. 120 0
      backend/tests/unit/test_support_helpers.py

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 86 - 0
backend/app/api/routes/support.py

@@ -1140,6 +1140,63 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
     return content.encode("utf-8")
     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:
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
     # Collect sensitive strings from DB for redaction
@@ -1191,6 +1248,35 @@ async def generate_support_bundle(
         # Add support info JSON
         # Add support info JSON
         zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
         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
         # Add log file
         log_content = _get_log_content(sensitive_strings=sensitive_strings)
         log_content = _get_log_content(sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)
         zf.writestr("bambuddy.log", log_content)

+ 120 - 0
backend/tests/unit/test_support_helpers.py

@@ -1116,3 +1116,123 @@ class TestCollectGitHubBackupInfo:
         assert info["providers_used"] == {"github": 2, "gitea": 1}
         assert info["providers_used"] == {"github": 2, "gitea": 1}
         assert info["schedule_enabled_count"] == 2
         assert info["schedule_enabled_count"] == 2
         assert info["last_failure_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]

Some files were not shown because too many files changed in this diff