diagnostic.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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. import os
  16. from backend.app.models.virtual_printer import VirtualPrinter
  17. from backend.app.schemas.printer import DiagnosticCheck
  18. from backend.app.schemas.virtual_printer import VPDiagnosticResult
  19. logger = logging.getLogger(__name__)
  20. # Server-mode listening ports — see virtual_printer/manager.py start_server().
  21. PORT_FTPS = 990 # implicit FTPS — slicer file upload
  22. PORT_MQTT = 8883 # MQTT over TLS — control + status
  23. PORT_BIND = 3002 # bind/detect (TLS) — slicer discovery handshake
  24. PORT_BIND_PLAIN = 3000 # bind/detect (plain) — legacy / some slicer models
  25. _PORT_PROBE_TIMEOUT = 2.0
  26. # Linux capability number for CAP_NET_BIND_SERVICE (linux/capability.h).
  27. _CAP_NET_BIND_SERVICE = 10
  28. def can_bind_privileged_ports() -> bool | None:
  29. """Whether this process is allowed to bind ports below 1024.
  30. Returns ``None`` when that cannot be determined — no procfs to read and not
  31. running as root, i.e. macOS or Windows, where this capability model does not
  32. apply and the caller should skip the check rather than guess.
  33. Reading the effective set covers both ways the permission is granted,
  34. because both are visible at runtime: ``AmbientCapabilities`` in the systemd
  35. unit (or ``cap_add: [NET_BIND_SERVICE]`` in Docker), and
  36. ``setcap cap_net_bind_service=+ep`` on the interpreter binary.
  37. Note this answers "does the process hold the capability", not "can port 990
  38. be bound" — a host with ``net.ipv4.ip_unprivileged_port_start`` lowered can
  39. bind it without holding anything. Callers must treat a False here as a
  40. *possible* explanation for a port that failed to open, never as proof on its
  41. own; the caller in this module only reports it when a probe actually failed.
  42. """
  43. geteuid = getattr(os, "geteuid", None)
  44. if geteuid is not None and geteuid() == 0:
  45. return True
  46. try:
  47. with open("/proc/self/status", encoding="utf-8") as fh:
  48. for line in fh:
  49. if line.startswith("CapEff:"):
  50. caps = int(line.split(":", 1)[1].strip(), 16)
  51. return bool((caps >> _CAP_NET_BIND_SERVICE) & 1)
  52. except (OSError, ValueError):
  53. return None
  54. return None
  55. async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
  56. """Test TCP connectivity to ip:port. Returns True if something is listening."""
  57. try:
  58. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  59. writer.close()
  60. try:
  61. await writer.wait_closed()
  62. except Exception:
  63. pass
  64. return True
  65. except Exception:
  66. return False
  67. async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
  68. """Run setup checks for a virtual printer.
  69. Args:
  70. vp: The virtual printer DB row.
  71. instance: The running ``VirtualPrinterInstance`` from the manager, or
  72. ``None`` if the VP is not currently instantiated.
  73. """
  74. checks: list[DiagnosticCheck] = []
  75. is_proxy = vp.mode == "proxy"
  76. running = bool(instance and instance.is_running)
  77. # --- VP enabled ---
  78. checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
  79. # --- Instance running ---
  80. if not vp.enabled:
  81. checks.append(DiagnosticCheck(id="running", status="skip"))
  82. else:
  83. checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
  84. # --- Bind interface still exists ---
  85. # A bind IP picked weeks ago can vanish after a Docker restart or a router
  86. # handing out a different lease — the VP then binds nothing and is invisible.
  87. if not vp.bind_ip:
  88. checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
  89. else:
  90. from backend.app.services.network_utils import find_interface_for_ip
  91. iface = find_interface_for_ip(vp.bind_ip)
  92. checks.append(
  93. DiagnosticCheck(
  94. id="bind_interface",
  95. status="pass" if iface else "fail",
  96. params={"bind_ip": vp.bind_ip},
  97. )
  98. )
  99. # --- Access code (non-proxy modes only) ---
  100. if is_proxy:
  101. checks.append(DiagnosticCheck(id="access_code", status="skip"))
  102. else:
  103. checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
  104. # --- Target printer (proxy mode only) ---
  105. if not is_proxy:
  106. checks.append(DiagnosticCheck(id="target_printer", status="skip"))
  107. elif not vp.target_printer_id:
  108. checks.append(DiagnosticCheck(id="target_printer", status="fail"))
  109. else:
  110. from backend.app.services.printer_manager import printer_manager
  111. state = printer_manager.get_status(vp.target_printer_id)
  112. online = bool(state and state.connected)
  113. # A configured-but-offline target degrades proxying but isn't a setup
  114. # error on the VP's side — warn rather than fail.
  115. checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
  116. # --- Service ports actually listening on the bind IP ---
  117. # The decisive check: a service object can exist while its socket never
  118. # bound (port already in use, permission denied) because start errors are
  119. # logged and swallowed. Probe the bind IP directly.
  120. bind_ip = vp.bind_ip
  121. ftp_ok: bool | None = None
  122. if not running or not bind_ip:
  123. for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
  124. checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
  125. elif is_proxy:
  126. # Proxy mode listens on dynamic ports reported by the proxy manager,
  127. # and runs no bind/detect server.
  128. proxy_status = instance.get_status().get("proxy", {})
  129. ftp_port = proxy_status.get("ftp_port")
  130. mqtt_port = proxy_status.get("mqtt_port")
  131. ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
  132. mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
  133. checks.append(
  134. DiagnosticCheck(
  135. id="port_ftps",
  136. status="pass" if ftp_ok else "fail",
  137. params={"port": ftp_port or PORT_FTPS},
  138. )
  139. )
  140. checks.append(
  141. DiagnosticCheck(
  142. id="port_mqtt",
  143. status="pass" if mqtt_ok else "fail",
  144. params={"port": mqtt_port or PORT_MQTT},
  145. )
  146. )
  147. checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
  148. else:
  149. # The non-proxy bind server listens on BOTH 3000 (plain) and 3002
  150. # (TLS) per bind_server.py BIND_PORTS — slicers pick either path.
  151. # Probing only 3002 missed half-dead VPs where one listener failed
  152. # to start and the other succeeded; report port_bind as pass only
  153. # when both probes succeed.
  154. ftp_ok, mqtt_ok, bind_tls_ok, bind_plain_ok = await asyncio.gather(
  155. _check_port(bind_ip, PORT_FTPS),
  156. _check_port(bind_ip, PORT_MQTT),
  157. _check_port(bind_ip, PORT_BIND),
  158. _check_port(bind_ip, PORT_BIND_PLAIN),
  159. )
  160. checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
  161. checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
  162. checks.append(
  163. DiagnosticCheck(
  164. id="port_bind",
  165. status="pass" if (bind_tls_ok and bind_plain_ok) else "fail",
  166. params={"port": PORT_BIND, "port_plain": PORT_BIND_PLAIN},
  167. )
  168. )
  169. # --- Privileged port binding ---
  170. # 990 (FTPS) and 322 (RTSP) are below 1024, so a service running as a normal
  171. # user cannot bind them without CAP_NET_BIND_SERVICE. When it is missing the
  172. # sockets never open, and every symptom above is a downstream effect: the
  173. # slicer simply never sees the printer. The EACCES is logged by TCPProxy but
  174. # that is one line in the journal, and the port checks alone report the same
  175. # "nothing is listening" as an ordinary port conflict — which is what sent
  176. # the reporter in #2549 to Discord for several days over one missing line in
  177. # a unit file.
  178. #
  179. # Reported only when a privileged port actually failed to answer. The
  180. # capability can legitimately be absent on a host that fronts these ports
  181. # some other way (an iptables REDIRECT is the documented alternative), and
  182. # flagging a working setup would be noise.
  183. if not running or ftp_ok is None:
  184. checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
  185. else:
  186. has_cap = can_bind_privileged_ports()
  187. if has_cap is None:
  188. # No procfs to read and not obviously root — typically macOS or
  189. # Windows, where this whole capability model does not apply.
  190. checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
  191. else:
  192. checks.append(
  193. DiagnosticCheck(
  194. id="privileged_ports",
  195. status="pass" if (has_cap or ftp_ok) else "fail",
  196. params={"port": PORT_FTPS},
  197. )
  198. )
  199. # --- TLS certificate ---
  200. # When running, the cert chain must exist on disk for the slicer's TLS
  201. # handshake to succeed. This is a pass/fail on the file; the localized
  202. # detail text reminds the user to import the CA into the slicer.
  203. if not running:
  204. checks.append(DiagnosticCheck(id="certificate", status="skip"))
  205. else:
  206. cert_ok = bool(instance and instance.cert_path.exists())
  207. checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
  208. statuses = {c.status for c in checks}
  209. if "fail" in statuses:
  210. overall = "problems"
  211. elif "warn" in statuses:
  212. overall = "warnings"
  213. else:
  214. overall = "ok"
  215. return VPDiagnosticResult(
  216. vp_id=vp.id,
  217. vp_name=vp.name,
  218. mode=vp.mode,
  219. overall=overall,
  220. checks=checks,
  221. )