mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Settings → Support → Debug Logging elevated httpx/httpcore to DEBUG, which makes httpx log every outbound request URL. For Discord and generic webhook notifications the bearer token is embedded in the URL path, so users who turned on debug logging to capture a support bundle were writing their webhook tokens straight into bambuddy.log. Pin httpx/httpcore to WARNING regardless of the debug toggle. paho.mqtt still honours debug. Users who enabled debug logging while notifications were sending must rotate any exposed Discord/webhook URLs — the token is the path, so the whole URL has to be regenerated in the provider UI.
916 lines
34 KiB
Python
916 lines
34 KiB
Python
"""Support endpoints for debug logging and support bundle generation."""
|
|
|
|
import asyncio
|
|
import importlib.metadata
|
|
import io
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.config import APP_VERSION, settings
|
|
from backend.app.core.database import async_session
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.core.websocket import ws_manager
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.filament import Filament
|
|
from backend.app.models.notification import NotificationProvider
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.project import Project
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
from backend.app.models.user import User
|
|
from backend.app.services.discovery import is_running_in_docker
|
|
from backend.app.services.network_utils import get_network_interfaces
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
router = APIRouter(prefix="/support", tags=["support"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DebugLoggingState(BaseModel):
|
|
enabled: bool
|
|
enabled_at: str | None = None
|
|
duration_seconds: int | None = None
|
|
|
|
|
|
class DebugLoggingToggle(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
|
|
"""Get debug logging state from database."""
|
|
result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
|
|
enabled_setting = result.scalar_one_or_none()
|
|
|
|
result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
|
|
enabled_at_setting = result.scalar_one_or_none()
|
|
|
|
enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
|
|
enabled_at = None
|
|
if enabled_at_setting and enabled_at_setting.value:
|
|
try:
|
|
enabled_at = datetime.fromisoformat(enabled_at_setting.value)
|
|
if enabled_at.tzinfo is None:
|
|
enabled_at = enabled_at.replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
pass # Ignore malformed timestamp; enabled_at stays None
|
|
|
|
return enabled, enabled_at
|
|
|
|
|
|
async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
|
|
"""Set debug logging state in database."""
|
|
# Update or create enabled setting
|
|
result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
|
|
setting = result.scalar_one_or_none()
|
|
if setting:
|
|
setting.value = str(enabled).lower()
|
|
else:
|
|
db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
|
|
|
|
# Update enabled_at timestamp
|
|
enabled_at = datetime.now(tz=timezone.utc) if enabled else None
|
|
result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
|
|
at_setting = result.scalar_one_or_none()
|
|
if at_setting:
|
|
at_setting.value = enabled_at.isoformat() if enabled_at else ""
|
|
else:
|
|
db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
|
|
|
|
await db.commit()
|
|
return enabled_at
|
|
|
|
|
|
def _apply_log_level(debug: bool):
|
|
"""Apply log level change to root logger."""
|
|
root_logger = logging.getLogger()
|
|
new_level = logging.DEBUG if debug else logging.INFO
|
|
|
|
root_logger.setLevel(new_level)
|
|
for handler in root_logger.handlers:
|
|
handler.setLevel(new_level)
|
|
|
|
# Also adjust third-party loggers. httpx/httpcore stay pinned to WARNING
|
|
# even in debug mode — at INFO/DEBUG they log full request URLs, which
|
|
# leaks secrets embedded in webhook URLs (Discord, generic webhooks, etc.).
|
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("paho.mqtt").setLevel(logging.DEBUG if debug else logging.WARNING)
|
|
|
|
logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
|
|
|
|
|
|
@router.get("/debug-logging", response_model=DebugLoggingState)
|
|
async def get_debug_logging_state(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Get current debug logging state."""
|
|
async with async_session() as db:
|
|
enabled, enabled_at = await _get_debug_setting(db)
|
|
|
|
duration = None
|
|
if enabled and enabled_at:
|
|
duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
|
|
|
|
return DebugLoggingState(
|
|
enabled=enabled,
|
|
enabled_at=enabled_at.isoformat() if enabled_at else None,
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
|
|
@router.post("/debug-logging", response_model=DebugLoggingState)
|
|
async def toggle_debug_logging(
|
|
toggle: DebugLoggingToggle,
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Enable or disable debug logging."""
|
|
async with async_session() as db:
|
|
enabled_at = await _set_debug_setting(db, toggle.enabled)
|
|
|
|
_apply_log_level(toggle.enabled)
|
|
|
|
duration = None
|
|
if toggle.enabled and enabled_at:
|
|
duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
|
|
|
|
return DebugLoggingState(
|
|
enabled=toggle.enabled,
|
|
enabled_at=enabled_at.isoformat() if enabled_at else None,
|
|
duration_seconds=duration,
|
|
)
|
|
|
|
|
|
class LogEntry(BaseModel):
|
|
"""A single log entry."""
|
|
|
|
timestamp: str
|
|
level: str
|
|
logger_name: str
|
|
message: str
|
|
|
|
|
|
class LogsResponse(BaseModel):
|
|
"""Response containing log entries."""
|
|
|
|
entries: list[LogEntry]
|
|
total_in_file: int
|
|
filtered_count: int
|
|
|
|
|
|
# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
|
|
LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
|
|
|
|
|
|
def _parse_log_line(line: str) -> LogEntry | None:
|
|
"""Parse a single log line into a LogEntry."""
|
|
match = LOG_LINE_PATTERN.match(line.strip())
|
|
if match:
|
|
return LogEntry(
|
|
timestamp=match.group(1),
|
|
level=match.group(2),
|
|
logger_name=match.group(3),
|
|
message=match.group(4),
|
|
)
|
|
return None
|
|
|
|
|
|
def _read_log_entries(
|
|
limit: int = 200,
|
|
level_filter: str | None = None,
|
|
search: str | None = None,
|
|
) -> tuple[list[LogEntry], int]:
|
|
"""Read and parse log entries from file with optional filtering."""
|
|
log_file = settings.log_dir / "bambuddy.log"
|
|
if not log_file.exists():
|
|
return [], 0
|
|
|
|
entries: list[LogEntry] = []
|
|
total_lines = 0
|
|
|
|
try:
|
|
with open(log_file, encoding="utf-8", errors="replace") as f:
|
|
# Read all lines and process
|
|
lines = f.readlines()
|
|
total_lines = len(lines)
|
|
|
|
# Parse lines in reverse order (newest first)
|
|
current_entry: LogEntry | None = None
|
|
multi_line_buffer: list[str] = []
|
|
|
|
for line in reversed(lines):
|
|
parsed = _parse_log_line(line)
|
|
if parsed:
|
|
# Found a new log entry start
|
|
if current_entry:
|
|
# Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
|
|
should_include = True
|
|
|
|
# Level filter
|
|
if level_filter and current_entry.level.upper() != level_filter.upper():
|
|
should_include = False
|
|
|
|
# Search filter (case-insensitive)
|
|
if search and should_include:
|
|
search_lower = search.lower()
|
|
if not (
|
|
search_lower in current_entry.message.lower()
|
|
or search_lower in current_entry.logger_name.lower()
|
|
):
|
|
should_include = False
|
|
|
|
if should_include:
|
|
entries.append(current_entry)
|
|
|
|
if len(entries) >= limit:
|
|
break
|
|
|
|
# Set new entry and attach any accumulated multi-line content to it
|
|
# (in reverse order, continuation lines come before their parent entry)
|
|
current_entry = parsed
|
|
if multi_line_buffer:
|
|
current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
|
|
multi_line_buffer = []
|
|
elif line.strip():
|
|
# Continuation of multi-line log entry (will be attached to next parsed entry)
|
|
multi_line_buffer.append(line.rstrip())
|
|
|
|
# Don't forget the last (oldest) entry
|
|
# Note: any remaining multi_line_buffer would be orphaned lines before the first entry
|
|
if current_entry and len(entries) < limit:
|
|
should_include = True
|
|
if level_filter and current_entry.level.upper() != level_filter.upper():
|
|
should_include = False
|
|
if search and should_include:
|
|
search_lower = search.lower()
|
|
if not (
|
|
search_lower in current_entry.message.lower()
|
|
or search_lower in current_entry.logger_name.lower()
|
|
):
|
|
should_include = False
|
|
if should_include:
|
|
entries.append(current_entry)
|
|
|
|
except Exception as e:
|
|
logger.error("Error reading log file: %s", e)
|
|
return [], 0
|
|
|
|
# Entries are already in newest-first order
|
|
return entries, total_lines
|
|
|
|
|
|
@router.get("/logs", response_model=LogsResponse)
|
|
async def get_logs(
|
|
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
|
|
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
|
|
search: str | None = Query(None, description="Search in message or logger name"),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Get recent application log entries with optional filtering."""
|
|
entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
|
|
|
|
return LogsResponse(
|
|
entries=entries,
|
|
total_in_file=total_lines,
|
|
filtered_count=len(entries),
|
|
)
|
|
|
|
|
|
@router.delete("/logs")
|
|
async def clear_logs(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Clear the application log file."""
|
|
log_file = settings.log_dir / "bambuddy.log"
|
|
|
|
if log_file.exists():
|
|
try:
|
|
# Truncate the file instead of deleting (keeps file handles valid)
|
|
with open(log_file, "w", encoding="utf-8") as f:
|
|
f.write("")
|
|
logger.info("Log file cleared by user")
|
|
return {"message": "Logs cleared successfully"}
|
|
except Exception as e:
|
|
logger.error("Error clearing log file: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
|
|
|
|
return {"message": "Log file does not exist"}
|
|
|
|
|
|
def _sanitize_path(path: str) -> str:
|
|
"""Remove username from paths for privacy."""
|
|
|
|
# Replace /home/username/ or /Users/username/ with /home/[user]/
|
|
path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
|
|
path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
|
|
# Replace /opt/username/ patterns
|
|
path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
|
|
return path
|
|
|
|
|
|
def _detect_docker_network_mode() -> str:
|
|
"""Detect Docker network mode by checking for host-level interfaces.
|
|
|
|
In host mode the container shares the host network namespace, so Docker
|
|
infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
|
|
mode the container is isolated and only sees its own veth (named eth0).
|
|
"""
|
|
try:
|
|
import socket
|
|
|
|
for _idx, name in socket.if_nameindex():
|
|
if name.startswith(("docker", "br-", "veth", "virbr")):
|
|
return "host"
|
|
except Exception:
|
|
pass
|
|
return "bridge"
|
|
|
|
|
|
def _mask_subnet(subnet: str) -> str:
|
|
"""Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
|
|
try:
|
|
parts = subnet.split(".")
|
|
if len(parts) >= 4:
|
|
parts[0] = "x"
|
|
parts[1] = "x"
|
|
return ".".join(parts)
|
|
except Exception:
|
|
pass
|
|
return subnet
|
|
|
|
|
|
def _anonymize_mqtt_broker(broker: str) -> str:
|
|
"""Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
|
|
if not broker:
|
|
return ""
|
|
try:
|
|
ipaddress.ip_address(broker)
|
|
return "[IP]"
|
|
except ValueError:
|
|
# It's a hostname — show *.domain pattern
|
|
parts = broker.split(".")
|
|
if len(parts) >= 2:
|
|
return "*." + ".".join(parts[-2:])
|
|
return broker
|
|
|
|
|
|
async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
|
|
"""Test TCP connectivity to ip:port. Returns True if reachable."""
|
|
try:
|
|
_reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _get_container_memory_limit() -> int | None:
|
|
"""Read cgroup memory limit. Returns bytes or None."""
|
|
# cgroup v2
|
|
v2 = Path("/sys/fs/cgroup/memory.max")
|
|
if v2.exists():
|
|
try:
|
|
val = v2.read_text().strip()
|
|
if val != "max":
|
|
return int(val)
|
|
except Exception:
|
|
pass
|
|
# cgroup v1
|
|
v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
|
if v1.exists():
|
|
try:
|
|
val = int(v1.read_text().strip())
|
|
# Values near page-aligned max (2^63-4096) mean unlimited
|
|
if val < 2**62:
|
|
return val
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _format_bytes(size_bytes: int) -> str:
|
|
"""Format bytes into human-readable string."""
|
|
if size_bytes < 1024:
|
|
return f"{size_bytes} B"
|
|
if size_bytes < 1024 * 1024:
|
|
return f"{size_bytes / 1024:.1f} KB"
|
|
if size_bytes < 1024 * 1024 * 1024:
|
|
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
|
|
|
|
async def _collect_support_info() -> dict:
|
|
"""Collect all support information."""
|
|
in_docker = is_running_in_docker()
|
|
|
|
info = {
|
|
"generated_at": datetime.now().isoformat(),
|
|
"app": {
|
|
"version": APP_VERSION,
|
|
"debug_mode": settings.debug,
|
|
},
|
|
"system": {
|
|
"platform": platform.system(),
|
|
"platform_release": platform.release(),
|
|
"platform_version": platform.version(),
|
|
"architecture": platform.machine(),
|
|
"python_version": platform.python_version(),
|
|
},
|
|
"environment": {
|
|
"docker": in_docker,
|
|
"data_dir": _sanitize_path(str(settings.base_dir)),
|
|
"log_dir": _sanitize_path(str(settings.log_dir)),
|
|
"timezone": os.environ.get("TZ", ""),
|
|
},
|
|
"database": {},
|
|
"printers": [],
|
|
"settings": {},
|
|
}
|
|
|
|
# Docker-specific info
|
|
if in_docker:
|
|
try:
|
|
mem_limit = _get_container_memory_limit()
|
|
info["docker"] = {
|
|
"container_memory_limit_bytes": mem_limit,
|
|
"container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
|
|
"network_mode_hint": _detect_docker_network_mode(),
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect Docker info", exc_info=True)
|
|
|
|
async with async_session() as db:
|
|
# Database stats
|
|
result = await db.execute(select(func.count(PrintArchive.id)))
|
|
info["database"]["archives_total"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
|
|
info["database"]["archives_completed"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(Printer.id)))
|
|
info["database"]["printers_total"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(Filament.id)))
|
|
info["database"]["filaments_total"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(Project.id)))
|
|
info["database"]["projects_total"] = result.scalar() or 0
|
|
|
|
result = await db.execute(select(func.count(SmartPlug.id)))
|
|
info["database"]["smart_plugs_total"] = result.scalar() or 0
|
|
|
|
# Printer info (anonymized - no names, IPs, or serials)
|
|
result = await db.execute(select(Printer))
|
|
printers = result.scalars().all()
|
|
statuses = printer_manager.get_all_statuses()
|
|
|
|
# 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)
|
|
|
|
for i, printer in enumerate(printers):
|
|
state = statuses.get(printer.id)
|
|
reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
|
|
|
|
# Count AMS units and trays from raw_data
|
|
ams_unit_count = 0
|
|
ams_tray_count = 0
|
|
has_vt_tray = False
|
|
if state:
|
|
ams_data = state.raw_data.get("ams")
|
|
if isinstance(ams_data, list):
|
|
ams_units = ams_data
|
|
elif isinstance(ams_data, dict) and "ams" in ams_data:
|
|
ams_units = ams_data["ams"] if isinstance(ams_data["ams"], list) else []
|
|
else:
|
|
ams_units = []
|
|
ams_unit_count = len(ams_units)
|
|
for unit in ams_units:
|
|
trays = unit.get("tray", [])
|
|
ams_tray_count += len([t for t in trays if t.get("tray_type")])
|
|
has_vt_tray = bool(state.raw_data.get("vt_tray"))
|
|
|
|
info["printers"].append(
|
|
{
|
|
"index": i + 1,
|
|
"model": printer.model or "Unknown",
|
|
"nozzle_count": printer.nozzle_count,
|
|
"is_active": printer.is_active,
|
|
"mqtt_connected": state.connected if state else False,
|
|
"state": state.state if state else "unknown",
|
|
"firmware_version": state.firmware_version if state else None,
|
|
"wifi_signal": state.wifi_signal if state else None,
|
|
"reachable": bool(reachable),
|
|
"ams_unit_count": ams_unit_count,
|
|
"ams_tray_count": ams_tray_count,
|
|
"has_vt_tray": has_vt_tray,
|
|
"external_camera_configured": bool(printer.external_camera_url),
|
|
"plate_detection_enabled": printer.plate_detection_enabled,
|
|
"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,
|
|
}
|
|
)
|
|
|
|
# Virtual printers
|
|
try:
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
|
|
|
|
result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
|
|
vps = result.scalars().all()
|
|
info["virtual_printers"] = []
|
|
for vp in vps:
|
|
instance = virtual_printer_manager.get_instance(vp.id)
|
|
status = instance.get_status() if instance else None
|
|
model_code = vp.model or "C12"
|
|
info["virtual_printers"].append(
|
|
{
|
|
"index": vp.id,
|
|
"enabled": vp.enabled,
|
|
"mode": vp.mode,
|
|
"model": model_code,
|
|
"model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
|
|
"has_target_printer": vp.target_printer_id is not None,
|
|
"has_bind_ip": bool(vp.bind_ip),
|
|
"running": status.get("running", False) if status else False,
|
|
"pending_files": status.get("pending_files", 0) if status else 0,
|
|
}
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to collect virtual printer info", exc_info=True)
|
|
|
|
# Non-sensitive settings
|
|
result = await db.execute(select(Settings))
|
|
all_settings = result.scalars().all()
|
|
sensitive_keys = {
|
|
"access_code",
|
|
"password",
|
|
"token",
|
|
"secret",
|
|
"api_key",
|
|
"installation_id",
|
|
"cloud_token",
|
|
"mqtt_password",
|
|
"email",
|
|
"username",
|
|
"vapid",
|
|
"private_key",
|
|
"public_key",
|
|
"webhook",
|
|
"url",
|
|
"path", # Filesystem paths may contain usernames
|
|
"config", # URLs may contain IPs, configs may have embedded secrets
|
|
"_ip", # IP address fields (e.g. virtual_printer_remote_interface_ip)
|
|
}
|
|
for s in all_settings:
|
|
# Skip sensitive settings
|
|
if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
|
|
continue
|
|
info["settings"][s.key] = s.value
|
|
|
|
# Notification providers (anonymized — type/enabled/error status only)
|
|
try:
|
|
result = await db.execute(select(NotificationProvider))
|
|
providers = result.scalars().all()
|
|
info["integrations"] = info.get("integrations", {})
|
|
info["integrations"]["notification_providers"] = [
|
|
{
|
|
"type": p.provider_type,
|
|
"enabled": p.enabled,
|
|
"has_last_error": bool(p.last_error),
|
|
}
|
|
for p in providers
|
|
]
|
|
except Exception:
|
|
logger.debug("Failed to collect notification provider info", exc_info=True)
|
|
|
|
# Database health
|
|
try:
|
|
from backend.app.core.db_dialect import is_sqlite
|
|
|
|
if is_sqlite():
|
|
result = await db.execute(text("PRAGMA journal_mode"))
|
|
journal_mode = result.scalar()
|
|
result = await db.execute(text("PRAGMA quick_check"))
|
|
quick_check = result.scalar()
|
|
|
|
db_path = settings.base_dir / "bambuddy.db"
|
|
db_size = db_path.stat().st_size if db_path.exists() else 0
|
|
wal_path = settings.base_dir / "bambuddy.db-wal"
|
|
wal_size = wal_path.stat().st_size if wal_path.exists() else 0
|
|
|
|
info["database_health"] = {
|
|
"backend": "sqlite",
|
|
"journal_mode": journal_mode,
|
|
"quick_check": quick_check,
|
|
"db_size_bytes": db_size,
|
|
"wal_size_bytes": wal_size,
|
|
}
|
|
else:
|
|
result = await db.execute(text("SELECT version()"))
|
|
pg_version = result.scalar()
|
|
result = await db.execute(text("SELECT pg_database_size(current_database())"))
|
|
db_size = result.scalar() or 0
|
|
|
|
info["database_health"] = {
|
|
"backend": "postgresql",
|
|
"version": pg_version,
|
|
"db_size_bytes": db_size,
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect database health info", exc_info=True)
|
|
|
|
# Integrations (lazy imports to avoid circular dependencies)
|
|
info.setdefault("integrations", {})
|
|
|
|
# Spoolman
|
|
try:
|
|
from backend.app.services.spoolman import get_spoolman_client
|
|
|
|
client = await get_spoolman_client()
|
|
if client:
|
|
reachable = await client.health_check()
|
|
info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
|
|
else:
|
|
info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
|
|
except Exception:
|
|
logger.debug("Failed to collect Spoolman info", exc_info=True)
|
|
|
|
# MQTT relay
|
|
try:
|
|
from backend.app.services.mqtt_relay import mqtt_relay
|
|
|
|
status = mqtt_relay.get_status()
|
|
info["integrations"]["mqtt_relay"] = {
|
|
"enabled": status.get("enabled", False),
|
|
"connected": status.get("connected", False),
|
|
"broker": _anonymize_mqtt_broker(status.get("broker", "")),
|
|
"port": status.get("port", 0),
|
|
"topic_prefix": status.get("topic_prefix", ""),
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect MQTT relay info", exc_info=True)
|
|
|
|
# Home Assistant (check ha_enabled setting)
|
|
try:
|
|
info["integrations"]["homeassistant"] = {
|
|
"enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect Home Assistant info", exc_info=True)
|
|
|
|
# Dependencies
|
|
try:
|
|
dep_packages = [
|
|
"fastapi",
|
|
"uvicorn",
|
|
"pydantic",
|
|
"sqlalchemy",
|
|
"paho-mqtt",
|
|
"psutil",
|
|
"httpx",
|
|
"aiofiles",
|
|
"cryptography",
|
|
"opencv-python-headless",
|
|
"numpy",
|
|
]
|
|
info["dependencies"] = {}
|
|
for pkg in dep_packages:
|
|
try:
|
|
info["dependencies"][pkg] = importlib.metadata.version(pkg)
|
|
except importlib.metadata.PackageNotFoundError:
|
|
info["dependencies"][pkg] = None
|
|
except Exception:
|
|
logger.debug("Failed to collect dependency info", exc_info=True)
|
|
|
|
# Log file info
|
|
try:
|
|
log_file = settings.log_dir / "bambuddy.log"
|
|
if log_file.exists():
|
|
size = log_file.stat().st_size
|
|
info["log_file"] = {
|
|
"size_bytes": size,
|
|
"size_formatted": _format_bytes(size),
|
|
}
|
|
else:
|
|
info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
|
|
except Exception:
|
|
logger.debug("Failed to collect log file info", exc_info=True)
|
|
|
|
# Network interfaces (subnets with first two octets masked)
|
|
try:
|
|
interfaces = get_network_interfaces()
|
|
info["network"] = {
|
|
"interface_count": len(interfaces),
|
|
"interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect network info", exc_info=True)
|
|
|
|
# WebSocket connections
|
|
try:
|
|
info["websockets"] = {
|
|
"active_connections": len(ws_manager.active_connections),
|
|
}
|
|
except Exception:
|
|
logger.debug("Failed to collect WebSocket info", exc_info=True)
|
|
|
|
return info
|
|
|
|
|
|
def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
|
|
"""Remove sensitive data from log content."""
|
|
# First, replace known sensitive values (database-aware exact matching)
|
|
# This catches printer names, usernames, and other arbitrary user-chosen strings
|
|
# that regex patterns cannot detect
|
|
if sensitive_strings:
|
|
# Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
|
|
for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
|
|
if len(value) < 3:
|
|
continue # Skip very short strings to prevent over-redaction
|
|
content = re.sub(re.escape(value), label, content)
|
|
|
|
# Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
|
|
content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
|
|
|
|
# Replace email addresses
|
|
content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
|
|
|
|
# Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
|
|
content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
|
|
|
|
# Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
|
|
content = re.sub(
|
|
r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
|
|
"[IP]",
|
|
content,
|
|
)
|
|
|
|
# Replace paths with usernames
|
|
content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
|
|
content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
|
|
content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
|
|
|
|
return content
|
|
|
|
|
|
def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
|
|
"""Get log file content, limited to max_bytes from the end."""
|
|
log_file = settings.log_dir / "bambuddy.log"
|
|
if not log_file.exists():
|
|
return b"Log file not found"
|
|
|
|
file_size = log_file.stat().st_size
|
|
if file_size <= max_bytes:
|
|
content = log_file.read_text(encoding="utf-8", errors="replace")
|
|
else:
|
|
# Read last max_bytes
|
|
with open(log_file, "rb") as f:
|
|
f.seek(file_size - max_bytes)
|
|
# Skip partial line at start
|
|
f.readline()
|
|
content = f.read().decode("utf-8", errors="replace")
|
|
|
|
# Sanitize sensitive data
|
|
content = _sanitize_log_content(content, sensitive_strings)
|
|
return content.encode("utf-8")
|
|
|
|
|
|
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
|
|
"""Get recent log lines, sanitized for inclusion in bug reports."""
|
|
# Collect sensitive strings from DB for redaction
|
|
sensitive_strings: dict[str, str] = {}
|
|
async with async_session() as db:
|
|
result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
|
|
for name, serial, ip_address, access_code in result.all():
|
|
if name:
|
|
sensitive_strings[name] = "[PRINTER]"
|
|
if serial:
|
|
sensitive_strings[serial] = "[SERIAL]"
|
|
if ip_address:
|
|
sensitive_strings[ip_address] = "[IP]"
|
|
if access_code:
|
|
sensitive_strings[access_code] = "[ACCESS_CODE]"
|
|
|
|
result = await db.execute(select(User.username))
|
|
for (username,) in result.all():
|
|
if username:
|
|
sensitive_strings[username] = "[USER]"
|
|
|
|
result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
|
|
cloud_email = result.scalar_one_or_none()
|
|
if cloud_email:
|
|
sensitive_strings[cloud_email] = "[EMAIL]"
|
|
|
|
log_file = settings.log_dir / "bambuddy.log"
|
|
if not log_file.exists():
|
|
return ""
|
|
|
|
# Read last portion of log file
|
|
try:
|
|
content = log_file.read_text(encoding="utf-8", errors="replace")
|
|
lines = content.splitlines()
|
|
recent = "\n".join(lines[-max_lines:])
|
|
return _sanitize_log_content(recent, sensitive_strings)
|
|
except Exception:
|
|
logger.debug("Failed to read logs for bug report", exc_info=True)
|
|
return ""
|
|
|
|
|
|
@router.get("/bundle")
|
|
async def generate_support_bundle(
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
|
):
|
|
"""Generate a support bundle ZIP file for issue reporting."""
|
|
# Check if debug logging is enabled and collect sensitive values for redaction
|
|
async with async_session() as db:
|
|
enabled, _enabled_at = await _get_debug_setting(db)
|
|
|
|
if not enabled:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Debug logging must be enabled before generating a support bundle. "
|
|
"Please enable debug logging, reproduce the issue, then generate the bundle.",
|
|
)
|
|
|
|
# Collect known sensitive values for log redaction
|
|
sensitive_strings: dict[str, str] = {}
|
|
|
|
# Printer names, serial numbers, IP addresses, and access codes
|
|
result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
|
|
for name, serial, ip_address, access_code in result.all():
|
|
if name:
|
|
sensitive_strings[name] = "[PRINTER]"
|
|
if serial:
|
|
sensitive_strings[serial] = "[SERIAL]"
|
|
if ip_address:
|
|
sensitive_strings[ip_address] = "[IP]"
|
|
if access_code:
|
|
sensitive_strings[access_code] = "[ACCESS_CODE]"
|
|
|
|
# Auth usernames
|
|
result = await db.execute(select(User.username))
|
|
for (username,) in result.all():
|
|
if username:
|
|
sensitive_strings[username] = "[USER]"
|
|
|
|
# Bambu Cloud email
|
|
result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
|
|
cloud_email = result.scalar_one_or_none()
|
|
if cloud_email:
|
|
sensitive_strings[cloud_email] = "[EMAIL]"
|
|
|
|
# Collect support info
|
|
support_info = await _collect_support_info()
|
|
|
|
# Create ZIP in memory
|
|
zip_buffer = io.BytesIO()
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
|
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
# Add support info JSON
|
|
zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
|
|
|
|
# Add log file
|
|
log_content = _get_log_content(sensitive_strings=sensitive_strings)
|
|
zf.writestr("bambuddy.log", log_content)
|
|
|
|
zip_buffer.seek(0)
|
|
|
|
filename = f"bambuddy-support-{timestamp}.zip"
|
|
logger.info("Generated support bundle: %s", filename)
|
|
|
|
return StreamingResponse(
|
|
zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
|
|
)
|
|
|
|
|
|
async def init_debug_logging():
|
|
"""Initialize debug logging state from database on startup."""
|
|
try:
|
|
async with async_session() as db:
|
|
enabled, _ = await _get_debug_setting(db)
|
|
|
|
if enabled:
|
|
_apply_log_level(True)
|
|
logger.info("Debug logging restored from previous session")
|
|
except Exception as e:
|
|
logger.warning("Could not restore debug logging state: %s", e)
|