diagnostic.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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_PROBE_TIMEOUT = 2.0
  24. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  25. """Test TCP connectivity to ip:port. Returns True if something is listening."""
  26. try:
  27. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  28. writer.close()
  29. try:
  30. await writer.wait_closed()
  31. except Exception:
  32. pass
  33. return True
  34. except Exception:
  35. return False
  36. async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
  37. """Run setup checks for a virtual printer.
  38. Args:
  39. vp: The virtual printer DB row.
  40. instance: The running ``VirtualPrinterInstance`` from the manager, or
  41. ``None`` if the VP is not currently instantiated.
  42. """
  43. checks: list[DiagnosticCheck] = []
  44. is_proxy = vp.mode == "proxy"
  45. running = bool(instance and instance.is_running)
  46. # --- VP enabled ---
  47. checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
  48. # --- Instance running ---
  49. if not vp.enabled:
  50. checks.append(DiagnosticCheck(id="running", status="skip"))
  51. else:
  52. checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
  53. # --- Bind interface still exists ---
  54. # A bind IP picked weeks ago can vanish after a Docker restart or a router
  55. # handing out a different lease — the VP then binds nothing and is invisible.
  56. if not vp.bind_ip:
  57. checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
  58. else:
  59. from backend.app.services.network_utils import find_interface_for_ip
  60. iface = find_interface_for_ip(vp.bind_ip)
  61. checks.append(
  62. DiagnosticCheck(
  63. id="bind_interface",
  64. status="pass" if iface else "fail",
  65. params={"bind_ip": vp.bind_ip},
  66. )
  67. )
  68. # --- Access code (non-proxy modes only) ---
  69. if is_proxy:
  70. checks.append(DiagnosticCheck(id="access_code", status="skip"))
  71. else:
  72. checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
  73. # --- Target printer (proxy mode only) ---
  74. if not is_proxy:
  75. checks.append(DiagnosticCheck(id="target_printer", status="skip"))
  76. elif not vp.target_printer_id:
  77. checks.append(DiagnosticCheck(id="target_printer", status="fail"))
  78. else:
  79. from backend.app.services.printer_manager import printer_manager
  80. state = printer_manager.get_status(vp.target_printer_id)
  81. online = bool(state and state.connected)
  82. # A configured-but-offline target degrades proxying but isn't a setup
  83. # error on the VP's side — warn rather than fail.
  84. checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
  85. # --- Service ports actually listening on the bind IP ---
  86. # The decisive check: a service object can exist while its socket never
  87. # bound (port already in use, permission denied) because start errors are
  88. # logged and swallowed. Probe the bind IP directly.
  89. bind_ip = vp.bind_ip
  90. if not running or not bind_ip:
  91. for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
  92. checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
  93. elif is_proxy:
  94. # Proxy mode listens on dynamic ports reported by the proxy manager,
  95. # and runs no bind/detect server.
  96. proxy_status = instance.get_status().get("proxy", {})
  97. ftp_port = proxy_status.get("ftp_port")
  98. mqtt_port = proxy_status.get("mqtt_port")
  99. ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
  100. mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
  101. checks.append(
  102. DiagnosticCheck(
  103. id="port_ftps",
  104. status="pass" if ftp_ok else "fail",
  105. params={"port": ftp_port or PORT_FTPS},
  106. )
  107. )
  108. checks.append(
  109. DiagnosticCheck(
  110. id="port_mqtt",
  111. status="pass" if mqtt_ok else "fail",
  112. params={"port": mqtt_port or PORT_MQTT},
  113. )
  114. )
  115. checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
  116. else:
  117. ftp_ok, mqtt_ok, bind_ok = await asyncio.gather(
  118. _check_port(bind_ip, PORT_FTPS),
  119. _check_port(bind_ip, PORT_MQTT),
  120. _check_port(bind_ip, PORT_BIND),
  121. )
  122. checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
  123. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
  124. checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND}))
  125. # --- TLS certificate ---
  126. # When running, the cert chain must exist on disk for the slicer's TLS
  127. # handshake to succeed. This is a pass/fail on the file; the localized
  128. # detail text reminds the user to import the CA into the slicer.
  129. if not running:
  130. checks.append(DiagnosticCheck(id="certificate", status="skip"))
  131. else:
  132. cert_ok = bool(instance and instance.cert_path.exists())
  133. checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
  134. statuses = {c.status for c in checks}
  135. if "fail" in statuses:
  136. overall = "problems"
  137. elif "warn" in statuses:
  138. overall = "warnings"
  139. else:
  140. overall = "ok"
  141. return VPDiagnosticResult(
  142. vp_id=vp.id,
  143. vp_name=vp.name,
  144. mode=vp.mode,
  145. overall=overall,
  146. checks=checks,
  147. )