| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- """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,
- )
|