mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
171 lines
6.9 KiB
Python
171 lines
6.9 KiB
Python
"""Setup diagnostic for a virtual printer.
|
|
|
|
A virtual printer fails for the user in ways a real printer never does: the
|
|
bind IP no longer exists after a host/network change, a service silently
|
|
failed to bind its port, the access code was never set, the slicer was never
|
|
told to trust the CA. The manager swallows per-service start errors
|
|
(``run_with_logging`` in ``start_server``), so a service object can exist
|
|
while nothing is actually listening — the only reliable signal is probing the
|
|
bind IP's ports from the outside, which is what this does.
|
|
|
|
Each check carries a stable ``id`` and a ``status`` of pass / fail / warn /
|
|
skip; the frontend renders the localized title and fix text keyed on that
|
|
id + status.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from backend.app.models.virtual_printer import VirtualPrinter
|
|
from backend.app.schemas.printer import DiagnosticCheck
|
|
from backend.app.schemas.virtual_printer import VPDiagnosticResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Server-mode listening ports — see virtual_printer/manager.py start_server().
|
|
PORT_FTPS = 990 # implicit FTPS — slicer file upload
|
|
PORT_MQTT = 8883 # MQTT over TLS — control + status
|
|
PORT_BIND = 3002 # bind/detect (TLS) — slicer discovery handshake
|
|
|
|
_PORT_PROBE_TIMEOUT = 2.0
|
|
|
|
|
|
async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
|
|
"""Test TCP connectivity to ip:port. Returns True if something is listening."""
|
|
try:
|
|
_reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
|
|
writer.close()
|
|
try:
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
|
|
"""Run setup checks for a virtual printer.
|
|
|
|
Args:
|
|
vp: The virtual printer DB row.
|
|
instance: The running ``VirtualPrinterInstance`` from the manager, or
|
|
``None`` if the VP is not currently instantiated.
|
|
"""
|
|
checks: list[DiagnosticCheck] = []
|
|
is_proxy = vp.mode == "proxy"
|
|
running = bool(instance and instance.is_running)
|
|
|
|
# --- VP enabled ---
|
|
checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
|
|
|
|
# --- Instance running ---
|
|
if not vp.enabled:
|
|
checks.append(DiagnosticCheck(id="running", status="skip"))
|
|
else:
|
|
checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
|
|
|
|
# --- Bind interface still exists ---
|
|
# A bind IP picked weeks ago can vanish after a Docker restart or a router
|
|
# handing out a different lease — the VP then binds nothing and is invisible.
|
|
if not vp.bind_ip:
|
|
checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
|
|
else:
|
|
from backend.app.services.network_utils import find_interface_for_ip
|
|
|
|
iface = find_interface_for_ip(vp.bind_ip)
|
|
checks.append(
|
|
DiagnosticCheck(
|
|
id="bind_interface",
|
|
status="pass" if iface else "fail",
|
|
params={"bind_ip": vp.bind_ip},
|
|
)
|
|
)
|
|
|
|
# --- Access code (non-proxy modes only) ---
|
|
if is_proxy:
|
|
checks.append(DiagnosticCheck(id="access_code", status="skip"))
|
|
else:
|
|
checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
|
|
|
|
# --- Target printer (proxy mode only) ---
|
|
if not is_proxy:
|
|
checks.append(DiagnosticCheck(id="target_printer", status="skip"))
|
|
elif not vp.target_printer_id:
|
|
checks.append(DiagnosticCheck(id="target_printer", status="fail"))
|
|
else:
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
|
state = printer_manager.get_status(vp.target_printer_id)
|
|
online = bool(state and state.connected)
|
|
# A configured-but-offline target degrades proxying but isn't a setup
|
|
# error on the VP's side — warn rather than fail.
|
|
checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
|
|
|
|
# --- Service ports actually listening on the bind IP ---
|
|
# The decisive check: a service object can exist while its socket never
|
|
# bound (port already in use, permission denied) because start errors are
|
|
# logged and swallowed. Probe the bind IP directly.
|
|
bind_ip = vp.bind_ip
|
|
if not running or not bind_ip:
|
|
for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
|
|
checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
|
|
elif is_proxy:
|
|
# Proxy mode listens on dynamic ports reported by the proxy manager,
|
|
# and runs no bind/detect server.
|
|
proxy_status = instance.get_status().get("proxy", {})
|
|
ftp_port = proxy_status.get("ftp_port")
|
|
mqtt_port = proxy_status.get("mqtt_port")
|
|
ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
|
|
mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
|
|
checks.append(
|
|
DiagnosticCheck(
|
|
id="port_ftps",
|
|
status="pass" if ftp_ok else "fail",
|
|
params={"port": ftp_port or PORT_FTPS},
|
|
)
|
|
)
|
|
checks.append(
|
|
DiagnosticCheck(
|
|
id="port_mqtt",
|
|
status="pass" if mqtt_ok else "fail",
|
|
params={"port": mqtt_port or PORT_MQTT},
|
|
)
|
|
)
|
|
checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
|
|
else:
|
|
ftp_ok, mqtt_ok, bind_ok = await asyncio.gather(
|
|
_check_port(bind_ip, PORT_FTPS),
|
|
_check_port(bind_ip, PORT_MQTT),
|
|
_check_port(bind_ip, PORT_BIND),
|
|
)
|
|
checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
|
|
checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
|
|
checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND}))
|
|
|
|
# --- TLS certificate ---
|
|
# When running, the cert chain must exist on disk for the slicer's TLS
|
|
# handshake to succeed. This is a pass/fail on the file; the localized
|
|
# detail text reminds the user to import the CA into the slicer.
|
|
if not running:
|
|
checks.append(DiagnosticCheck(id="certificate", status="skip"))
|
|
else:
|
|
cert_ok = bool(instance and instance.cert_path.exists())
|
|
checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
|
|
|
|
statuses = {c.status for c in checks}
|
|
if "fail" in statuses:
|
|
overall = "problems"
|
|
elif "warn" in statuses:
|
|
overall = "warnings"
|
|
else:
|
|
overall = "ok"
|
|
|
|
return VPDiagnosticResult(
|
|
vp_id=vp.id,
|
|
vp_name=vp.name,
|
|
mode=vp.mode,
|
|
overall=overall,
|
|
checks=checks,
|
|
)
|