support.py 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508
  1. """Support endpoints for debug logging and support bundle generation."""
  2. import asyncio
  3. import importlib.metadata
  4. import io
  5. import ipaddress
  6. import json
  7. import logging
  8. import os
  9. import platform
  10. import re
  11. import time
  12. import zipfile
  13. from datetime import datetime, timezone
  14. from pathlib import Path
  15. from fastapi import APIRouter, HTTPException, Query
  16. from fastapi.responses import StreamingResponse
  17. from pydantic import BaseModel
  18. from sqlalchemy import func, select, text
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  21. from backend.app.core.config import APP_VERSION, settings
  22. from backend.app.core.database import async_session
  23. from backend.app.core.permissions import Permission
  24. from backend.app.core.websocket import ws_manager
  25. from backend.app.models.archive import PrintArchive
  26. from backend.app.models.filament import Filament
  27. from backend.app.models.notification import NotificationProvider
  28. from backend.app.models.printer import Printer
  29. from backend.app.models.project import Project
  30. from backend.app.models.settings import Settings
  31. from backend.app.models.smart_plug import SmartPlug
  32. from backend.app.models.user import User
  33. from backend.app.services.discovery import is_running_in_docker
  34. from backend.app.services.log_reader import (
  35. LogEntry,
  36. collect_sensitive_strings,
  37. read_log_entries,
  38. sanitize_log_content,
  39. )
  40. from backend.app.services.network_utils import get_network_interfaces
  41. from backend.app.services.printer_manager import printer_manager
  42. from backend.app.utils.local_time import utcnow_naive
  43. router = APIRouter(prefix="/support", tags=["support"])
  44. logger = logging.getLogger(__name__)
  45. class DebugLoggingState(BaseModel):
  46. enabled: bool
  47. enabled_at: str | None = None
  48. duration_seconds: int | None = None
  49. class DebugLoggingToggle(BaseModel):
  50. enabled: bool
  51. async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
  52. """Get debug logging state from database."""
  53. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  54. enabled_setting = result.scalar_one_or_none()
  55. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  56. enabled_at_setting = result.scalar_one_or_none()
  57. enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
  58. enabled_at = None
  59. if enabled_at_setting and enabled_at_setting.value:
  60. try:
  61. enabled_at = datetime.fromisoformat(enabled_at_setting.value)
  62. if enabled_at.tzinfo is None:
  63. enabled_at = enabled_at.replace(tzinfo=timezone.utc)
  64. except ValueError:
  65. pass # Ignore malformed timestamp; enabled_at stays None
  66. return enabled, enabled_at
  67. async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
  68. """Set debug logging state in database."""
  69. # Update or create enabled setting
  70. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  71. setting = result.scalar_one_or_none()
  72. if setting:
  73. setting.value = str(enabled).lower()
  74. else:
  75. db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
  76. # Update enabled_at timestamp
  77. enabled_at = datetime.now(tz=timezone.utc) if enabled else None
  78. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  79. at_setting = result.scalar_one_or_none()
  80. if at_setting:
  81. at_setting.value = enabled_at.isoformat() if enabled_at else ""
  82. else:
  83. db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
  84. await db.commit()
  85. return enabled_at
  86. def _apply_log_level(debug: bool):
  87. """Apply log level change to root logger."""
  88. root_logger = logging.getLogger()
  89. new_level = logging.DEBUG if debug else logging.INFO
  90. root_logger.setLevel(new_level)
  91. for handler in root_logger.handlers:
  92. handler.setLevel(new_level)
  93. # Also adjust third-party loggers. httpx/httpcore stay pinned to WARNING
  94. # even in debug mode — at INFO/DEBUG they log full request URLs, which
  95. # leaks secrets embedded in webhook URLs (Discord, generic webhooks, etc.).
  96. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  97. logging.getLogger("aiosqlite").setLevel(logging.WARNING)
  98. logging.getLogger("httpcore").setLevel(logging.WARNING)
  99. logging.getLogger("httpx").setLevel(logging.WARNING)
  100. logging.getLogger("paho.mqtt").setLevel(logging.DEBUG if debug else logging.WARNING)
  101. logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
  102. @router.get("/debug-logging", response_model=DebugLoggingState)
  103. async def get_debug_logging_state(
  104. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  105. ):
  106. """Get current debug logging state."""
  107. async with async_session() as db:
  108. enabled, enabled_at = await _get_debug_setting(db)
  109. duration = None
  110. if enabled and enabled_at:
  111. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  112. return DebugLoggingState(
  113. enabled=enabled,
  114. enabled_at=enabled_at.isoformat() if enabled_at else None,
  115. duration_seconds=duration,
  116. )
  117. @router.post("/debug-logging", response_model=DebugLoggingState)
  118. async def toggle_debug_logging(
  119. toggle: DebugLoggingToggle,
  120. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  121. ):
  122. """Enable or disable debug logging."""
  123. async with async_session() as db:
  124. enabled_at = await _set_debug_setting(db, toggle.enabled)
  125. _apply_log_level(toggle.enabled)
  126. duration = None
  127. if toggle.enabled and enabled_at:
  128. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  129. return DebugLoggingState(
  130. enabled=toggle.enabled,
  131. enabled_at=enabled_at.isoformat() if enabled_at else None,
  132. duration_seconds=duration,
  133. )
  134. class LogsResponse(BaseModel):
  135. """Response containing log entries."""
  136. entries: list[LogEntry]
  137. total_in_file: int
  138. filtered_count: int
  139. @router.get("/logs", response_model=LogsResponse)
  140. async def get_logs(
  141. limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
  142. level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
  143. search: str | None = Query(None, description="Search in message or logger name"),
  144. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  145. ):
  146. """Get recent application log entries with optional filtering."""
  147. entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
  148. return LogsResponse(
  149. entries=entries,
  150. total_in_file=total_lines,
  151. filtered_count=len(entries),
  152. )
  153. @router.delete("/logs")
  154. async def clear_logs(
  155. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  156. ):
  157. """Clear the application log file."""
  158. log_file = settings.log_dir / "bambuddy.log"
  159. if log_file.exists():
  160. try:
  161. # Truncate the file instead of deleting (keeps file handles valid)
  162. with open(log_file, "w", encoding="utf-8") as f:
  163. f.write("")
  164. logger.info("Log file cleared by user")
  165. return {"message": "Logs cleared successfully"}
  166. except Exception as e:
  167. logger.error("Error clearing log file: %s", e, exc_info=True)
  168. raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
  169. return {"message": "Log file does not exist"}
  170. def _sanitize_path(path: str) -> str:
  171. """Remove username from paths for privacy."""
  172. # Replace /home/username/ or /Users/username/ with /home/[user]/
  173. path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
  174. path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
  175. # Replace /opt/username/ patterns
  176. path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
  177. return path
  178. def _detect_docker_network_mode() -> str:
  179. """Detect Docker network mode by checking for host-level interfaces.
  180. In host mode the container shares the host network namespace, so Docker
  181. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  182. mode the container is isolated and only sees its own veth (named eth0).
  183. """
  184. try:
  185. import socket
  186. for _idx, name in socket.if_nameindex():
  187. if name.startswith(("docker", "br-", "veth", "virbr")):
  188. return "host"
  189. except Exception:
  190. pass
  191. return "bridge"
  192. def _mask_subnet(subnet: str) -> str:
  193. """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
  194. try:
  195. parts = subnet.split(".")
  196. if len(parts) >= 4:
  197. parts[0] = "x"
  198. parts[1] = "x"
  199. return ".".join(parts)
  200. except Exception:
  201. pass
  202. return subnet
  203. def _anonymize_mqtt_broker(broker: str) -> str:
  204. """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
  205. if not broker:
  206. return ""
  207. try:
  208. ipaddress.ip_address(broker)
  209. return "[IP]"
  210. except ValueError:
  211. # It's a hostname — show *.domain pattern
  212. parts = broker.split(".")
  213. if len(parts) >= 2:
  214. return "*." + ".".join(parts[-2:])
  215. return broker
  216. async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
  217. """Test TCP connectivity to ip:port. Returns True if reachable."""
  218. try:
  219. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  220. writer.close()
  221. await writer.wait_closed()
  222. return True
  223. except Exception:
  224. return False
  225. def _get_container_memory_limit() -> int | None:
  226. """Read cgroup memory limit. Returns bytes or None."""
  227. # cgroup v2
  228. v2 = Path("/sys/fs/cgroup/memory.max")
  229. if v2.exists():
  230. try:
  231. val = v2.read_text().strip()
  232. if val != "max":
  233. return int(val)
  234. except Exception:
  235. pass
  236. # cgroup v1
  237. v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
  238. if v1.exists():
  239. try:
  240. val = int(v1.read_text().strip())
  241. # Values near page-aligned max (2^63-4096) mean unlimited
  242. if val < 2**62:
  243. return val
  244. except Exception:
  245. pass
  246. return None
  247. # Above this RSS the heap census is skipped — see _collect_process_info.
  248. _GC_CENSUS_RSS_LIMIT = 2 * 1024**3
  249. def _collect_process_info() -> dict:
  250. """Snapshot this process's resource usage, for reports about it growing.
  251. Bundles used to carry nothing about Bambuddy's own footprint, which made
  252. "memory climbs over days until the OOM killer fires" impossible to triage
  253. from a bundle alone — the reporter of #2734 had to be asked to run commands
  254. by hand, and the numbers that would have identified the mechanism could not
  255. be recovered after the fact.
  256. The four figures below separate the mechanisms that look identical from
  257. outside:
  258. * ``rss_bytes`` vs ``vms_bytes`` — a large virtual size against a modest
  259. resident one is address space, not live data: thread stacks or allocator
  260. arenas rather than a heap that keeps growing.
  261. * ``num_threads`` — every leaked MQTT client reconnect would leave a paho
  262. network thread behind, each reserving its stack.
  263. * ``children`` — the ffmpeg-per-camera-stream leak class (#776).
  264. * ``open_files`` / ``connections`` — descriptors held by streams or sockets
  265. that were never closed.
  266. Everything is best-effort: psutil raises on hardened kernels and inside
  267. restricted containers, and a support bundle must still be produced when it
  268. does. Child command lines are reduced to the executable name — a full
  269. ffmpeg argv carries the camera URL, and with it the camera's password.
  270. """
  271. import psutil
  272. out: dict = {}
  273. try:
  274. proc = psutil.Process()
  275. except Exception:
  276. return {"available": False}
  277. out["available"] = True
  278. try:
  279. mem = proc.memory_info()
  280. out["rss_bytes"] = mem.rss
  281. out["rss_formatted"] = _format_bytes(mem.rss)
  282. out["vms_bytes"] = mem.vms
  283. out["vms_formatted"] = _format_bytes(mem.vms)
  284. except Exception:
  285. pass
  286. try:
  287. out["num_threads"] = proc.num_threads()
  288. except Exception:
  289. pass
  290. try:
  291. out["uptime_seconds"] = int(time.time() - proc.create_time())
  292. except Exception:
  293. pass
  294. try:
  295. out["open_files"] = len(proc.open_files())
  296. except Exception:
  297. pass
  298. try:
  299. out["connections"] = len(proc.net_connections(kind="inet"))
  300. except Exception:
  301. pass
  302. # Children by executable name only. The count per name is what identifies a
  303. # leak; the arguments would leak credentials.
  304. try:
  305. names: dict[str, int] = {}
  306. for child in proc.children(recursive=True):
  307. try:
  308. names[child.name()] = names.get(child.name(), 0) + 1
  309. except Exception:
  310. names["<unknown>"] = names.get("<unknown>", 0) + 1
  311. out["children_total"] = sum(names.values())
  312. out["children_by_name"] = dict(sorted(names.items(), key=lambda kv: -kv[1]))
  313. except Exception:
  314. pass
  315. # Live object counts by type, top 15. Identifies a heap that is growing and
  316. # what it is growing with — the one thing RSS alone cannot say.
  317. #
  318. # Skipped above _GC_CENSUS_RSS_LIMIT. gc.get_objects() materialises a list
  319. # of every tracked object, so the census costs most on exactly the process
  320. # that can least afford it: a bundle generated to diagnose runaway memory
  321. # must not be the allocation that tips the host over. The numbers that
  322. # actually separate the mechanisms — RSS vs VMS, threads, children — are
  323. # collected above and unaffected.
  324. rss = out.get("rss_bytes")
  325. if rss is not None and rss > _GC_CENSUS_RSS_LIMIT:
  326. out["gc_census"] = (
  327. f"skipped: process is using {_format_bytes(rss)}, above the "
  328. f"{_format_bytes(_GC_CENSUS_RSS_LIMIT)} limit for walking the heap"
  329. )
  330. return out
  331. try:
  332. import gc
  333. counts: dict[str, int] = {}
  334. for obj in gc.get_objects():
  335. name = type(obj).__name__
  336. counts[name] = counts.get(name, 0) + 1
  337. out["gc_tracked_objects"] = sum(counts.values())
  338. out["gc_top_types"] = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:15])
  339. except Exception:
  340. pass
  341. return out
  342. def _format_bytes(size_bytes: int) -> str:
  343. """Format bytes into human-readable string."""
  344. if size_bytes < 1024:
  345. return f"{size_bytes} B"
  346. if size_bytes < 1024 * 1024:
  347. return f"{size_bytes / 1024:.1f} KB"
  348. if size_bytes < 1024 * 1024 * 1024:
  349. return f"{size_bytes / (1024 * 1024):.1f} MB"
  350. return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
  351. async def _collect_auth_info(db: AsyncSession) -> dict:
  352. """Auth-related configuration that's stored OUTSIDE the settings table.
  353. The settings-table passthrough already captures `ldap_*`, `advanced_auth_enabled`,
  354. etc. The blocks below come from dedicated tables that the support bundle did
  355. not previously surface — every recent SSO / 2FA / group bug needed this data
  356. to triage.
  357. """
  358. from backend.app.models.api_key import APIKey
  359. from backend.app.models.group import Group
  360. from backend.app.models.long_lived_token import LongLivedToken
  361. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  362. from backend.app.models.user_otp_code import UserOTPCode
  363. from backend.app.models.user_totp import UserTOTP
  364. now = datetime.now(timezone.utc)
  365. auth: dict = {}
  366. # OIDC providers — names are public (login-button labels), no secrets.
  367. providers_result = await db.execute(select(OIDCProvider).order_by(OIDCProvider.id))
  368. providers = providers_result.scalars().all()
  369. oidc_list = []
  370. for p in providers:
  371. # Count linked users per provider — separate query so failure on one
  372. # provider doesn't blank the whole list.
  373. try:
  374. link_count = (
  375. await db.execute(select(func.count(UserOIDCLink.id)).where(UserOIDCLink.provider_id == p.id))
  376. ).scalar() or 0
  377. except Exception:
  378. link_count = None
  379. oidc_list.append(
  380. {
  381. "name": p.name,
  382. "is_enabled": p.is_enabled,
  383. "scopes": p.scopes,
  384. "email_claim": p.email_claim,
  385. "require_email_verified": p.require_email_verified,
  386. "auto_create_users": p.auto_create_users,
  387. "auto_link_existing_accounts": p.auto_link_existing_accounts,
  388. "has_default_group": p.default_group_id is not None,
  389. # Derive from icon_content_type (non-deferred) rather than
  390. # icon_data (deferred BLOB) to avoid an async lazy-load.
  391. # Falls back to icon_url for pre-#1333 rows that have a URL
  392. # configured but no cached bytes yet.
  393. "has_icon": bool(p.icon_content_type) or bool(p.icon_url),
  394. "linked_user_count": link_count,
  395. }
  396. )
  397. auth["oidc_providers"] = oidc_list
  398. # 2FA enrollment — counts only, no per-user data.
  399. totp_enabled = (
  400. await db.execute(select(func.count(UserTOTP.id)).where(UserTOTP.is_enabled.is_(True)))
  401. ).scalar() or 0
  402. auth["users_with_totp"] = totp_enabled
  403. # Active (not-yet-expired, not-yet-used) email OTP codes — bounded count;
  404. # spikes here would point at someone hammering the email OTP flow.
  405. email_otp_pending = (
  406. await db.execute(
  407. select(func.count(UserOTPCode.id)).where(
  408. UserOTPCode.used.is_(False),
  409. UserOTPCode.expires_at > now,
  410. )
  411. )
  412. ).scalar() or 0
  413. auth["email_otp_codes_pending"] = email_otp_pending
  414. # API keys
  415. api_keys_total = (await db.execute(select(func.count(APIKey.id)))).scalar() or 0
  416. api_keys_enabled = (await db.execute(select(func.count(APIKey.id)).where(APIKey.enabled.is_(True)))).scalar() or 0
  417. api_keys_expired = (
  418. await db.execute(
  419. select(func.count(APIKey.id)).where(
  420. APIKey.expires_at.is_not(None),
  421. APIKey.expires_at < now,
  422. )
  423. )
  424. ).scalar() or 0
  425. auth["api_keys_total"] = api_keys_total
  426. auth["api_keys_enabled"] = api_keys_enabled
  427. auth["api_keys_expired"] = api_keys_expired
  428. # Long-lived tokens (camera-stream tokens used by kiosks etc.)
  429. llt_total = (await db.execute(select(func.count(LongLivedToken.id)))).scalar() or 0
  430. llt_active = (
  431. await db.execute(
  432. select(func.count(LongLivedToken.id)).where(
  433. LongLivedToken.revoked_at.is_(None),
  434. LongLivedToken.expires_at > now,
  435. )
  436. )
  437. ).scalar() or 0
  438. auth["long_lived_tokens_total"] = llt_total
  439. auth["long_lived_tokens_active"] = llt_active
  440. # Groups — system vs custom split matters for permission triage.
  441. groups_system = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(True)))).scalar() or 0
  442. groups_custom = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(False)))).scalar() or 0
  443. auth["groups_system"] = groups_system
  444. auth["groups_custom"] = groups_custom
  445. return auth
  446. async def _collect_library_info(db: AsyncSession) -> dict:
  447. """Library file / folder totals, including external-link and trash counts."""
  448. from backend.app.models.external_link import ExternalLink
  449. from backend.app.models.library import LibraryFile, LibraryFolder
  450. info: dict = {}
  451. info["library_files_total"] = (
  452. await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_(None)))
  453. ).scalar() or 0
  454. info["library_files_in_trash"] = (
  455. await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_not(None)))
  456. ).scalar() or 0
  457. info["library_folders_total"] = (await db.execute(select(func.count(LibraryFolder.id)))).scalar() or 0
  458. info["external_folders_total"] = (
  459. await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.is_external.is_(True)))
  460. ).scalar() or 0
  461. info["external_links_total"] = (await db.execute(select(func.count(ExternalLink.id)))).scalar() or 0
  462. # MakerWorld imports — counted here because they're LibraryFile rows with
  463. # source_type='makerworld' (the import path doesn't have its own table).
  464. info["makerworld_imports_total"] = (
  465. await db.execute(
  466. select(func.count(LibraryFile.id)).where(
  467. LibraryFile.deleted_at.is_(None),
  468. LibraryFile.source_type == "makerworld",
  469. )
  470. )
  471. ).scalar() or 0
  472. return info
  473. async def _collect_inventory_info(db: AsyncSession) -> dict:
  474. """Spool / k-profile totals from the inventory feature."""
  475. from backend.app.models.spool import Spool
  476. from backend.app.models.spool_k_profile import SpoolKProfile
  477. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  478. info: dict = {}
  479. info["spools_internal"] = (await db.execute(select(func.count(Spool.id)))).scalar() or 0
  480. info["k_profiles_internal"] = (await db.execute(select(func.count(SpoolKProfile.id)))).scalar() or 0
  481. info["k_profiles_spoolman"] = (await db.execute(select(func.count(SpoolmanKProfile.id)))).scalar() or 0
  482. return info
  483. async def _collect_queue_info(db: AsyncSession) -> dict:
  484. """Print-queue health: pending count + oldest pending age."""
  485. from backend.app.models.print_queue import PrintQueueItem
  486. info: dict = {}
  487. info["pending_total"] = (
  488. await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
  489. ).scalar() or 0
  490. info["manual_start_pending"] = (
  491. await db.execute(
  492. select(func.count(PrintQueueItem.id)).where(
  493. PrintQueueItem.status == "pending",
  494. PrintQueueItem.manual_start.is_(True),
  495. )
  496. )
  497. ).scalar() or 0
  498. # Oldest pending item — derived from created_at to detect items stuck in queue
  499. # (target printer offline, missing filament match, etc.).
  500. oldest_row = (
  501. await db.execute(
  502. select(PrintQueueItem.created_at)
  503. .where(PrintQueueItem.status == "pending")
  504. .order_by(PrintQueueItem.created_at)
  505. .limit(1)
  506. )
  507. ).scalar_one_or_none()
  508. if oldest_row is not None:
  509. # created_at is naive in this codebase (server_default=func.now()) and holds
  510. # UTC, so the clock on the other side of the subtraction has to be naive UTC
  511. # too. datetime.now() is naive *local*: on a UTC+3 host it reported an item
  512. # queued five minutes ago as three hours old, and went negative west of
  513. # Greenwich (#2855).
  514. age = (utcnow_naive() - oldest_row).total_seconds()
  515. info["oldest_pending_age_seconds"] = int(age)
  516. else:
  517. info["oldest_pending_age_seconds"] = None
  518. return info
  519. async def _collect_maintenance_info(db: AsyncSession) -> dict:
  520. """Maintenance schedule totals: enabled items count + last-serviced-never count."""
  521. from backend.app.models.maintenance import PrinterMaintenance
  522. info: dict = {}
  523. info["items_total"] = (await db.execute(select(func.count(PrinterMaintenance.id)))).scalar() or 0
  524. info["items_enabled"] = (
  525. await db.execute(select(func.count(PrinterMaintenance.id)).where(PrinterMaintenance.enabled.is_(True)))
  526. ).scalar() or 0
  527. return info
  528. async def _collect_github_backup_info(db: AsyncSession) -> dict:
  529. """GitHub-backup configs: count per provider + recent-failure indicator."""
  530. from backend.app.models.github_backup import GitHubBackupConfig
  531. rows = (await db.execute(select(GitHubBackupConfig))).scalars().all()
  532. providers_used: dict[str, int] = {}
  533. last_failure_count = 0
  534. schedule_enabled_count = 0
  535. for cfg in rows:
  536. providers_used[cfg.provider] = providers_used.get(cfg.provider, 0) + 1
  537. if cfg.last_backup_status == "failed":
  538. last_failure_count += 1
  539. if cfg.schedule_enabled:
  540. schedule_enabled_count += 1
  541. return {
  542. "configs_total": len(rows),
  543. "providers_used": providers_used,
  544. "schedule_enabled_count": schedule_enabled_count,
  545. "last_failure_count": last_failure_count,
  546. }
  547. async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
  548. """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
  549. if not url or not url.strip():
  550. return None
  551. try:
  552. import httpx
  553. async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
  554. r = await client.get(url, follow_redirects=False)
  555. # Anything that returned a status code counts as reachable, even 404
  556. # (the API server is up, just the path was wrong) — separates network
  557. # failure from configuration mistakes for the user.
  558. return r.status_code is not None
  559. except Exception:
  560. return False
  561. async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
  562. """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
  563. Returns ``None`` when ``url`` is empty (so the caller can distinguish
  564. "not configured" from "unreachable"). On any failure to fetch or parse,
  565. returns ``{"reachable": False, "version": None}``. The slicer-API wrapper
  566. labels both sidecars' CLI under ``checks.orcaslicer`` regardless of which
  567. slicer is actually bundled (cosmetic wrapper bug), so we read the version
  568. from whichever non-``dataPath`` child key exists rather than hardcoding
  569. one. This lets the bundle reviewer answer "is the user running the image
  570. they think they are?" without a separate curl round-trip.
  571. """
  572. if not url or not url.strip():
  573. return None
  574. health_url = url.rstrip("/") + "/health"
  575. try:
  576. import httpx
  577. async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
  578. r = await client.get(health_url, follow_redirects=False)
  579. if r.status_code != 200:
  580. return {"reachable": True, "version": None}
  581. try:
  582. data = r.json()
  583. except Exception:
  584. return {"reachable": True, "version": None}
  585. checks = data.get("checks") if isinstance(data, dict) else None
  586. if not isinstance(checks, dict):
  587. return {"reachable": True, "version": None}
  588. for key, value in checks.items():
  589. if key == "dataPath":
  590. continue
  591. if isinstance(value, dict) and "version" in value:
  592. return {"reachable": True, "version": value.get("version")}
  593. return {"reachable": True, "version": None}
  594. except Exception:
  595. return {"reachable": False, "version": None}
  596. async def _collect_slicer_api_info() -> dict:
  597. """Reachability check for configured slicer-API sidecars.
  598. Mirrors the URL-resolution precedence used by the real slicer routes
  599. (``archives.py:_slice_for_archive`` and ``library.py``) — DB setting first,
  600. falling back to ``app_settings.bambu_studio_api_url`` / ``slicer_api_url``
  601. which themselves respect the ``BAMBU_STUDIO_API_URL`` / ``SLICER_API_URL``
  602. env vars and default to ``http://localhost:3001`` / ``http://localhost:3003``.
  603. A bundle-time reachability check that only looked at the DB setting would
  604. return ``null`` for every user who runs the sidecar via env var or on the
  605. default port — i.e. most users.
  606. Also reads URLs directly from ``Settings.value`` rather than from
  607. ``info["settings"]``, which has already been redacted by the time the
  608. integrations block runs (``bambu_studio_api_url`` matches the ``url``
  609. keyword filter, so its value there is ``"[REDACTED]"`` and pinging that
  610. crashes httpx).
  611. """
  612. async with async_session() as db:
  613. keys_we_need = (
  614. "use_slicer_api",
  615. "preferred_slicer",
  616. "bambu_studio_api_url",
  617. "orcaslicer_api_url",
  618. )
  619. rows = (await db.execute(select(Settings).where(Settings.key.in_(keys_we_need)))).scalars().all()
  620. raw = {s.key: (s.value or "") for s in rows}
  621. # Resolve with the same DB-then-env-then-default precedence as the route
  622. # that the slicer-API client actually uses, so the bundle reflects what
  623. # the running app would resolve at request time.
  624. bs_db = raw.get("bambu_studio_api_url", "").strip()
  625. oc_db = raw.get("orcaslicer_api_url", "").strip()
  626. bs_url = bs_db or (settings.bambu_studio_api_url or "").strip()
  627. oc_url = oc_db or (settings.slicer_api_url or "").strip()
  628. info: dict = {
  629. "enabled": (raw.get("use_slicer_api", "false") or "false").lower() == "true",
  630. "preferred": raw.get("preferred_slicer", ""),
  631. # Layer accounting helps triage: was the URL set in the DB, or are
  632. # we falling through to the env-var / default? "Reachable but no
  633. # DB setting" is the env-var case.
  634. "bambu_studio_url_set_in_db": bool(bs_db),
  635. "orcaslicer_url_set_in_db": bool(oc_db),
  636. # Effective URL is the resolved one — kept as a host-portion-only
  637. # echo so we can confirm it's the expected sidecar without leaking
  638. # the full URL (which `url` keyword would have redacted anyway).
  639. "bambu_studio_url_source": ("db" if bs_db else ("env_or_default" if bs_url else "unset")),
  640. "orcaslicer_url_source": ("db" if oc_db else ("env_or_default" if oc_url else "unset")),
  641. }
  642. if info["enabled"]:
  643. bs_health, oc_health = await asyncio.gather(
  644. _fetch_slicer_health(bs_url),
  645. _fetch_slicer_health(oc_url),
  646. )
  647. info["bambu_studio_reachable"] = (bs_health or {}).get("reachable") if bs_health is not None else None
  648. info["bambu_studio_version"] = (bs_health or {}).get("version") if bs_health is not None else None
  649. info["orcaslicer_reachable"] = (oc_health or {}).get("reachable") if oc_health is not None else None
  650. info["orcaslicer_version"] = (oc_health or {}).get("version") if oc_health is not None else None
  651. return info
  652. def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
  653. """Parse the `obico_enabled_printers` setting the way the detection service does.
  654. The setting is a JSON array of printer IDs and an empty value means *all*
  655. printers — see ``ObicoDetectionService._load_settings``. This used to split
  656. on commas and treat empty as *none*, so a bundle from a default Obico setup
  657. reported every printer as unmonitored while the service was in fact polling
  658. all of them. Returns ``None`` for "all printers"; a comma-separated fallback
  659. is kept in case an install ever stored the legacy shape.
  660. """
  661. if not raw or not raw.strip():
  662. return None
  663. try:
  664. parsed = json.loads(raw)
  665. except (json.JSONDecodeError, TypeError):
  666. parsed = None
  667. if isinstance(parsed, list):
  668. return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
  669. result: set[int] = set()
  670. for token in raw.split(","):
  671. token = token.strip()
  672. if token.isdigit():
  673. result.add(int(token))
  674. return result
  675. async def _collect_support_info() -> dict:
  676. """Collect all support information."""
  677. in_docker = is_running_in_docker()
  678. info = {
  679. "generated_at": datetime.now(timezone.utc).isoformat(),
  680. "app": {
  681. "version": APP_VERSION,
  682. "debug_mode": settings.debug,
  683. },
  684. "system": {
  685. "platform": platform.system(),
  686. "platform_release": platform.release(),
  687. "platform_version": platform.version(),
  688. "architecture": platform.machine(),
  689. "python_version": platform.python_version(),
  690. },
  691. "environment": {
  692. "docker": in_docker,
  693. "data_dir": _sanitize_path(str(settings.base_dir)),
  694. "log_dir": _sanitize_path(str(settings.log_dir)),
  695. "timezone": os.environ.get("TZ", ""),
  696. },
  697. "database": {},
  698. "printers": [],
  699. "settings": {},
  700. # Bambuddy's own footprint. Cheap to collect and the only thing that
  701. # makes a "memory grows over days" report triageable from the bundle
  702. # rather than a round trip of shell commands (#2734). Off the event
  703. # loop: the heap census walks every tracked object, and a bundle
  704. # request must not stall status ingest while it does.
  705. "process": await asyncio.to_thread(_collect_process_info),
  706. }
  707. # Docker-specific info
  708. if in_docker:
  709. try:
  710. mem_limit = _get_container_memory_limit()
  711. info["docker"] = {
  712. "container_memory_limit_bytes": mem_limit,
  713. "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
  714. "network_mode_hint": _detect_docker_network_mode(),
  715. }
  716. except Exception:
  717. logger.debug("Failed to collect Docker info", exc_info=True)
  718. async with async_session() as db:
  719. # Database stats
  720. result = await db.execute(select(func.count(PrintArchive.id)))
  721. info["database"]["archives_total"] = result.scalar() or 0
  722. result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  723. info["database"]["archives_completed"] = result.scalar() or 0
  724. result = await db.execute(select(func.count(Printer.id)))
  725. info["database"]["printers_total"] = result.scalar() or 0
  726. result = await db.execute(select(func.count(Filament.id)))
  727. info["database"]["filaments_total"] = result.scalar() or 0
  728. result = await db.execute(select(func.count(Project.id)))
  729. info["database"]["projects_total"] = result.scalar() or 0
  730. result = await db.execute(select(func.count(SmartPlug.id)))
  731. info["database"]["smart_plugs_total"] = result.scalar() or 0
  732. # Printer info (anonymized - no names, IPs, or serials)
  733. result = await db.execute(select(Printer))
  734. printers = result.scalars().all()
  735. statuses = printer_manager.get_all_statuses()
  736. # Pre-load the obico settings that decide which printers are monitored.
  737. # Settings are loaded later in this function (and would overwrite these
  738. # keys in info["settings"]), so do a targeted query here for the
  739. # per-printer flag below. ``None`` means every printer is monitored.
  740. obico_enabled_set: set[int] | None = None
  741. obico_globally_enabled = False
  742. try:
  743. obico_rows = {
  744. row.key: row.value
  745. for row in (
  746. await db.execute(
  747. select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
  748. )
  749. )
  750. .scalars()
  751. .all()
  752. }
  753. obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
  754. obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
  755. except Exception:
  756. logger.debug("Failed to load obico settings", exc_info=True)
  757. # Check reachability in parallel
  758. reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
  759. reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
  760. for i, printer in enumerate(printers):
  761. state = statuses.get(printer.id)
  762. reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
  763. # Count AMS units and trays from raw_data
  764. ams_unit_count = 0
  765. ams_tray_count = 0
  766. has_vt_tray = False
  767. if state:
  768. ams_data = state.raw_data.get("ams")
  769. if isinstance(ams_data, list):
  770. ams_units = ams_data
  771. elif isinstance(ams_data, dict) and "ams" in ams_data:
  772. ams_units = ams_data["ams"] if isinstance(ams_data["ams"], list) else []
  773. else:
  774. ams_units = []
  775. ams_unit_count = len(ams_units)
  776. for unit in ams_units:
  777. trays = unit.get("tray", [])
  778. ams_tray_count += len([t for t in trays if t.get("tray_type")])
  779. has_vt_tray = bool(state.raw_data.get("vt_tray"))
  780. info["printers"].append(
  781. {
  782. "index": i + 1,
  783. "model": printer.model or "Unknown",
  784. "nozzle_count": printer.nozzle_count,
  785. "is_active": printer.is_active,
  786. "mqtt_connected": state.connected if state else False,
  787. "state": state.state if state else "unknown",
  788. "firmware_version": state.firmware_version if state else None,
  789. "wifi_signal": state.wifi_signal if state else None,
  790. "reachable": bool(reachable),
  791. "ams_unit_count": ams_unit_count,
  792. "ams_tray_count": ams_tray_count,
  793. "has_vt_tray": has_vt_tray,
  794. "external_camera_configured": bool(printer.external_camera_url),
  795. "plate_detection_enabled": printer.plate_detection_enabled,
  796. "obico_enabled": obico_globally_enabled
  797. and (obico_enabled_set is None or printer.id in obico_enabled_set),
  798. "hms_error_count": len(state.hms_errors) if state else 0,
  799. "developer_mode": state.developer_mode if state else None,
  800. "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
  801. }
  802. )
  803. # Virtual printers
  804. try:
  805. from backend.app.models.virtual_printer import VirtualPrinter
  806. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  807. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
  808. vps = result.scalars().all()
  809. info["virtual_printers"] = []
  810. for vp in vps:
  811. instance = virtual_printer_manager.get_instance(vp.id)
  812. status = instance.get_status() if instance else None
  813. model_code = vp.model or "C12"
  814. info["virtual_printers"].append(
  815. {
  816. "index": vp.id,
  817. "enabled": vp.enabled,
  818. "mode": vp.mode,
  819. "model": model_code,
  820. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  821. "has_target_printer": vp.target_printer_id is not None,
  822. "has_bind_ip": bool(vp.bind_ip),
  823. "running": status.get("running", False) if status else False,
  824. "pending_files": status.get("pending_files", 0) if status else 0,
  825. }
  826. )
  827. except Exception:
  828. logger.debug("Failed to collect virtual printer info", exc_info=True)
  829. # All settings — sensitive values are redacted rather than dropped so
  830. # new settings automatically show up in support bundles without a code
  831. # change. The value is replaced with "[REDACTED]" but the key is kept
  832. # so we can still see which integrations are configured.
  833. result = await db.execute(select(Settings))
  834. all_settings = result.scalars().all()
  835. sensitive_keys = {
  836. "access_code",
  837. "password",
  838. "token",
  839. "secret",
  840. "api_key",
  841. "auth_key", # Tailscale auth keys: virtual_printer_tailscale_auth_key
  842. "installation_id",
  843. "cloud_token",
  844. "mqtt_password",
  845. "email",
  846. "username",
  847. "vapid",
  848. "private_key",
  849. "public_key",
  850. "webhook",
  851. "url",
  852. "path", # Filesystem paths may contain usernames
  853. "config", # URLs may contain IPs, configs may have embedded secrets
  854. "_ip", # IP address fields (e.g. virtual_printer_remote_interface_ip)
  855. "host",
  856. "broker", # MQTT broker hostname / IP — network exposure
  857. "credential",
  858. }
  859. # Value-based safety net: redact anything whose value carries an
  860. # unambiguous secret prefix, even if the key name didn't match.
  861. # `tskey-` is the Tailscale auth-key prefix — future Tailscale settings
  862. # with unexpected names won't leak just because we forgot to add them.
  863. sensitive_value_prefixes = ("tskey-",)
  864. for s in all_settings:
  865. key_lower = s.key.lower()
  866. value = s.value or ""
  867. if any(sensitive in key_lower for sensitive in sensitive_keys) or any(
  868. value.startswith(prefix) for prefix in sensitive_value_prefixes
  869. ):
  870. # Preserve shape: mark presence without leaking the value
  871. info["settings"][s.key] = "[REDACTED]" if s.value else ""
  872. else:
  873. info["settings"][s.key] = s.value
  874. # Notification providers (anonymized — type/enabled/error status only)
  875. try:
  876. result = await db.execute(select(NotificationProvider))
  877. providers = result.scalars().all()
  878. info["integrations"] = info.get("integrations", {})
  879. info["integrations"]["notification_providers"] = [
  880. {
  881. "type": p.provider_type,
  882. "enabled": p.enabled,
  883. "has_last_error": bool(p.last_error),
  884. }
  885. for p in providers
  886. ]
  887. except Exception:
  888. logger.debug("Failed to collect notification provider info", exc_info=True)
  889. # Database health
  890. try:
  891. from backend.app.core.db_dialect import is_sqlite
  892. if is_sqlite():
  893. result = await db.execute(text("PRAGMA journal_mode"))
  894. journal_mode = result.scalar()
  895. result = await db.execute(text("PRAGMA quick_check"))
  896. quick_check = result.scalar()
  897. db_path = settings.base_dir / "bambuddy.db"
  898. db_size = db_path.stat().st_size if db_path.exists() else 0
  899. wal_path = settings.base_dir / "bambuddy.db-wal"
  900. wal_size = wal_path.stat().st_size if wal_path.exists() else 0
  901. info["database_health"] = {
  902. "backend": "sqlite",
  903. "journal_mode": journal_mode,
  904. "quick_check": quick_check,
  905. "db_size_bytes": db_size,
  906. "wal_size_bytes": wal_size,
  907. }
  908. else:
  909. result = await db.execute(text("SELECT version()"))
  910. pg_version = result.scalar()
  911. result = await db.execute(text("SELECT pg_database_size(current_database())"))
  912. db_size = result.scalar() or 0
  913. info["database_health"] = {
  914. "backend": "postgresql",
  915. "version": pg_version,
  916. "db_size_bytes": db_size,
  917. }
  918. except Exception:
  919. logger.debug("Failed to collect database health info", exc_info=True)
  920. # Auth section — OIDC, 2FA, API keys, long-lived tokens, groups.
  921. # Stored in dedicated tables that the settings-table passthrough doesn't see.
  922. try:
  923. async with async_session() as auth_db:
  924. info["auth"] = await _collect_auth_info(auth_db)
  925. except Exception:
  926. logger.debug("Failed to collect auth info", exc_info=True)
  927. # Library + folder + makerworld import totals
  928. try:
  929. async with async_session() as lib_db:
  930. info["library"] = await _collect_library_info(lib_db)
  931. except Exception:
  932. logger.debug("Failed to collect library info", exc_info=True)
  933. # Spool / k-profile totals (inventory feature)
  934. try:
  935. async with async_session() as inv_db:
  936. info["inventory"] = await _collect_inventory_info(inv_db)
  937. except Exception:
  938. logger.debug("Failed to collect inventory info", exc_info=True)
  939. # Print queue health
  940. try:
  941. async with async_session() as q_db:
  942. info["queue"] = await _collect_queue_info(q_db)
  943. except Exception:
  944. logger.debug("Failed to collect queue info", exc_info=True)
  945. # Maintenance schedules
  946. try:
  947. async with async_session() as m_db:
  948. info["maintenance"] = await _collect_maintenance_info(m_db)
  949. except Exception:
  950. logger.debug("Failed to collect maintenance info", exc_info=True)
  951. # Integrations (lazy imports to avoid circular dependencies)
  952. info.setdefault("integrations", {})
  953. # Spoolman
  954. try:
  955. from backend.app.services.spoolman import get_spoolman_client
  956. client = await get_spoolman_client()
  957. if client:
  958. reachable = await client.health_check()
  959. info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
  960. else:
  961. info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
  962. except Exception:
  963. logger.debug("Failed to collect Spoolman info", exc_info=True)
  964. # MQTT relay
  965. try:
  966. from backend.app.services.mqtt_relay import mqtt_relay
  967. status = mqtt_relay.get_status()
  968. info["integrations"]["mqtt_relay"] = {
  969. "enabled": status.get("enabled", False),
  970. "connected": status.get("connected", False),
  971. "broker": _anonymize_mqtt_broker(status.get("broker", "")),
  972. "port": status.get("port", 0),
  973. "topic_prefix": status.get("topic_prefix", ""),
  974. }
  975. except Exception:
  976. logger.debug("Failed to collect MQTT relay info", exc_info=True)
  977. # SpoolBuddy devices (anonymized — no hostnames, IPs or device IDs)
  978. try:
  979. async with async_session() as db:
  980. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  981. result = await db.execute(select(SpoolBuddyDevice))
  982. devices = result.scalars().all()
  983. info["integrations"]["spoolbuddy"] = {
  984. "device_count": len(devices),
  985. "online_count": sum(
  986. 1
  987. for d in devices
  988. if d.last_seen
  989. and (datetime.now(tz=timezone.utc) - d.last_seen.replace(tzinfo=timezone.utc)).total_seconds() < 30
  990. ),
  991. "devices": [
  992. {
  993. "index": i + 1,
  994. "firmware_version": d.firmware_version,
  995. "has_nfc": d.has_nfc,
  996. "has_scale": d.has_scale,
  997. "nfc_reader_type": d.nfc_reader_type,
  998. "nfc_connection": d.nfc_connection,
  999. "has_backlight": d.has_backlight,
  1000. "nfc_ok": d.nfc_ok,
  1001. "scale_ok": d.scale_ok,
  1002. "uptime_s": d.uptime_s,
  1003. "calibration_factor": d.calibration_factor,
  1004. "tare_offset": d.tare_offset,
  1005. "last_calibrated_at": d.last_calibrated_at.isoformat() if d.last_calibrated_at else None,
  1006. "update_status": d.update_status,
  1007. }
  1008. for i, d in enumerate(devices)
  1009. ],
  1010. }
  1011. except Exception:
  1012. logger.debug("Failed to collect SpoolBuddy info", exc_info=True)
  1013. # Home Assistant (check ha_enabled setting)
  1014. try:
  1015. info["integrations"]["homeassistant"] = {
  1016. "enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
  1017. }
  1018. except Exception:
  1019. logger.debug("Failed to collect Home Assistant info", exc_info=True)
  1020. # GitHub backup — providers + recent-failure counts from github_backup_config.
  1021. try:
  1022. async with async_session() as gb_db:
  1023. info["integrations"]["github_backup"] = await _collect_github_backup_info(gb_db)
  1024. except Exception:
  1025. logger.debug("Failed to collect GitHub backup info", exc_info=True)
  1026. # Slicer-API sidecar reachability (#X1C-investigation-style triage)
  1027. try:
  1028. info["integrations"]["slicer_api"] = await _collect_slicer_api_info()
  1029. except Exception:
  1030. logger.debug("Failed to collect slicer-API info", exc_info=True)
  1031. # Dependencies
  1032. try:
  1033. dep_packages = [
  1034. "fastapi",
  1035. "uvicorn",
  1036. "pydantic",
  1037. "sqlalchemy",
  1038. "paho-mqtt",
  1039. "psutil",
  1040. "httpx",
  1041. "aiofiles",
  1042. "cryptography",
  1043. "opencv-python-headless",
  1044. "numpy",
  1045. ]
  1046. info["dependencies"] = {}
  1047. for pkg in dep_packages:
  1048. try:
  1049. info["dependencies"][pkg] = importlib.metadata.version(pkg)
  1050. except importlib.metadata.PackageNotFoundError:
  1051. info["dependencies"][pkg] = None
  1052. except Exception:
  1053. logger.debug("Failed to collect dependency info", exc_info=True)
  1054. # Log file info
  1055. try:
  1056. log_file = settings.log_dir / "bambuddy.log"
  1057. if log_file.exists():
  1058. size = log_file.stat().st_size
  1059. info["log_file"] = {
  1060. "size_bytes": size,
  1061. "size_formatted": _format_bytes(size),
  1062. }
  1063. else:
  1064. info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
  1065. except Exception:
  1066. logger.debug("Failed to collect log file info", exc_info=True)
  1067. # Network interfaces (subnets with first two octets masked)
  1068. try:
  1069. interfaces = get_network_interfaces()
  1070. info["network"] = {
  1071. "interface_count": len(interfaces),
  1072. "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
  1073. }
  1074. except Exception:
  1075. logger.debug("Failed to collect network info", exc_info=True)
  1076. # WebSocket connections
  1077. try:
  1078. info["websockets"] = {
  1079. "active_connections": len(ws_manager.active_connections),
  1080. }
  1081. except Exception:
  1082. logger.debug("Failed to collect WebSocket info", exc_info=True)
  1083. # Active diagnostics — per-printer connection check, per-VP setup check,
  1084. # and the log-health scan. These all surface in the UI today (System page +
  1085. # bug-report bubble) but were never persisted into what the maintainer
  1086. # receives, so a "looks broken in bambuddy" report arrived with no
  1087. # actionable signal beyond raw logs. The snapshot helper is fail-soft per
  1088. # probe and bounded by a per-probe wall-clock cap, so a hung interface
  1089. # adds at most ~15 s to bundle generation regardless of fleet size (probes
  1090. # run concurrently).
  1091. try:
  1092. from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
  1093. async with async_session() as db:
  1094. info["diagnostics"] = await collect_diagnostic_snapshot(db)
  1095. except Exception:
  1096. logger.warning("Failed to collect diagnostic snapshot", exc_info=True)
  1097. return info
  1098. def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
  1099. """Get recent log content, limited to max_bytes from the end.
  1100. Spans the rotated files as well as the live one. ``bambuddy.log`` is capped
  1101. at 5 MB by the RotatingFileHandler, and the bundle used to ship only that
  1102. file — so on a large fleet with debug logging on, the window we ask a
  1103. reporter for was far shorter than anyone realised. The 19-printer farm in
  1104. #2555 emits ~100 lines/s of MQTT frame dumps, which fills 5 MB in under five
  1105. minutes: the bundle we received to diagnose a *queue* problem barely
  1106. contained one upload. The three rotated backups were sitting on disk unread.
  1107. Reads oldest -> newest so the result is chronological, then takes the last
  1108. ``max_bytes``, which is where the budget was all along.
  1109. """
  1110. log_file = settings.log_dir / "bambuddy.log"
  1111. if not log_file.exists():
  1112. return b"Log file not found"
  1113. # RotatingFileHandler names its backups .log.1 (newest) .. .log.N (oldest).
  1114. # Walk them in reverse so the concatenation reads forwards in time.
  1115. candidates: list[Path] = []
  1116. for index in range(settings.log_backup_count, 0, -1):
  1117. rotated = log_file.with_name(f"{log_file.name}.{index}")
  1118. if rotated.exists():
  1119. candidates.append(rotated)
  1120. candidates.append(log_file)
  1121. chunks: list[str] = []
  1122. remaining = max_bytes
  1123. # Fill from the newest backwards so the byte budget is spent on recent
  1124. # history, then flip back to chronological order for the reader.
  1125. for path in reversed(candidates):
  1126. if remaining <= 0:
  1127. break
  1128. try:
  1129. size = path.stat().st_size
  1130. with open(path, "rb") as f:
  1131. if size > remaining:
  1132. f.seek(size - remaining)
  1133. f.readline() # discard the partial line the seek landed in
  1134. chunks.append(f.read().decode("utf-8", errors="replace"))
  1135. remaining -= min(size, remaining)
  1136. except OSError:
  1137. logger.debug("Failed to read log file %s for support bundle", path, exc_info=True)
  1138. content = "".join(reversed(chunks))
  1139. # Sanitize sensitive data
  1140. content = sanitize_log_content(content, sensitive_strings)
  1141. return content.encode("utf-8")
  1142. # Top-level push_status keys that carry user-private data (filenames, BambuCloud
  1143. # IDs). Dropped from the bundled per-printer snapshot. Keep print.cfg /
  1144. # print.option / ams / vt_tray / vir_slot / mapping — those are the fields that
  1145. # make the snapshot worth shipping (per-model AMS Backup detection, tray-shape
  1146. # research, VP regression baselines).
  1147. _RAW_DATA_DROP_KEYS = frozenset(
  1148. {
  1149. "subtask_name",
  1150. "gcode_file",
  1151. "gcode_file_prepare_percent",
  1152. "subtask_id",
  1153. "task_id",
  1154. "project_id",
  1155. "gcode_state", # not sensitive, but mirrors current_print which we strip
  1156. "design_id",
  1157. "profile_id",
  1158. "model_id",
  1159. }
  1160. )
  1161. def _redact_raw_push_status(raw: dict) -> dict:
  1162. """Strip user-private keys from a cached push_status snapshot.
  1163. Drops the keys in :data:`_RAW_DATA_DROP_KEYS` anywhere in the tree, then
  1164. rewrites every entry under ``net.info[*].ip`` to ``"0.0.0.0"``. Mirrors the
  1165. LAN-topology leak fixed in the virtual-printer bridge (#1429) — the same
  1166. field exposes the printer's local IP plus the gateway/peers it sees. Returns
  1167. a NEW dict; the live ``state.raw_data`` is never mutated.
  1168. """
  1169. if not isinstance(raw, dict):
  1170. return {}
  1171. def _walk(value):
  1172. if isinstance(value, dict):
  1173. return {k: _walk(v) for k, v in value.items() if k not in _RAW_DATA_DROP_KEYS}
  1174. if isinstance(value, list):
  1175. return [_walk(v) for v in value]
  1176. return value
  1177. out = _walk(raw)
  1178. # Scrub net.info[*].ip after the structural walk — only meaningful at the
  1179. # top level; nested "net" blocks don't appear in Bambu push_status payloads.
  1180. net = out.get("net")
  1181. if isinstance(net, dict):
  1182. info_list = net.get("info")
  1183. if isinstance(info_list, list):
  1184. net["info"] = [
  1185. ({**entry, "ip": "0.0.0.0"} if isinstance(entry, dict) and "ip" in entry else entry) # nosec B104 - redaction sentinel, not a bind address
  1186. for entry in info_list
  1187. ]
  1188. return out
  1189. def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
  1190. """Sanitize a push_status snapshot's string *values*, never its JSON text.
  1191. This used to run :func:`sanitize_log_content` over the serialised snapshot.
  1192. That pass includes a generic Bambu-serial regex
  1193. (``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
  1194. decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
  1195. factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
  1196. shipped invalid JSON — unusable for exactly the ground-truth purpose the
  1197. snapshot exists for (found while diagnosing #2702).
  1198. Walking the structure instead leaves numbers, bools and None untouched, so
  1199. the output always parses. Keys are structural and never rewritten.
  1200. """
  1201. if isinstance(node, str):
  1202. return sanitize_log_content(node, sensitive_strings)
  1203. if isinstance(node, dict):
  1204. return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
  1205. if isinstance(node, list | tuple):
  1206. # Tuples too: `json.dumps` renders them as arrays, so stringifying one
  1207. # here would change the file's shape rather than just its content.
  1208. return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
  1209. if node is None or isinstance(node, bool | int | float):
  1210. return node
  1211. # Anything else (datetime, Decimal, …) would be stringified by json.dumps'
  1212. # ``default=str`` *after* this pass and so escape sanitisation entirely.
  1213. return sanitize_log_content(str(node), sensitive_strings)
  1214. async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
  1215. """Get recent log lines, sanitized for inclusion in bug reports."""
  1216. # Collect sensitive strings from DB for redaction
  1217. async with async_session() as db:
  1218. sensitive_strings = await collect_sensitive_strings(db)
  1219. log_file = settings.log_dir / "bambuddy.log"
  1220. if not log_file.exists():
  1221. return ""
  1222. # Read last portion of log file
  1223. try:
  1224. content = log_file.read_text(encoding="utf-8", errors="replace")
  1225. lines = content.splitlines()
  1226. recent = "\n".join(lines[-max_lines:])
  1227. return sanitize_log_content(recent, sensitive_strings)
  1228. except Exception:
  1229. logger.debug("Failed to read logs for bug report", exc_info=True)
  1230. return ""
  1231. @router.get("/bundle")
  1232. async def generate_support_bundle(
  1233. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  1234. ):
  1235. """Generate a support bundle ZIP file for issue reporting."""
  1236. # Check if debug logging is enabled and collect sensitive values for redaction
  1237. async with async_session() as db:
  1238. enabled, _enabled_at = await _get_debug_setting(db)
  1239. if not enabled:
  1240. raise HTTPException(
  1241. status_code=400,
  1242. detail="Debug logging must be enabled before generating a support bundle. "
  1243. "Please enable debug logging, reproduce the issue, then generate the bundle.",
  1244. )
  1245. # Collect known sensitive values for log redaction
  1246. sensitive_strings = await collect_sensitive_strings(db)
  1247. # Collect support info
  1248. support_info = await _collect_support_info()
  1249. # Create ZIP in memory
  1250. zip_buffer = io.BytesIO()
  1251. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  1252. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1253. # Add support info JSON
  1254. zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
  1255. # Per-printer cached push_status dump. Bambu firmware ships per-model
  1256. # config in a different shape for every family (the bit-26 / print.cfg
  1257. # gap that blocked AMS Backup awareness in 85fbd7fc), and shape-of-
  1258. # vt_tray / mapping / vir_slot has bitten the VP bridge repeatedly.
  1259. # Including the redacted snapshot turns every future support bundle
  1260. # into a ground-truth sample for that exact model+firmware. Index
  1261. # matches the 1-based ordering in support-info.json["printers"] so a
  1262. # maintainer can cross-reference without re-deriving identifiers.
  1263. statuses = printer_manager.get_all_statuses()
  1264. async with async_session() as db:
  1265. db_printers = (await db.execute(select(Printer))).scalars().all()
  1266. for i, printer in enumerate(db_printers):
  1267. state = statuses.get(printer.id)
  1268. if state is None or not state.raw_data:
  1269. continue
  1270. redacted = _redact_raw_push_status(state.raw_data)
  1271. snapshot = {
  1272. "model": printer.model or "Unknown",
  1273. "firmware_version": state.firmware_version,
  1274. "captured_at": datetime.now(timezone.utc).isoformat(),
  1275. "raw_data": redacted,
  1276. }
  1277. # Belt-and-suspenders: pass every string value through the
  1278. # string-based sanitizer so any user-named string (printer name,
  1279. # serial baked into a tray uuid) the structural pass missed still
  1280. # gets caught. Values only — sanitizing the serialised JSON text
  1281. # corrupted numeric literals (see _sanitize_push_status_values).
  1282. snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
  1283. zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
  1284. # Add log file
  1285. # Off the event loop: this reads up to 10 MB and then runs one full regex
  1286. # pass per sensitive string over it. Now that the bundle spans the rotated
  1287. # files it can genuinely reach that ceiling, and the blocking cost scales
  1288. # with the number of printers (4 redaction patterns each) — i.e. it is
  1289. # worst on exactly the fleet size this change was written for.
  1290. log_content = await asyncio.to_thread(_get_log_content, sensitive_strings=sensitive_strings)
  1291. zf.writestr("bambuddy.log", log_content)
  1292. zip_buffer.seek(0)
  1293. filename = f"bambuddy-support-{timestamp}.zip"
  1294. logger.info("Generated support bundle: %s", filename)
  1295. return StreamingResponse(
  1296. zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
  1297. )
  1298. async def init_debug_logging():
  1299. """Initialize debug logging state from database on startup."""
  1300. try:
  1301. async with async_session() as db:
  1302. enabled, _ = await _get_debug_setting(db)
  1303. if enabled:
  1304. _apply_log_level(True)
  1305. logger.info("Debug logging restored from previous session")
  1306. except Exception as e:
  1307. logger.warning("Could not restore debug logging state: %s", e)