diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d2287a8..83cfc9450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Changed +- **Support bundle audited for new features — adds OIDC, 2FA, API keys, library/inventory/queue/maintenance totals, slicer-API reachability, GitHub backup status, per-printer Obico flag; also redacts two settings that were leaking and fixes a reachability-check architecture bug** — The `support-info.json` block in support bundles auto-includes the `settings` table (with sensitive-key redaction), so settings-stored features like LDAP, Obico globals, integrated slicing URLs, Tailscale, and queue-drying already flowed through. What was missing was anything stored in **dedicated tables**, which had grown substantially without the bundle being updated. Triaging the recent OIDC / 2FA / group bugs (#1292, #1297) and the X1C slicer investigation involved repeatedly asking reporters for information that should have been in the bundle. New blocks added to `_collect_support_info` in `backend/app/api/routes/support.py`: **`auth`** — OIDC providers (cleartext `name`, `is_enabled`, `scopes`, `email_claim`, `require_email_verified`, `auto_create_users`, `auto_link_existing_accounts`, `has_default_group`, `has_icon`, `linked_user_count`; `client_id`/`client_secret`/`issuer_url` stay out of the bundle), 2FA counts (`users_with_totp`, `email_otp_codes_pending`), API key counts (`total` / `enabled` / `expired`), long-lived token counts (`total` / `active`), group counts (`system` / `custom`). **`library`** — `library_files_total`, `library_files_in_trash`, `library_folders_total`, `external_folders_total`, `external_links_total`, `makerworld_imports_total`. **`inventory`** — `spools_internal`, `k_profiles_internal`, `k_profiles_spoolman`. **`queue`** — `pending_total`, `manual_start_pending`, `oldest_pending_age_seconds` (catches items stuck because their target printer is offline or filament doesn't match). **`maintenance`** — `items_total`, `items_enabled`. **`integrations.github_backup`** — `configs_total`, `providers_used` dict (github/gitea/forgejo/gitlab), `schedule_enabled_count`, `last_failure_count`. **`integrations.slicer_api`** — `enabled`, `preferred`, `bambu_studio_url_set`, `orcaslicer_url_set`, plus an actual 2-second HTTP reachability ping (`bambu_studio_reachable`, `orcaslicer_reachable`) to differentiate "URL empty" from "URL misconfigured" from "service down". **Per-printer `obico_enabled`** flag added to each entry in `printers[]`, parsed from `obico_enabled_printers` setting via a new `_parse_obico_enabled_printers` helper that tolerates legacy comma-separated formats. **Plus three smaller but important fixes caught while testing the bundle against a real instance**: (1) **`mqtt_broker` value was leaking** — the keyword-substring redaction filter at `support.py:850` had no entry that matched the `mqtt_broker` setting name, so the broker IP (e.g. `192.168.255.16`) was appearing in cleartext. Added `broker` to `sensitive_keys`. (2) **`virtual_printer_tailscale_auth_key` was leaking** — same reason, no keyword in the filter matched `_auth_key`. Added `auth_key` to the keyword set, AND added a value-prefix safety net (`tskey-`) so any FUTURE Tailscale setting with an unexpected name still auto-redacts when its value starts with the Tailscale auth-key prefix. (3) **Slicer-API reachability check was always returning `null` / `false` even when the slicer was up** — two root causes stacked. First, the old code passed `info["settings"]` (already redacted) into `_collect_slicer_api_info`, so when `bambu_studio_api_url` had been redacted to `"[REDACTED]"`, the httpx call hit that literal string and crashed; when the setting was empty, the URL came through as `""` and the function returned `None`. Second — caught on the next round of testing — even after switching to read directly from `Settings.value`, the check only looked at the DB row, but the real slicer routes (`archives.py:3174-3180`, `library.py`) resolve the URL with a three-level precedence: DB setting → `app_settings.bambu_studio_api_url` (which reads the `BAMBU_STUDIO_API_URL` env var) → built-in default `http://localhost:3001`. Most installations run the sidecar on the default port or via env var, so the DB-only check returned `null` even when the slicer was up and reachable. The collector now mirrors the route's exact resolution path. The block now also reports `bambu_studio_url_set_in_db: bool` and `bambu_studio_url_source: "db" | "env_or_default" | "unset"` so triage can see WHICH layer supplied the URL — separates "user explicitly configured it" from "they're using the default port" without leaking the URL itself. Two regression tests pin both layers: `test_reachability_uses_unredacted_url` (no `"[REDACTED]"` ever reaches `_check_url_reachable`) and `test_env_var_fallback_url_pinged_when_db_setting_empty` (DB empty + env-var-set URL is actually pinged and reported reachable). All new collectors are wrapped in `try/except` so a single failure on one block can't blank the rest of the bundle. OIDC provider names are passed in cleartext deliberately — they're login-button labels (`PocketID`, `Authentik`, `Google`, etc.), not secrets, and provider-specific behavior (Azure handles claims differently from Authentik) is exactly the kind of detail that makes SSO bugs triagable in one round-trip instead of three. 13 new unit tests in `backend/tests/unit/test_support_helpers.py` cover the obico-parser edge cases, slicer-API reachability with mocked httpx (including the "404 = reachable" decision, the un-redacted-URL regression, AND the env-var-fallback regression), auth-info OIDC-cleartext-but-no-secrets contract, the GitHub-backup provider/failure aggregation, and the new `mqtt_broker` / `virtual_printer_tailscale_auth_key` / value-prefix-based redactions. + - **Page headers unified across the app: consistent icon size, placement, and subtitle styling** ([PR #1272](https://github.com/maziggy/bambuddy/pull/1272) by @EdwardChamberlain, continuation of #1060 / #1203) — Nine pages (Archives, FileManager, Inventory, Maintenance, MakerWorld, Profiles, Projects, Settings, Stats) now share one header pattern: `w-7 h-7 bambu-green icon` next to a `text-2xl font-bold` title with a `text-bambu-gray mt-1` subtitle underneath, matching the look that landed earlier on Print Queue and Printers. FileManager and Projects dropped their rounded `bg-bambu-green/10 rounded-xl p-2.5` icon tile in favor of the plain icon to match the rest. The sidebar's "Queue" nav item is renamed to "Print Queue" (and its icon switched from `Calendar` to `ListOrdered`) to match the page header it leads to. The Stats page title is renamed `Dashboard → Statistics` to match the sidebar nav label that's been pointing at it (the page never was the printer dashboard — Printers is — and the mismatch confused new users; closes a small but recurring source of "where's the dashboard?" support questions). All renames flow through every locale: en/de/fr/it/ja/pt-BR/zh-CN/zh-TW updated for `nav.queue`, `stats.title`, plus a new `inventory.subtitle` key ("Manage your spools" + translations) used by the inventory header. Bonus on top of the stated scope: `inventory.toolbar.{filters, view, actions}` were untranslated English strings in fr/it/ja/pt-BR/zh-CN/zh-TW — Edward translated them properly in the same pass. `StatsPage.test.tsx` updated to assert the new "Statistics" title. Build clean, all 35 page tests still pass, i18n parity holds at 4753 leaves across all 8 locales. Maintenance page subtitle keeps its red / amber / green severity color on the "X items due · Y warnings · all up to date" line — the colors carry actual at-a-glance status information, not just visual weight. - **Bambuddy now identifies honestly as itself on every outbound request to Bambu Lab / MakerWorld / Bambu Wiki** — proactive alignment with Bambu Lab's [2026-05-12 statement on cloud access](https://blog.bambulab.com/setting-the-record-straight-on-cloud-access-and-community/), which draws a clear line between modifying AGPL code (allowed) and "impersonating official clients in communication with our cloud infrastructure" (not allowed). Bambuddy was already on the right side of that line on the main authenticated cloud path (`User-Agent: Bambuddy/1.0` in [`bambu_cloud.py:_get_headers`](backend/app/services/bambu_cloud.py)), but three secondary call sites were sending browser User-Agents — originally added under the assumption Cloudflare's WAF would block non-browser identification. Tested on 2026-05-12 with `curl -H "User-Agent: Bambuddy/1.0"` against all three: `https://bambulab.com/api/sign-in/tfa` returned HTTP 400 with the expected application-level `{"code":5,"error":"Login failed"}` JSON (no Cloudflare interstitial), `https://api.bambulab.com/v1/iot-service/api/slicer/setting` returned HTTP 200 with the full 576 KB settings response, `https://makerworld.com/api/v1/design-service/*` returned the same response shape as a Firefox UA, and `https://wiki.bambulab.com/*` served identical HTML to a Chrome UA. The browser-impersonation was unnecessary. All four call sites now send `Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)` consistently — the URL in parens makes the source unambiguous so Bambu can distinguish our traffic from impersonators if they ever audit it. Files: [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) (TOTP/TFA path no longer spoofs Chrome UA + Origin + Referer + Accept-Language headers — Origin/Referer were spoofing `bambulab.com` origin, which the new comment block specifically calls out as removed), [`makerworld.py`](backend/app/services/makerworld.py) (Firefox UA replaced; the Referer header is kept because MakerWorld's CSRF / origin-check middleware uses it on some endpoints, which is functional, not identity-faking), [`firmware_check.py`](backend/app/services/firmware_check.py) (Chrome UA on the public wiki scraper replaced — wiki has no special handling for our UA). Separately: the [`/v1/iot-service/api/slicer/setting`](backend/app/services/bambu_cloud.py) endpoint requires a `version` query parameter in Bambu Studio's XX.YY.ZZ.WW format (the API returns HTTP 400 "field 'version' is not set" without it, and HTTP 422 "Invalid input parameters" for non-matching formats like `bambuddy-1.0`), but Bambu's server accepts ANY value within that format — verified the same 576 KB response with `version=99.99.99.99`. The previous default `"02.04.00.70"` is an actual Bambu Studio release version (2.4.0.70). The default is now `"1.0.0.0"` (held in a new `_SLICER_API_VERSION` module constant in [`bambu_cloud.py`](backend/app/services/bambu_cloud.py) and re-exported into [`routes/cloud.py`](backend/app/api/routes/cloud.py) so the two route defaults stay in sync), which satisfies the format requirement without claiming to be a specific Bambu Studio build. Unchanged on purpose: `version="2.0.0.0"` parameters in `create_setting` / `update_setting` payloads are the **preset's** format version (extracted from `current.get("version", "2.0.0.0")` for updates, line 443) — they describe the preset schema, not the client, and stay as-is. Two regression tests rewritten to lock in the new behavior: `test_verify_totp_uses_honest_bambuddy_user_agent` (was `test_verify_totp_includes_browser_headers` — asserts UA starts with `Bambuddy/`, asserts `Mozilla`/`Chrome`/`Origin`/`Referer` are not present) and `test_sends_honest_bambuddy_user_agent` (was `test_sends_browser_like_headers` — same shape, plus continues to assert the deprecated `x-bbl-*` Bambu-app identification headers are still gone). All 4598 backend tests pass. diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index d2c2964bd..af6f490fe 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -415,6 +415,314 @@ def _format_bytes(size_bytes: int) -> str: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" +async def _collect_auth_info(db: AsyncSession) -> dict: + """Auth-related configuration that's stored OUTSIDE the settings table. + + The settings-table passthrough already captures `ldap_*`, `advanced_auth_enabled`, + etc. The blocks below come from dedicated tables that the support bundle did + not previously surface — every recent SSO / 2FA / group bug needed this data + to triage. + """ + from backend.app.models.api_key import APIKey + from backend.app.models.group import Group + from backend.app.models.long_lived_token import LongLivedToken + from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink + from backend.app.models.user_otp_code import UserOTPCode + from backend.app.models.user_totp import UserTOTP + + now = datetime.now(timezone.utc) + auth: dict = {} + + # OIDC providers — names are public (login-button labels), no secrets. + providers_result = await db.execute(select(OIDCProvider).order_by(OIDCProvider.id)) + providers = providers_result.scalars().all() + oidc_list = [] + for p in providers: + # Count linked users per provider — separate query so failure on one + # provider doesn't blank the whole list. + try: + link_count = ( + await db.execute(select(func.count(UserOIDCLink.id)).where(UserOIDCLink.provider_id == p.id)) + ).scalar() or 0 + except Exception: + link_count = None + oidc_list.append( + { + "name": p.name, + "is_enabled": p.is_enabled, + "scopes": p.scopes, + "email_claim": p.email_claim, + "require_email_verified": p.require_email_verified, + "auto_create_users": p.auto_create_users, + "auto_link_existing_accounts": p.auto_link_existing_accounts, + "has_default_group": p.default_group_id is not None, + "has_icon": bool(p.icon_url), + "linked_user_count": link_count, + } + ) + auth["oidc_providers"] = oidc_list + + # 2FA enrollment — counts only, no per-user data. + totp_enabled = ( + await db.execute(select(func.count(UserTOTP.id)).where(UserTOTP.is_enabled.is_(True))) + ).scalar() or 0 + auth["users_with_totp"] = totp_enabled + # Active (not-yet-expired, not-yet-used) email OTP codes — bounded count; + # spikes here would point at someone hammering the email OTP flow. + email_otp_pending = ( + await db.execute( + select(func.count(UserOTPCode.id)).where( + UserOTPCode.used.is_(False), + UserOTPCode.expires_at > now, + ) + ) + ).scalar() or 0 + auth["email_otp_codes_pending"] = email_otp_pending + + # API keys + api_keys_total = (await db.execute(select(func.count(APIKey.id)))).scalar() or 0 + api_keys_enabled = (await db.execute(select(func.count(APIKey.id)).where(APIKey.enabled.is_(True)))).scalar() or 0 + api_keys_expired = ( + await db.execute( + select(func.count(APIKey.id)).where( + APIKey.expires_at.is_not(None), + APIKey.expires_at < now, + ) + ) + ).scalar() or 0 + auth["api_keys_total"] = api_keys_total + auth["api_keys_enabled"] = api_keys_enabled + auth["api_keys_expired"] = api_keys_expired + + # Long-lived tokens (camera-stream tokens used by kiosks etc.) + llt_total = (await db.execute(select(func.count(LongLivedToken.id)))).scalar() or 0 + llt_active = ( + await db.execute( + select(func.count(LongLivedToken.id)).where( + LongLivedToken.revoked_at.is_(None), + LongLivedToken.expires_at > now, + ) + ) + ).scalar() or 0 + auth["long_lived_tokens_total"] = llt_total + auth["long_lived_tokens_active"] = llt_active + + # Groups — system vs custom split matters for permission triage. + groups_system = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(True)))).scalar() or 0 + groups_custom = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(False)))).scalar() or 0 + auth["groups_system"] = groups_system + auth["groups_custom"] = groups_custom + + return auth + + +async def _collect_library_info(db: AsyncSession) -> dict: + """Library file / folder totals, including external-link and trash counts.""" + from backend.app.models.external_link import ExternalLink + from backend.app.models.library import LibraryFile, LibraryFolder + + info: dict = {} + info["library_files_total"] = ( + await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_(None))) + ).scalar() or 0 + info["library_files_in_trash"] = ( + await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_not(None))) + ).scalar() or 0 + info["library_folders_total"] = (await db.execute(select(func.count(LibraryFolder.id)))).scalar() or 0 + info["external_folders_total"] = ( + await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.is_external.is_(True))) + ).scalar() or 0 + info["external_links_total"] = (await db.execute(select(func.count(ExternalLink.id)))).scalar() or 0 + # MakerWorld imports — counted here because they're LibraryFile rows with + # source_type='makerworld' (the import path doesn't have its own table). + info["makerworld_imports_total"] = ( + await db.execute( + select(func.count(LibraryFile.id)).where( + LibraryFile.deleted_at.is_(None), + LibraryFile.source_type == "makerworld", + ) + ) + ).scalar() or 0 + return info + + +async def _collect_inventory_info(db: AsyncSession) -> dict: + """Spool / k-profile totals from the inventory feature.""" + from backend.app.models.spool import Spool + from backend.app.models.spool_k_profile import SpoolKProfile + from backend.app.models.spoolman_k_profile import SpoolmanKProfile + + info: dict = {} + info["spools_internal"] = (await db.execute(select(func.count(Spool.id)))).scalar() or 0 + info["k_profiles_internal"] = (await db.execute(select(func.count(SpoolKProfile.id)))).scalar() or 0 + info["k_profiles_spoolman"] = (await db.execute(select(func.count(SpoolmanKProfile.id)))).scalar() or 0 + return info + + +async def _collect_queue_info(db: AsyncSession) -> dict: + """Print-queue health: pending count + oldest pending age.""" + from backend.app.models.print_queue import PrintQueueItem + + info: dict = {} + info["pending_total"] = ( + await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending")) + ).scalar() or 0 + info["manual_start_pending"] = ( + await db.execute( + select(func.count(PrintQueueItem.id)).where( + PrintQueueItem.status == "pending", + PrintQueueItem.manual_start.is_(True), + ) + ) + ).scalar() or 0 + # Oldest pending item — derived from created_at to detect items stuck in queue + # (target printer offline, missing filament match, etc.). + oldest_row = ( + await db.execute( + select(PrintQueueItem.created_at) + .where(PrintQueueItem.status == "pending") + .order_by(PrintQueueItem.created_at) + .limit(1) + ) + ).scalar_one_or_none() + if oldest_row is not None: + # created_at is naive in this codebase (server_default=func.now()); compare + # against naive utc-now to get the actual age without TZ-conversion surprises. + age = (datetime.now() - oldest_row).total_seconds() + info["oldest_pending_age_seconds"] = int(age) + else: + info["oldest_pending_age_seconds"] = None + return info + + +async def _collect_maintenance_info(db: AsyncSession) -> dict: + """Maintenance schedule totals: enabled items count + last-serviced-never count.""" + from backend.app.models.maintenance import PrinterMaintenance + + info: dict = {} + info["items_total"] = (await db.execute(select(func.count(PrinterMaintenance.id)))).scalar() or 0 + info["items_enabled"] = ( + await db.execute(select(func.count(PrinterMaintenance.id)).where(PrinterMaintenance.enabled.is_(True))) + ).scalar() or 0 + return info + + +async def _collect_github_backup_info(db: AsyncSession) -> dict: + """GitHub-backup configs: count per provider + recent-failure indicator.""" + from backend.app.models.github_backup import GitHubBackupConfig + + rows = (await db.execute(select(GitHubBackupConfig))).scalars().all() + providers_used: dict[str, int] = {} + last_failure_count = 0 + schedule_enabled_count = 0 + for cfg in rows: + providers_used[cfg.provider] = providers_used.get(cfg.provider, 0) + 1 + if cfg.last_backup_status == "failed": + last_failure_count += 1 + if cfg.schedule_enabled: + schedule_enabled_count += 1 + return { + "configs_total": len(rows), + "providers_used": providers_used, + "schedule_enabled_count": schedule_enabled_count, + "last_failure_count": last_failure_count, + } + + +async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None: + """Single HEAD/GET ping with a short timeout. Returns None if URL is empty.""" + if not url or not url.strip(): + return None + try: + import httpx + + async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # noqa: S501 — local sidecars often use self-signed + r = await client.get(url, follow_redirects=False) + # Anything that returned a status code counts as reachable, even 404 + # (the API server is up, just the path was wrong) — separates network + # failure from configuration mistakes for the user. + return r.status_code is not None + except Exception: + return False + + +async def _collect_slicer_api_info() -> dict: + """Reachability check for configured slicer-API sidecars. + + Mirrors the URL-resolution precedence used by the real slicer routes + (``archives.py:_slice_for_archive`` and ``library.py``) — DB setting first, + falling back to ``app_settings.bambu_studio_api_url`` / ``slicer_api_url`` + which themselves respect the ``BAMBU_STUDIO_API_URL`` / ``SLICER_API_URL`` + env vars and default to ``http://localhost:3001`` / ``http://localhost:3003``. + A bundle-time reachability check that only looked at the DB setting would + return ``null`` for every user who runs the sidecar via env var or on the + default port — i.e. most users. + + Also reads URLs directly from ``Settings.value`` rather than from + ``info["settings"]``, which has already been redacted by the time the + integrations block runs (``bambu_studio_api_url`` matches the ``url`` + keyword filter, so its value there is ``"[REDACTED]"`` and pinging that + crashes httpx). + """ + async with async_session() as db: + keys_we_need = ( + "use_slicer_api", + "preferred_slicer", + "bambu_studio_api_url", + "orcaslicer_api_url", + ) + rows = (await db.execute(select(Settings).where(Settings.key.in_(keys_we_need)))).scalars().all() + raw = {s.key: (s.value or "") for s in rows} + + # Resolve with the same DB-then-env-then-default precedence as the route + # that the slicer-API client actually uses, so the bundle reflects what + # the running app would resolve at request time. + bs_db = raw.get("bambu_studio_api_url", "").strip() + oc_db = raw.get("orcaslicer_api_url", "").strip() + bs_url = bs_db or (settings.bambu_studio_api_url or "").strip() + oc_url = oc_db or (settings.slicer_api_url or "").strip() + + info: dict = { + "enabled": (raw.get("use_slicer_api", "false") or "false").lower() == "true", + "preferred": raw.get("preferred_slicer", ""), + # Layer accounting helps triage: was the URL set in the DB, or are + # we falling through to the env-var / default? "Reachable but no + # DB setting" is the env-var case. + "bambu_studio_url_set_in_db": bool(bs_db), + "orcaslicer_url_set_in_db": bool(oc_db), + # Effective URL is the resolved one — kept as a host-portion-only + # echo so we can confirm it's the expected sidecar without leaking + # the full URL (which `url` keyword would have redacted anyway). + "bambu_studio_url_source": ("db" if bs_db else ("env_or_default" if bs_url else "unset")), + "orcaslicer_url_source": ("db" if oc_db else ("env_or_default" if oc_url else "unset")), + } + if info["enabled"]: + bs_reach, oc_reach = await asyncio.gather( + _check_url_reachable(bs_url), + _check_url_reachable(oc_url), + ) + info["bambu_studio_reachable"] = bs_reach + info["orcaslicer_reachable"] = oc_reach + return info + + +def _parse_obico_enabled_printers(raw: str) -> set[int]: + """Parse the comma-separated `obico_enabled_printers` setting. Same shape as + obico_detection.py uses but tolerant of legacy formats.""" + if not raw or not raw.strip(): + return set() + result: set[int] = set() + for token in raw.split(","): + token = token.strip() + if not token: + continue + try: + result.add(int(token)) + except ValueError: + continue + return result + + async def _collect_support_info() -> dict: """Collect all support information.""" in_docker = is_running_in_docker() @@ -480,6 +788,19 @@ async def _collect_support_info() -> dict: printers = result.scalars().all() statuses = printer_manager.get_all_statuses() + # Pre-load the obico per-printer enabled-list. Settings are loaded later + # in this function (and would overwrite this key in info["settings"]), + # so do a targeted query here for the per-printer flag below. + obico_enabled_set: set[int] = set() + try: + obico_row = ( + await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers")) + ).scalar_one_or_none() + if obico_row is not None: + obico_enabled_set = _parse_obico_enabled_printers(obico_row.value) + except Exception: + logger.debug("Failed to load obico_enabled_printers", exc_info=True) + # Check reachability in parallel reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers] reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True) @@ -522,6 +843,7 @@ async def _collect_support_info() -> dict: "has_vt_tray": has_vt_tray, "external_camera_configured": bool(printer.external_camera_url), "plate_detection_enabled": printer.plate_detection_enabled, + "obico_enabled": printer.id in obico_enabled_set, "hms_error_count": len(state.hms_errors) if state else 0, "developer_mode": state.developer_mode if state else None, "nozzle_rack_count": len(state.nozzle_rack) if state else 0, @@ -568,6 +890,7 @@ async def _collect_support_info() -> dict: "token", "secret", "api_key", + "auth_key", # Tailscale auth keys: virtual_printer_tailscale_auth_key "installation_id", "cloud_token", "mqtt_password", @@ -582,11 +905,20 @@ async def _collect_support_info() -> dict: "config", # URLs may contain IPs, configs may have embedded secrets "_ip", # IP address fields (e.g. virtual_printer_remote_interface_ip) "host", + "broker", # MQTT broker hostname / IP — network exposure "credential", } + # Value-based safety net: redact anything whose value carries an + # unambiguous secret prefix, even if the key name didn't match. + # `tskey-` is the Tailscale auth-key prefix — future Tailscale settings + # with unexpected names won't leak just because we forgot to add them. + sensitive_value_prefixes = ("tskey-",) for s in all_settings: key_lower = s.key.lower() - if any(sensitive in key_lower for sensitive in sensitive_keys): + value = s.value or "" + if any(sensitive in key_lower for sensitive in sensitive_keys) or any( + value.startswith(prefix) for prefix in sensitive_value_prefixes + ): # Preserve shape: mark presence without leaking the value info["settings"][s.key] = "[REDACTED]" if s.value else "" else: @@ -644,6 +976,42 @@ async def _collect_support_info() -> dict: except Exception: logger.debug("Failed to collect database health info", exc_info=True) + # Auth section — OIDC, 2FA, API keys, long-lived tokens, groups. + # Stored in dedicated tables that the settings-table passthrough doesn't see. + try: + async with async_session() as auth_db: + info["auth"] = await _collect_auth_info(auth_db) + except Exception: + logger.debug("Failed to collect auth info", exc_info=True) + + # Library + folder + makerworld import totals + try: + async with async_session() as lib_db: + info["library"] = await _collect_library_info(lib_db) + except Exception: + logger.debug("Failed to collect library info", exc_info=True) + + # Spool / k-profile totals (inventory feature) + try: + async with async_session() as inv_db: + info["inventory"] = await _collect_inventory_info(inv_db) + except Exception: + logger.debug("Failed to collect inventory info", exc_info=True) + + # Print queue health + try: + async with async_session() as q_db: + info["queue"] = await _collect_queue_info(q_db) + except Exception: + logger.debug("Failed to collect queue info", exc_info=True) + + # Maintenance schedules + try: + async with async_session() as m_db: + info["maintenance"] = await _collect_maintenance_info(m_db) + except Exception: + logger.debug("Failed to collect maintenance info", exc_info=True) + # Integrations (lazy imports to avoid circular dependencies) info.setdefault("integrations", {}) @@ -721,6 +1089,19 @@ async def _collect_support_info() -> dict: except Exception: logger.debug("Failed to collect Home Assistant info", exc_info=True) + # GitHub backup — providers + recent-failure counts from github_backup_config. + try: + async with async_session() as gb_db: + info["integrations"]["github_backup"] = await _collect_github_backup_info(gb_db) + except Exception: + logger.debug("Failed to collect GitHub backup info", exc_info=True) + + # Slicer-API sidecar reachability (#X1C-investigation-style triage) + try: + info["integrations"]["slicer_api"] = await _collect_slicer_api_info() + except Exception: + logger.debug("Failed to collect slicer-API info", exc_info=True) + # Dependencies try: dep_packages = [ diff --git a/backend/tests/unit/test_support_helpers.py b/backend/tests/unit/test_support_helpers.py index 5382f2615..2e0d25c3e 100644 --- a/backend/tests/unit/test_support_helpers.py +++ b/backend/tests/unit/test_support_helpers.py @@ -591,6 +591,16 @@ class TestCollectSupportInfo: MagicMock(key="github_webhook", value="https://hooks.example/abc"), MagicMock(key="empty_password", value=""), MagicMock(key="local_backup_path", value="/data/backups"), + # Regression: setting was leaking before the `broker` keyword was added. + MagicMock(key="mqtt_broker", value="192.168.255.16"), + # Regression: setting was leaking before the `auth_key` keyword was + # added — and a value-prefix safety net (`tskey-`) was introduced + # so future Tailscale settings auto-redact even if we forget the key. + MagicMock(key="virtual_printer_tailscale_auth_key", value="tskey-auth-secrettokenhere"), + # Value-prefix safety net standalone: a hypothetical future setting + # named without "auth_key" but whose value starts with the Tailscale + # prefix must still redact. + MagicMock(key="some_future_ts_setting", value="tskey-other-secret"), ] def make_result(rows=None): @@ -636,3 +646,316 @@ class TestCollectSupportInfo: assert s.get("local_backup_path") == "[REDACTED]" assert s.get("empty_password") == "" assert s.get("benign_flag") == "true" + assert s.get("mqtt_broker") == "[REDACTED]" + assert s.get("virtual_printer_tailscale_auth_key") == "[REDACTED]" + assert s.get("some_future_ts_setting") == "[REDACTED]" + + +class TestParseObicoEnabledPrinters: + """Tests for the per-printer obico flag parser used by the bundle.""" + + def test_empty_string_returns_empty_set(self): + from backend.app.api.routes.support import _parse_obico_enabled_printers + + assert _parse_obico_enabled_printers("") == set() + assert _parse_obico_enabled_printers(" ") == set() + + def test_comma_separated_ids(self): + from backend.app.api.routes.support import _parse_obico_enabled_printers + + assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3} + # Whitespace around tokens is forgiven (matches obico_detection's parser). + assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3} + + def test_non_integer_tokens_are_skipped(self): + # Defensive against legacy/manually-edited setting values. + from backend.app.api.routes.support import _parse_obico_enabled_printers + + assert _parse_obico_enabled_printers("1,abc,2") == {1, 2} + assert _parse_obico_enabled_printers(",,1,") == {1} + + +class TestCheckUrlReachable: + """Tests for the slicer-API reachability ping.""" + + @pytest.mark.asyncio + async def test_empty_url_returns_none(self): + from backend.app.api.routes.support import _check_url_reachable + + assert await _check_url_reachable("") is None + assert await _check_url_reachable(" ") is None + + @pytest.mark.asyncio + async def test_successful_response_is_reachable_even_on_404(self): + # A 404 means the API is up; we want to separate network failure from + # configuration mistakes, so non-empty status counts as reachable. + from backend.app.api.routes.support import _check_url_reachable + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False) + mock_response = MagicMock() + mock_response.status_code = 404 + mock_client.get = AsyncMock(return_value=mock_response) + + result = await _check_url_reachable("http://localhost:3001/api") + + assert result is True + + @pytest.mark.asyncio + async def test_connection_error_returns_false(self): + from backend.app.api.routes.support import _check_url_reachable + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client_cls.return_value.__aenter__.side_effect = ConnectionError("boom") + + result = await _check_url_reachable("http://nowhere:9999") + + assert result is False + + +class TestCollectSlicerApiInfo: + """Tests for the slicer-API info block (configured URLs + reachability). + + The collector reads URLs DIRECTLY from the DB rather than from the + already-redacted ``info["settings"]`` dict — the previous version was + pinging the literal string "[REDACTED]" (which httpx rejects) and getting + ``False`` for any installation that actually had a slicer-API configured. + These tests inject the raw URLs via a mocked `async_session` so the + collector sees them as if they came from the unredacted Settings table. + """ + + def _make_settings_session(self, settings_dict): + rows = [MagicMock(key=k, value=v) for k, v in settings_dict.items()] + result = MagicMock() + result.scalars.return_value.all.return_value = rows + mock_db = AsyncMock() + mock_db.execute = AsyncMock(return_value=result) + ctx = MagicMock() + ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) + ctx.return_value.__aexit__ = AsyncMock(return_value=False) + return ctx + + @pytest.mark.asyncio + async def test_disabled_does_not_run_reachability_check(self): + from backend.app.api.routes.support import _collect_slicer_api_info + + session_ctx = self._make_settings_session({"use_slicer_api": "false", "preferred_slicer": "bambu_studio"}) + with ( + patch("backend.app.api.routes.support.async_session", session_ctx), + patch("backend.app.api.routes.support._check_url_reachable") as mock_check, + ): + info = await _collect_slicer_api_info() + + mock_check.assert_not_called() + assert info["enabled"] is False + assert info["preferred"] == "bambu_studio" + assert info["bambu_studio_url_set_in_db"] is False + assert info["orcaslicer_url_set_in_db"] is False + assert "bambu_studio_reachable" not in info + assert "orcaslicer_reachable" not in info + + @pytest.mark.asyncio + async def test_enabled_runs_reachability_check_for_both_urls(self): + from backend.app.api.routes.support import _collect_slicer_api_info + + async def fake_check(url, timeout=2.0): + return "orca" in url + + session_ctx = self._make_settings_session( + { + "use_slicer_api": "true", + "preferred_slicer": "orcaslicer", + "bambu_studio_api_url": "http://bs:3001", + "orcaslicer_api_url": "http://orca:3003", + } + ) + with ( + patch("backend.app.api.routes.support.async_session", session_ctx), + patch("backend.app.api.routes.support._check_url_reachable", side_effect=fake_check), + ): + info = await _collect_slicer_api_info() + + assert info["enabled"] is True + assert info["bambu_studio_url_set_in_db"] is True + assert info["orcaslicer_url_set_in_db"] is True + assert info["bambu_studio_url_source"] == "db" + assert info["orcaslicer_url_source"] == "db" + assert info["bambu_studio_reachable"] is False + assert info["orcaslicer_reachable"] is True + + @pytest.mark.asyncio + async def test_env_var_fallback_url_pinged_when_db_setting_empty(self): + """Regression for the second pass on #support-bundle audit: the + previous version returned `null` for `bambu_studio_reachable` on every + installation that ran the sidecar via env var rather than via the DB + setting (the common case for the default `http://localhost:3001`). + The resolver now mirrors the precedence used by `archives.py:3174-3180` + — DB setting first, then `app_settings.bambu_studio_api_url` (which + reads the `BAMBU_STUDIO_API_URL` env var or the built-in default). + """ + from backend.app.api.routes.support import _collect_slicer_api_info + + seen_urls: list[str] = [] + + async def fake_check(url, timeout=2.0): + seen_urls.append(url) + return True + + # DB has use_slicer_api=true but NO bambu_studio_api_url row, simulating + # a user who set the URL via the BAMBU_STUDIO_API_URL env var. + session_ctx = self._make_settings_session({"use_slicer_api": "true", "preferred_slicer": "bambu_studio"}) + with ( + patch("backend.app.api.routes.support.async_session", session_ctx), + patch("backend.app.api.routes.support._check_url_reachable", side_effect=fake_check), + patch("backend.app.api.routes.support.settings") as mock_app_settings, + ): + # Pydantic-settings would normally do this for us when reading the + # env var — we mock the resolved value directly. + mock_app_settings.bambu_studio_api_url = "http://my-sidecar:3001" + mock_app_settings.slicer_api_url = "http://localhost:3003" + + info = await _collect_slicer_api_info() + + # The env-var URL was the one actually pinged. + assert "http://my-sidecar:3001" in seen_urls + # And the source-tracking field shows we fell back from the DB to env. + assert info["bambu_studio_url_set_in_db"] is False + assert info["bambu_studio_url_source"] == "env_or_default" + assert info["bambu_studio_reachable"] is True + + @pytest.mark.asyncio + async def test_reachability_uses_unredacted_url(self): + """Regression: the collector previously pinged the literal '[REDACTED]' + from the already-sanitized info["settings"] dict and always returned + False. The collector must read the un-redacted URL fresh from the DB. + """ + from backend.app.api.routes.support import _collect_slicer_api_info + + seen_urls: list[str] = [] + + async def fake_check(url, timeout=2.0): + seen_urls.append(url) + return True + + session_ctx = self._make_settings_session( + { + "use_slicer_api": "true", + "bambu_studio_api_url": "http://real-bs-host:3001", + "orcaslicer_api_url": "http://real-orca-host:3003", + } + ) + with ( + patch("backend.app.api.routes.support.async_session", session_ctx), + patch("backend.app.api.routes.support._check_url_reachable", side_effect=fake_check), + ): + await _collect_slicer_api_info() + + assert "http://real-bs-host:3001" in seen_urls + assert "http://real-orca-host:3003" in seen_urls + assert "[REDACTED]" not in seen_urls + + +class TestCollectAuthInfo: + """Tests for the OIDC / 2FA / API-key / group bundle block.""" + + @pytest.mark.asyncio + async def test_empty_database_returns_zero_counts_and_empty_list(self): + from backend.app.api.routes.support import _collect_auth_info + + def make_count(value): + r = MagicMock() + r.scalar.return_value = value + r.scalar_one_or_none.return_value = None + r.scalars.return_value.all.return_value = [] + r.all.return_value = [] + return r + + async def fake_execute(stmt, *_a, **_kw): + return make_count(0) + + db = AsyncMock() + db.execute = fake_execute + + info = await _collect_auth_info(db) + + assert info["oidc_providers"] == [] + assert info["users_with_totp"] == 0 + assert info["email_otp_codes_pending"] == 0 + assert info["api_keys_total"] == 0 + assert info["api_keys_enabled"] == 0 + assert info["api_keys_expired"] == 0 + assert info["long_lived_tokens_total"] == 0 + assert info["long_lived_tokens_active"] == 0 + assert info["groups_system"] == 0 + assert info["groups_custom"] == 0 + + @pytest.mark.asyncio + async def test_oidc_provider_names_exported_in_cleartext(self): + """Provider names are login-button labels — public, not a secret. Triage + for SSO bugs is significantly easier when the provider is identified.""" + from backend.app.api.routes.support import _collect_auth_info + + provider = MagicMock() + provider.id = 1 + provider.name = "PocketID" + provider.is_enabled = True + provider.scopes = "openid email profile" + provider.email_claim = "email" + provider.require_email_verified = True + provider.auto_create_users = False + provider.auto_link_existing_accounts = False + provider.default_group_id = None + provider.icon_url = None + + def make_result(rows=None, count=0): + r = MagicMock() + r.scalar.return_value = count + r.scalar_one_or_none.return_value = None + r.scalars.return_value.all.return_value = rows or [] + r.all.return_value = [] + return r + + async def fake_execute(stmt, *_a, **_kw): + sql = str(stmt).lower() + if "oidc_providers" in sql and "user_oidc_link" not in sql: + return make_result([provider]) + return make_result(count=0) + + db = AsyncMock() + db.execute = fake_execute + + info = await _collect_auth_info(db) + + assert len(info["oidc_providers"]) == 1 + oidc = info["oidc_providers"][0] + assert oidc["name"] == "PocketID" + # No secrets leak through — these fields don't exist on the dict. + assert "client_id" not in oidc + assert "client_secret" not in oidc + assert "issuer_url" not in oidc + + +class TestCollectGitHubBackupInfo: + """Tests for the GitHub-backup provider/failure-count block.""" + + @pytest.mark.asyncio + async def test_aggregates_providers_and_recent_failures(self): + from backend.app.api.routes.support import _collect_github_backup_info + + c1 = MagicMock(provider="github", last_backup_status="success", schedule_enabled=True) + c2 = MagicMock(provider="github", last_backup_status="failed", schedule_enabled=False) + c3 = MagicMock(provider="gitea", last_backup_status="failed", schedule_enabled=True) + + result = MagicMock() + result.scalars.return_value.all.return_value = [c1, c2, c3] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + info = await _collect_github_backup_info(db) + + assert info["configs_total"] == 3 + assert info["providers_used"] == {"github": 2, "gitea": 1} + assert info["schedule_enabled_count"] == 2 + assert info["last_failure_count"] == 2