diagnostic.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Setup diagnostic for a virtual printer.
  2. A virtual printer fails for the user in ways a real printer never does: the
  3. bind IP no longer exists after a host/network change, a service silently
  4. failed to bind its port, the access code was never set, the slicer was never
  5. told to trust the CA. The manager swallows per-service start errors
  6. (``run_with_logging`` in ``start_server``), so a service object can exist
  7. while nothing is actually listening — the only reliable signal is probing the
  8. bind IP's ports from the outside, which is what this does.
  9. Each check carries a stable ``id`` and a ``status`` of pass / fail / warn /
  10. skip; the frontend renders the localized title and fix text keyed on that
  11. id + status.
  12. """
  13. import asyncio
  14. import logging
  15. from backend.app.models.virtual_printer import VirtualPrinter
  16. from backend.app.schemas.printer import DiagnosticCheck
  17. from backend.app.schemas.virtual_printer import VPDiagnosticResult
  18. logger = logging.getLogger(__name__)
  19. # Server-mode listening ports — see virtual_printer/manager.py start_server().
  20. PORT_FTPS = 990 # implicit FTPS — slicer file upload
  21. PORT_MQTT = 8883 # MQTT over TLS — control + status
  22. PORT_BIND = 3002 # bind/detect (TLS) — slicer discovery handshake
  23. PORT_BIND_PLAIN = 3000 # bind/detect (plain) — legacy / some slicer models
  24. _PORT_PROBE_TIMEOUT = 2.0
  25. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  26. """Test TCP connectivity to ip:port. Returns True if something is listening."""
  27. try:
  28. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  29. writer.close()
  30. try:
  31. await writer.wait_closed()
  32. except Exception:
  33. pass
  34. return True
  35. except Exception:
  36. return False
  37. async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
  38. """Run setup checks for a virtual printer.
  39. Args:
  40. vp: The virtual printer DB row.
  41. instance: The running ``VirtualPrinterInstance`` from the manager, or
  42. ``None`` if the VP is not currently instantiated.
  43. """
  44. checks: list[DiagnosticCheck] = []
  45. is_proxy = vp.mode == "proxy"
  46. running = bool(instance and instance.is_running)
  47. # --- VP enabled ---
  48. checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
  49. # --- Instance running ---
  50. if not vp.enabled:
  51. checks.append(DiagnosticCheck(id="running", status="skip"))
  52. else:
  53. checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
  54. # --- Bind interface still exists ---
  55. # A bind IP picked weeks ago can vanish after a Docker restart or a router
  56. # handing out a different lease — the VP then binds nothing and is invisible.
  57. if not vp.bind_ip:
  58. checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
  59. else:
  60. from backend.app.services.network_utils import find_interface_for_ip
  61. iface = find_interface_for_ip(vp.bind_ip)
  62. checks.append(
  63. DiagnosticCheck(
  64. id="bind_interface",
  65. status="pass" if iface else "fail",
  66. params={"bind_ip": vp.bind_ip},
  67. )
  68. )
  69. # --- Access code (non-proxy modes only) ---
  70. if is_proxy:
  71. checks.append(DiagnosticCheck(id="access_code", status="skip"))
  72. else:
  73. checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
  74. # --- Target printer (proxy mode only) ---
  75. if not is_proxy:
  76. checks.append(DiagnosticCheck(id="target_printer", status="skip"))
  77. elif not vp.target_printer_id:
  78. checks.append(DiagnosticCheck(id="target_printer", status="fail"))
  79. else:
  80. from backend.app.services.printer_manager import printer_manager
  81. state = printer_manager.get_status(vp.target_printer_id)
  82. online = bool(state and state.connected)
  83. # A configured-but-offline target degrades proxying but isn't a setup
  84. # error on the VP's side — warn rather than fail.
  85. checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
  86. # --- Service ports actually listening on the bind IP ---
  87. # The decisive check: a service object can exist while its socket never
  88. # bound (port already in use, permission denied) because start errors are
  89. # logged and swallowed. Probe the bind IP directly.
  90. bind_ip = vp.bind_ip
  91. if not running or not bind_ip:
  92. for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
  93. checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
  94. elif is_proxy:
  95. # Proxy mode listens on dynamic ports reported by the proxy manager,
  96. # and runs no bind/detect server.
  97. proxy_status = instance.get_status().get("proxy", {})
  98. ftp_port = proxy_status.get("ftp_port")
  99. mqtt_port = proxy_status.get("mqtt_port")
  100. ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
  101. mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
  102. checks.append(
  103. DiagnosticCheck(
  104. id="port_ftps",
  105. status="pass" if ftp_ok else "fail",
  106. params={"port": ftp_port or PORT_FTPS},
  107. )
  108. )
  109. checks.append(
  110. DiagnosticCheck(
  111. id="port_mqtt",
  112. status="pass" if mqtt_ok else "fail",
  113. params={"port": mqtt_port or PORT_MQTT},
  114. )
  115. )
  116. checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
  117. else:
  118. # The non-proxy bind server listens on BOTH 3000 (plain) and 3002
  119. # (TLS) per bind_server.py BIND_PORTS — slicers pick either path.
  120. # Probing only 3002 missed half-dead VPs where one listener failed
  121. # to start and the other succeeded; report port_bind as pass only
  122. # when both probes succeed.
  123. ftp_ok, mqtt_ok, bind_tls_ok, bind_plain_ok = await asyncio.gather(
  124. _check_port(bind_ip, PORT_FTPS),
  125. _check_port(bind_ip, PORT_MQTT),
  126. _check_port(bind_ip, PORT_BIND),
  127. _check_port(bind_ip, PORT_BIND_PLAIN),
  128. )
  129. checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
  130. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
  131. checks.append(
  132. DiagnosticCheck(
  133. id="port_bind",
  134. status="pass" if (bind_tls_ok and bind_plain_ok) else "fail",
  135. params={"port": PORT_BIND, "port_plain": PORT_BIND_PLAIN},
  136. )
  137. )
  138. # --- TLS certificate ---
  139. # When running, the cert chain must exist on disk for the slicer's TLS
  140. # handshake to succeed. This is a pass/fail on the file; the localized
  141. # detail text reminds the user to import the CA into the slicer.
  142. if not running:
  143. checks.append(DiagnosticCheck(id="certificate", status="skip"))
  144. else:
  145. cert_ok = bool(instance and instance.cert_path.exists())
  146. checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
  147. statuses = {c.status for c in checks}
  148. if "fail" in statuses:
  149. overall = "problems"
  150. elif "warn" in statuses:
  151. overall = "warnings"
  152. else:
  153. overall = "ok"
  154. return VPDiagnosticResult(
  155. vp_id=vp.id,
  156. vp_name=vp.name,
  157. mode=vp.mode,
  158. overall=overall,
  159. checks=checks,
  160. )