bind_server.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """Bind/detect server for virtual printer discovery (ports 3000 + 3002).
  2. Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
  3. or 3002 to perform the "bind with access code" handshake before using
  4. MQTT/FTP.
  5. Port 3000: plain TCP (legacy / some printer models).
  6. Port 3002: TLS (newer firmware, e.g. A1 Mini 01.07.x).
  7. Protocol (same on both ports, only transport differs):
  8. - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
  9. - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
  10. - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
  11. "dev_cap":1,"id":"<serial>","model":"<model>","name":"<name>",
  12. "sequence_id":<int>,"version":"<firmware>"}}
  13. - Connection closes after one exchange.
  14. """
  15. import asyncio
  16. import json
  17. import logging
  18. import ssl
  19. import struct
  20. from pathlib import Path
  21. logger = logging.getLogger(__name__)
  22. BIND_PORT_PLAIN = 3000
  23. BIND_PORT_TLS = 3002
  24. BIND_PORTS = [BIND_PORT_PLAIN, BIND_PORT_TLS]
  25. FRAME_HEADER = b"\xa5\xa5"
  26. FRAME_TRAILER = b"\xa7\xa7"
  27. HEADER_SIZE = 4 # 2 bytes magic + 2 bytes length
  28. TRAILER_SIZE = 2
  29. class BindServer:
  30. """Responds to slicer bind/detect requests on ports 3000 and 3002.
  31. In server mode, Bambuddy IS the printer — it responds with its own
  32. identity so the slicer can discover and bind to it.
  33. Port 3000 is plain TCP, port 3002 is TLS. BambuStudio chooses which
  34. port to use based on the printer model discovered via SSDP.
  35. """
  36. def __init__(
  37. self,
  38. serial: str,
  39. model: str,
  40. name: str,
  41. version: str = "01.00.00.00",
  42. bind_address: str = "0.0.0.0", # nosec B104
  43. cert_path: Path | None = None,
  44. key_path: Path | None = None,
  45. ):
  46. self.serial = serial
  47. self.model = model
  48. self.name = name
  49. self.version = version
  50. self.bind_address = bind_address
  51. self.cert_path = cert_path
  52. self.key_path = key_path
  53. self._servers: list[asyncio.Server] = []
  54. self._running = False
  55. # Set after at least one bind port is listening — see ftp_server.py
  56. # for rationale. Bind server is best-effort across BIND_PORTS, so
  57. # "ready" means "at least one port bound", matching the existing
  58. # serve_forever path.
  59. self.ready = asyncio.Event()
  60. def _create_tls_context(self) -> ssl.SSLContext | None:
  61. """Create SSL context for the TLS bind port (3002)."""
  62. if not self.cert_path or not self.key_path:
  63. return None
  64. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  65. ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
  66. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  67. # Match real Bambu printer cipher behaviour: include the plain-RSA
  68. # AES-GCM suites the slicer's bind/connect path expects. On hardened
  69. # distros (Fedora / RHEL with `update-crypto-policies`, hardened Alpine
  70. # builds) the OpenSSL `DEFAULT` list strips these suites, leaving no
  71. # overlap with the slicer's ClientHello and producing `code=-1` on the
  72. # slicer side (#1610). Same fix the #620 client-side patch applied to
  73. # `tcp_proxy.py::_create_client_ssl_context`; the bind-server / server
  74. # side needs it too.
  75. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  76. ctx.verify_mode = ssl.CERT_NONE
  77. return ctx
  78. async def start(self) -> None:
  79. """Start the bind server on ports 3000 (plain) and 3002 (TLS)."""
  80. if self._running:
  81. return
  82. self._running = True
  83. tls_ctx = self._create_tls_context()
  84. if not tls_ctx:
  85. logger.warning("Bind server: no TLS cert provided, port %s will be plain TCP", BIND_PORT_TLS)
  86. logger.info(
  87. "Starting bind server on ports %s (serial=%s, model=%s, tls=%s)",
  88. BIND_PORTS,
  89. self.serial,
  90. self.model,
  91. tls_ctx is not None,
  92. )
  93. try:
  94. for port in BIND_PORTS:
  95. use_tls = port == BIND_PORT_TLS and tls_ctx is not None
  96. try:
  97. server = await asyncio.start_server(
  98. self._handle_client,
  99. self.bind_address,
  100. port,
  101. ssl=tls_ctx if use_tls else None,
  102. )
  103. self._servers.append(server)
  104. logger.info(
  105. "Bind server listening on %s:%s (%s)",
  106. self.bind_address,
  107. port,
  108. "TLS" if use_tls else "plain",
  109. )
  110. except OSError as e:
  111. if e.errno == 98:
  112. logger.warning("Bind server port %s already in use, skipping", port)
  113. elif e.errno == 13:
  114. logger.warning("Bind server: cannot bind to port %s (permission denied), skipping", port)
  115. else:
  116. logger.warning("Bind server: failed to bind port %s: %s", port, e)
  117. if not self._servers:
  118. logger.error("Bind server: could not bind to any port")
  119. return
  120. self.ready.set()
  121. # Serve all successfully bound ports
  122. await asyncio.gather(*(s.serve_forever() for s in self._servers))
  123. except asyncio.CancelledError:
  124. logger.debug("Bind server task cancelled")
  125. except Exception as e:
  126. logger.error("Bind server error: %s", e)
  127. finally:
  128. await self.stop()
  129. async def stop(self) -> None:
  130. """Stop the bind server."""
  131. logger.info("Stopping bind server")
  132. self._running = False
  133. self.ready.clear()
  134. for server in self._servers:
  135. try:
  136. server.close()
  137. await server.wait_closed()
  138. except OSError as e:
  139. logger.debug("Error closing bind server: %s", e)
  140. self._servers = []
  141. async def _handle_client(
  142. self,
  143. reader: asyncio.StreamReader,
  144. writer: asyncio.StreamWriter,
  145. ) -> None:
  146. """Handle a single bind/detect request from a slicer."""
  147. peername = writer.get_extra_info("peername")
  148. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  149. logger.info("Bind server: client connected from %s", client_id)
  150. try:
  151. # Read the framed message (timeout after 10s)
  152. data = await asyncio.wait_for(reader.read(4096), timeout=10.0)
  153. if not data:
  154. return
  155. # Parse the request
  156. request = self._parse_frame(data)
  157. if request is None:
  158. logger.warning("Bind server: invalid frame from %s", client_id)
  159. return
  160. logger.info("Bind server: received from %s: %s", client_id, request)
  161. # Check if this is a detect command
  162. login = request.get("login", {})
  163. if not isinstance(login, dict) or login.get("command") != "detect":
  164. logger.warning("Bind server: unexpected command from %s: %s", client_id, request)
  165. return
  166. # Build response. `sequence_id` is an INTEGER counter chosen by
  167. # the printer side (not an echo of the slicer's string seq_id).
  168. # The protocol docstring at the top of this file documents the
  169. # asymmetry: slicer sends `"20000"` (string), printer replies
  170. # with an int. The hardcoded 3021 mirrors real-firmware-captured
  171. # value; an earlier audit suggesting we echo the slicer's seq_id
  172. # was wrong and would have broken slicers that validate the
  173. # type (int vs string).
  174. response = {
  175. "login": {
  176. "bind": "free",
  177. "command": "detect",
  178. "connect": "lan",
  179. "dev_cap": 1,
  180. "id": self.serial,
  181. "model": self.model,
  182. "name": self.name,
  183. "sequence_id": 3021,
  184. "version": self.version,
  185. }
  186. }
  187. frame = self._build_frame(response)
  188. writer.write(frame)
  189. await writer.drain()
  190. logger.info("Bind server: sent detect response to %s (serial=%s)", client_id, self.serial)
  191. except TimeoutError:
  192. logger.debug("Bind server: timeout waiting for data from %s", client_id)
  193. except Exception as e:
  194. logger.error("Bind server: error handling %s: %s", client_id, e)
  195. finally:
  196. try:
  197. writer.close()
  198. await writer.wait_closed()
  199. except OSError:
  200. pass
  201. logger.debug("Bind server: client %s disconnected", client_id)
  202. def _parse_frame(self, data: bytes) -> dict | None:
  203. """Parse a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  204. if len(data) < HEADER_SIZE + TRAILER_SIZE:
  205. return None
  206. if data[:2] != FRAME_HEADER:
  207. return None
  208. if data[-2:] != FRAME_TRAILER:
  209. return None
  210. # Length field is total message size (header + json + trailer)
  211. total_len = struct.unpack_from("<H", data, 2)[0]
  212. if total_len != len(data):
  213. logger.debug("Bind frame length mismatch: header says %d, got %d", total_len, len(data))
  214. # JSON payload is between header and trailer
  215. json_bytes = data[HEADER_SIZE:-TRAILER_SIZE]
  216. try:
  217. return json.loads(json_bytes)
  218. except (json.JSONDecodeError, UnicodeDecodeError) as e:
  219. logger.warning("Bind server: failed to parse JSON: %s", e)
  220. return None
  221. def _build_frame(self, payload: dict) -> bytes:
  222. """Build a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  223. json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
  224. total_len = HEADER_SIZE + len(json_bytes) + TRAILER_SIZE
  225. header = FRAME_HEADER + struct.pack("<H", total_len)
  226. return header + json_bytes + FRAME_TRAILER