tcp_proxy.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203
  1. """Proxy for slicer-to-printer communication.
  2. This module provides both transparent TCP proxying and TLS-terminating
  3. proxying for forwarding data between a slicer and a real Bambu printer,
  4. enabling remote printing over any network connection.
  5. Most protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —
  6. raw bytes are forwarded without decryption, preserving end-to-end TLS
  7. between slicer and printer. Only MQTT is TLS-terminated so Bambuddy can
  8. rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
  9. """
  10. # ruff: noqa: N801
  11. import asyncio
  12. import logging
  13. import re
  14. import ssl
  15. import subprocess
  16. from collections.abc import Callable
  17. from pathlib import Path
  18. logger = logging.getLogger(__name__)
  19. def detect_port_redirect(port: int) -> int | None:
  20. """Detect if iptables redirects a port to another port.
  21. When iptables NAT REDIRECT rules exist (e.g. port redirects), connections
  22. to the original port never reach our socket because iptables intercepts
  23. them in PREROUTING. We must listen on the redirect target instead.
  24. Returns the redirect target port, or None if no redirect is active.
  25. """
  26. # Method 1: Read persistent rules file (doesn't require root)
  27. for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"):
  28. try:
  29. with open(rules_path) as f:
  30. content = f.read()
  31. match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content)
  32. if match:
  33. target = int(match.group(1))
  34. if target != port:
  35. return target
  36. except (FileNotFoundError, PermissionError, OSError):
  37. continue
  38. # Method 2: Query live iptables rules (may require root)
  39. try:
  40. result = subprocess.run( # noqa: S603, S607
  41. ["iptables-save", "-t", "nat"],
  42. capture_output=True,
  43. text=True,
  44. timeout=5,
  45. )
  46. if result.returncode == 0:
  47. match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout)
  48. if match:
  49. target = int(match.group(1))
  50. if target != port:
  51. return target
  52. except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
  53. pass
  54. return None
  55. class TLSProxy:
  56. """TLS terminating proxy that forwards data between client and target.
  57. This proxy terminates TLS on both ends, allowing the slicer to connect
  58. to Bambuddy's certificate while Bambuddy connects to the real printer.
  59. """
  60. def __init__(
  61. self,
  62. name: str,
  63. listen_port: int,
  64. target_host: str,
  65. target_port: int,
  66. server_cert_path: Path,
  67. server_key_path: Path,
  68. on_connect: Callable[[str], None] | None = None,
  69. on_disconnect: Callable[[str], None] | None = None,
  70. bind_address: str = "0.0.0.0", # nosec B104
  71. rewrite_ip: tuple[str, str] | None = None,
  72. ):
  73. """Initialize the TLS proxy.
  74. Args:
  75. name: Friendly name for logging (e.g., "FTP", "MQTT")
  76. listen_port: Port to listen on for incoming connections
  77. target_host: Target printer IP/hostname
  78. target_port: Target printer port
  79. server_cert_path: Path to server certificate (for accepting slicer connections)
  80. server_key_path: Path to server private key
  81. on_connect: Optional callback when client connects (receives client_id)
  82. on_disconnect: Optional callback when client disconnects (receives client_id)
  83. bind_address: IP address to bind to (default: all interfaces)
  84. rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
  85. the printer's real IP with the proxy's bind IP in printer→client data.
  86. This prevents the slicer from discovering the printer's real IP
  87. in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
  88. """
  89. self.name = name
  90. self.listen_port = listen_port
  91. self.target_host = target_host
  92. self.target_port = target_port
  93. self.server_cert_path = server_cert_path
  94. self.server_key_path = server_key_path
  95. self.on_connect = on_connect
  96. self.on_disconnect = on_disconnect
  97. self.bind_address = bind_address
  98. # IP rewriting for printer→client direction
  99. if rewrite_ip:
  100. self._rewrite_old = rewrite_ip[0].encode("utf-8")
  101. self._rewrite_new = rewrite_ip[1].encode("utf-8")
  102. # Also rewrite the integer IP in net.info[].ip fields.
  103. # Bambu printers encode their IP as a little-endian uint32 integer
  104. # in the JSON payload. BambuStudio reads this to set dev_ip.
  105. self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
  106. self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
  107. else:
  108. self._rewrite_old = None
  109. self._rewrite_new = None
  110. self._rewrite_old_int = None
  111. self._rewrite_new_int = None
  112. self._server: asyncio.Server | None = None
  113. self._running = False
  114. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  115. self._server_ssl_context: ssl.SSLContext | None = None
  116. self._client_ssl_context: ssl.SSLContext | None = None
  117. @staticmethod
  118. def _ip_to_le_int_bytes(ip: str) -> bytes:
  119. """Convert an IP address to its little-endian integer JSON representation.
  120. E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
  121. as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
  122. """
  123. import struct as _struct
  124. parts = ip.split(".")
  125. packed = bytes(int(p) for p in parts)
  126. le_int = _struct.unpack("<I", packed)[0]
  127. return str(le_int).encode("utf-8")
  128. def _create_server_ssl_context(self) -> ssl.SSLContext:
  129. """Create SSL context for accepting client (slicer) connections."""
  130. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  131. ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
  132. # Allow older TLS versions for compatibility with slicers
  133. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  134. # Don't require client certificates
  135. ctx.verify_mode = ssl.CERT_NONE
  136. # Match real Bambu printer cipher behaviour on the slicer-facing side
  137. # as well (the #620 fix only patched the printer-facing client context
  138. # below). On hardened distros where OpenSSL `DEFAULT` strips the
  139. # plain-RSA AES-GCM suites, the slicer's ClientHello has no overlap
  140. # with our server's offered suites and the handshake fails before any
  141. # data flows (#1610 audit).
  142. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  143. return ctx
  144. def _create_client_ssl_context(self) -> ssl.SSLContext:
  145. """Create SSL context for connecting to printer."""
  146. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  147. # Don't verify printer's certificate (self-signed)
  148. ctx.check_hostname = False
  149. ctx.verify_mode = ssl.CERT_NONE
  150. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  151. # Bambu printers use plain RSA key exchange (no ECDHE/DHE),
  152. # which modern OpenSSL 3.x defaults exclude. Add them back.
  153. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  154. return ctx
  155. async def start(self) -> None:
  156. """Start the TLS proxy server."""
  157. if self._running:
  158. return
  159. logger.info(
  160. f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
  161. )
  162. try:
  163. self._running = True
  164. # Create SSL contexts
  165. self._server_ssl_context = self._create_server_ssl_context()
  166. self._client_ssl_context = self._create_client_ssl_context()
  167. # Start server with TLS
  168. self._server = await asyncio.start_server(
  169. self._handle_client,
  170. self.bind_address,
  171. self.listen_port,
  172. ssl=self._server_ssl_context,
  173. )
  174. logger.info("%s TLS proxy listening on port %s", self.name, self.listen_port)
  175. async with self._server:
  176. await self._server.serve_forever()
  177. except OSError as e:
  178. if e.errno == 98: # Address already in use
  179. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  180. elif e.errno == 13: # Permission denied
  181. logger.error(
  182. "%s proxy: cannot bind to port %s (permission denied). "
  183. "Port %s requires root or CAP_NET_BIND_SERVICE. "
  184. "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
  185. "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
  186. "or redirect with iptables.",
  187. self.name,
  188. self.listen_port,
  189. self.listen_port,
  190. )
  191. else:
  192. logger.error("%s proxy error: %s", self.name, e)
  193. except asyncio.CancelledError:
  194. logger.debug("%s proxy task cancelled", self.name)
  195. except Exception as e:
  196. logger.error("%s proxy error: %s", self.name, e)
  197. finally:
  198. await self.stop()
  199. async def stop(self) -> None:
  200. """Stop the TLS proxy server."""
  201. logger.info("Stopping %s proxy", self.name)
  202. self._running = False
  203. # Cancel all active connection tasks
  204. for client_id, (task1, task2) in list(self._active_connections.items()):
  205. task1.cancel()
  206. task2.cancel()
  207. if self.on_disconnect:
  208. try:
  209. self.on_disconnect(client_id)
  210. except Exception:
  211. pass # Ignore disconnect callback errors during shutdown
  212. self._active_connections.clear()
  213. if self._server:
  214. try:
  215. self._server.close()
  216. await self._server.wait_closed()
  217. except OSError as e:
  218. logger.debug("Error closing %s proxy server: %s", self.name, e)
  219. self._server = None
  220. async def _handle_client(
  221. self,
  222. client_reader: asyncio.StreamReader,
  223. client_writer: asyncio.StreamWriter,
  224. ) -> None:
  225. """Handle a new client connection by proxying to target."""
  226. peername = client_writer.get_extra_info("peername")
  227. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  228. logger.info("%s proxy: client connected from %s", self.name, client_id)
  229. if self.on_connect:
  230. try:
  231. self.on_connect(client_id)
  232. except Exception:
  233. pass # Ignore connect callback errors; connection proceeds regardless
  234. # Connect to target printer with TLS
  235. try:
  236. printer_reader, printer_writer = await asyncio.wait_for(
  237. asyncio.open_connection(
  238. self.target_host,
  239. self.target_port,
  240. ssl=self._client_ssl_context,
  241. ),
  242. timeout=10.0,
  243. )
  244. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  245. except TimeoutError:
  246. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  247. client_writer.close()
  248. await client_writer.wait_closed()
  249. return
  250. except ssl.SSLError as e:
  251. logger.error(
  252. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  253. )
  254. client_writer.close()
  255. await client_writer.wait_closed()
  256. return
  257. except OSError as e:
  258. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  259. client_writer.close()
  260. await client_writer.wait_closed()
  261. return
  262. # Create bidirectional forwarding tasks
  263. client_to_printer = asyncio.create_task(
  264. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  265. name=f"{self.name}_c2p_{client_id}",
  266. )
  267. printer_to_client = asyncio.create_task(
  268. self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
  269. name=f"{self.name}_p2c_{client_id}",
  270. )
  271. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  272. try:
  273. # Wait for either direction to complete (connection closed)
  274. done, pending = await asyncio.wait(
  275. [client_to_printer, printer_to_client],
  276. return_when=asyncio.FIRST_COMPLETED,
  277. )
  278. # Cancel the other direction
  279. for task in pending:
  280. task.cancel()
  281. try:
  282. await task
  283. except asyncio.CancelledError:
  284. pass # Expected when cancelling the other forwarding direction
  285. except Exception as e:
  286. logger.debug("%s proxy connection error: %s", self.name, e)
  287. finally:
  288. # Clean up
  289. self._active_connections.pop(client_id, None)
  290. for writer in [client_writer, printer_writer]:
  291. try:
  292. writer.close()
  293. await writer.wait_closed()
  294. except OSError:
  295. pass # Best-effort connection cleanup; peer may have disconnected
  296. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  297. if self.on_disconnect:
  298. try:
  299. self.on_disconnect(client_id)
  300. except Exception:
  301. pass # Ignore disconnect callback errors; cleanup continues
  302. @staticmethod
  303. def _rewrite_mqtt_ip(
  304. data: bytes,
  305. old_ip: bytes,
  306. new_ip: bytes,
  307. buffer: bytearray,
  308. extra_replacements: list[tuple[bytes, bytes]] | None = None,
  309. ) -> tuple[bytes, bytearray]:
  310. """Rewrite IP addresses inside MQTT packets, preserving packet framing.
  311. MQTT packets have a variable-length header encoding the remaining
  312. packet length. A naive bytes.replace() would corrupt this framing
  313. when old_ip and new_ip differ in length.
  314. This method parses individual MQTT packets out of the data stream,
  315. performs the replacement only on PUBLISH payloads, and re-encodes
  316. the remaining-length field to match the new size.
  317. Incomplete packets are buffered and returned for the next call.
  318. Args:
  319. extra_replacements: Additional (old, new) byte pairs to replace
  320. (e.g. the integer IP representation in net.info[].ip).
  321. Returns (output_data, remaining_buffer).
  322. """
  323. buffer.extend(data)
  324. # Check if any replacement target exists in the buffer
  325. has_target = old_ip in buffer
  326. if not has_target and extra_replacements:
  327. has_target = any(old in buffer for old, _new in extra_replacements)
  328. if not has_target:
  329. # Fast path: no IP in buffer, but we still need to check for
  330. # incomplete packets at the end that might contain a partial IP.
  331. # For safety, try to parse and emit only complete packets.
  332. result = bytearray()
  333. pos = 0
  334. length = len(buffer)
  335. while pos < length:
  336. packet_start = pos
  337. if pos + 1 >= length:
  338. break
  339. pos += 1 # header byte
  340. # Parse remaining length
  341. remaining_length = 0
  342. multiplier = 1
  343. length_bytes = 0
  344. while pos < length:
  345. encoded_byte = buffer[pos]
  346. pos += 1
  347. remaining_length += (encoded_byte & 0x7F) * multiplier
  348. multiplier *= 128
  349. length_bytes += 1
  350. if (encoded_byte & 0x80) == 0:
  351. break
  352. if length_bytes >= 4:
  353. break
  354. if pos + remaining_length > length:
  355. # Incomplete — keep in buffer
  356. new_buffer = bytearray(buffer[packet_start:])
  357. return bytes(result), new_buffer
  358. pos += remaining_length
  359. result.extend(buffer[packet_start:pos])
  360. # All complete
  361. buffer.clear()
  362. return bytes(result) if result else bytes(data), buffer
  363. # Buffer contains old_ip — parse packets and rewrite
  364. result = bytearray()
  365. pos = 0
  366. length = len(buffer)
  367. while pos < length:
  368. packet_start = pos
  369. if pos >= length:
  370. break
  371. header_byte = buffer[pos]
  372. pos += 1
  373. # Remaining length: variable-length encoding (1-4 bytes)
  374. remaining_length = 0
  375. multiplier = 1
  376. length_bytes = 0
  377. while pos < length:
  378. encoded_byte = buffer[pos]
  379. pos += 1
  380. remaining_length += (encoded_byte & 0x7F) * multiplier
  381. multiplier *= 128
  382. length_bytes += 1
  383. if (encoded_byte & 0x80) == 0:
  384. break
  385. if length_bytes >= 4:
  386. break
  387. # Check if we have enough data for the full packet
  388. if pos + remaining_length > length:
  389. # Incomplete packet — keep in buffer for next call
  390. new_buffer = bytearray(buffer[packet_start:])
  391. return bytes(result), new_buffer
  392. packet_type = (header_byte >> 4) & 0x0F
  393. packet_body = buffer[pos : pos + remaining_length]
  394. pos += remaining_length
  395. # Only rewrite PUBLISH packets (type 3)
  396. needs_rewrite = packet_type == 3 and (
  397. old_ip in packet_body
  398. or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
  399. )
  400. if needs_rewrite:
  401. new_body = bytes(packet_body).replace(old_ip, new_ip)
  402. if extra_replacements:
  403. for old_val, new_val in extra_replacements:
  404. new_body = new_body.replace(old_val, new_val)
  405. # Re-encode: header byte + new remaining length + new body
  406. result.append(header_byte)
  407. # Encode remaining length (MQTT variable-length encoding)
  408. new_remaining = len(new_body)
  409. while True:
  410. encoded_byte = new_remaining % 128
  411. new_remaining //= 128
  412. if new_remaining > 0:
  413. encoded_byte |= 0x80
  414. result.append(encoded_byte)
  415. if new_remaining == 0:
  416. break
  417. result.extend(new_body)
  418. else:
  419. # Pass through unchanged
  420. result.extend(buffer[packet_start:pos])
  421. buffer.clear()
  422. return bytes(result), buffer
  423. async def _forward(
  424. self,
  425. reader: asyncio.StreamReader,
  426. writer: asyncio.StreamWriter,
  427. direction: str,
  428. rewrite_ip: bool = False,
  429. ) -> None:
  430. """Forward data from reader to writer.
  431. Args:
  432. reader: Source stream (already TLS-decrypted)
  433. writer: Destination stream (will be TLS-encrypted by the stream)
  434. direction: Description for logging (e.g., "client→printer")
  435. rewrite_ip: If True and rewrite_ip was configured, replace the
  436. printer's real IP with the proxy's bind IP in the data.
  437. """
  438. do_rewrite = rewrite_ip and self._rewrite_old is not None
  439. rewrite_buffer = bytearray() if do_rewrite else None
  440. rewrite_logged = False
  441. total_bytes = 0
  442. try:
  443. while self._running:
  444. # Read chunk - use reasonable buffer size
  445. data = await reader.read(65536)
  446. if not data:
  447. # Connection closed
  448. break
  449. # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
  450. # to prevent the slicer from bypassing the proxy.
  451. if do_rewrite:
  452. extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
  453. data, rewrite_buffer = self._rewrite_mqtt_ip(
  454. data,
  455. self._rewrite_old,
  456. self._rewrite_new,
  457. rewrite_buffer,
  458. extra_replacements=extra,
  459. )
  460. if not rewrite_logged and data:
  461. if self._rewrite_old in data:
  462. logger.warning(
  463. "%s proxy IP rewrite FAILED — %s still present after rewrite!",
  464. self.name,
  465. self._rewrite_old.decode(),
  466. )
  467. else:
  468. logger.info(
  469. "%s proxy IP rewrite active: %s → %s",
  470. self.name,
  471. self._rewrite_old.decode(),
  472. self._rewrite_new.decode(),
  473. )
  474. rewrite_logged = True
  475. if not data:
  476. continue # All data buffered, waiting for more
  477. # Forward to destination
  478. writer.write(data)
  479. await writer.drain()
  480. total_bytes += len(data)
  481. except asyncio.CancelledError:
  482. pass # Expected when the other forwarding direction closes first
  483. except ConnectionResetError:
  484. logger.debug("%s proxy %s: connection reset", self.name, direction)
  485. except BrokenPipeError:
  486. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  487. except OSError as e:
  488. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  489. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  490. class TCPProxy:
  491. """Raw TCP proxy that forwards data without TLS termination.
  492. Used for protocols where the printer doesn't use TLS (e.g., port 3000
  493. binding/authentication protocol).
  494. """
  495. def __init__(
  496. self,
  497. name: str,
  498. listen_port: int,
  499. target_host: str,
  500. target_port: int,
  501. on_connect: Callable[[str], None] | None = None,
  502. on_disconnect: Callable[[str], None] | None = None,
  503. bind_address: str = "0.0.0.0", # nosec B104
  504. ):
  505. self.name = name
  506. self.listen_port = listen_port
  507. self.target_host = target_host
  508. self.target_port = target_port
  509. self.on_connect = on_connect
  510. self.on_disconnect = on_disconnect
  511. self.bind_address = bind_address
  512. self._server: asyncio.Server | None = None
  513. self._running = False
  514. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  515. async def start(self) -> None:
  516. """Start the TCP proxy server."""
  517. if self._running:
  518. return
  519. logger.info(
  520. "Starting %s TCP proxy: %s:%s → %s:%s",
  521. self.name,
  522. self.bind_address,
  523. self.listen_port,
  524. self.target_host,
  525. self.target_port,
  526. )
  527. try:
  528. self._running = True
  529. self._server = await asyncio.start_server(
  530. self._handle_client,
  531. self.bind_address,
  532. self.listen_port,
  533. )
  534. logger.info("%s TCP proxy listening on port %s", self.name, self.listen_port)
  535. async with self._server:
  536. await self._server.serve_forever()
  537. except OSError as e:
  538. if e.errno == 98: # Address already in use
  539. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  540. else:
  541. logger.error("%s proxy error: %s", self.name, e)
  542. except asyncio.CancelledError:
  543. logger.debug("%s proxy task cancelled", self.name)
  544. except Exception as e:
  545. logger.error("%s proxy error: %s", self.name, e)
  546. finally:
  547. await self.stop()
  548. async def stop(self) -> None:
  549. """Stop the TCP proxy server."""
  550. logger.info("Stopping %s proxy", self.name)
  551. self._running = False
  552. for client_id, (task1, task2) in list(self._active_connections.items()):
  553. task1.cancel()
  554. task2.cancel()
  555. if self.on_disconnect:
  556. try:
  557. self.on_disconnect(client_id)
  558. except Exception:
  559. pass
  560. self._active_connections.clear()
  561. if self._server:
  562. try:
  563. self._server.close()
  564. await self._server.wait_closed()
  565. except OSError as e:
  566. logger.debug("Error closing %s proxy server: %s", self.name, e)
  567. self._server = None
  568. async def _handle_client(
  569. self,
  570. client_reader: asyncio.StreamReader,
  571. client_writer: asyncio.StreamWriter,
  572. ) -> None:
  573. """Handle a new client connection by proxying to target."""
  574. peername = client_writer.get_extra_info("peername")
  575. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  576. logger.info("%s proxy: client connected from %s", self.name, client_id)
  577. if self.on_connect:
  578. try:
  579. self.on_connect(client_id)
  580. except Exception:
  581. pass
  582. try:
  583. printer_reader, printer_writer = await asyncio.wait_for(
  584. asyncio.open_connection(self.target_host, self.target_port),
  585. timeout=10.0,
  586. )
  587. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  588. except TimeoutError:
  589. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  590. client_writer.close()
  591. await client_writer.wait_closed()
  592. return
  593. except OSError as e:
  594. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  595. client_writer.close()
  596. await client_writer.wait_closed()
  597. return
  598. client_to_printer = asyncio.create_task(
  599. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  600. name=f"{self.name}_c2p_{client_id}",
  601. )
  602. printer_to_client = asyncio.create_task(
  603. self._forward(printer_reader, client_writer, f"printer→{client_id}"),
  604. name=f"{self.name}_p2c_{client_id}",
  605. )
  606. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  607. try:
  608. done, pending = await asyncio.wait(
  609. [client_to_printer, printer_to_client],
  610. return_when=asyncio.FIRST_COMPLETED,
  611. )
  612. for task in pending:
  613. task.cancel()
  614. try:
  615. await task
  616. except asyncio.CancelledError:
  617. pass
  618. except Exception as e:
  619. logger.debug("%s proxy connection error: %s", self.name, e)
  620. finally:
  621. self._active_connections.pop(client_id, None)
  622. for writer in [client_writer, printer_writer]:
  623. try:
  624. writer.close()
  625. await writer.wait_closed()
  626. except OSError:
  627. pass
  628. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  629. if self.on_disconnect:
  630. try:
  631. self.on_disconnect(client_id)
  632. except Exception:
  633. pass
  634. async def _forward(
  635. self,
  636. reader: asyncio.StreamReader,
  637. writer: asyncio.StreamWriter,
  638. direction: str,
  639. ) -> None:
  640. """Forward data from reader to writer."""
  641. total_bytes = 0
  642. try:
  643. while self._running:
  644. data = await reader.read(65536)
  645. if not data:
  646. break
  647. writer.write(data)
  648. await writer.drain()
  649. total_bytes += len(data)
  650. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  651. except asyncio.CancelledError:
  652. pass
  653. except ConnectionResetError:
  654. logger.debug("%s proxy %s: connection reset", self.name, direction)
  655. except BrokenPipeError:
  656. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  657. except OSError as e:
  658. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  659. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  660. class SlicerProxyManager:
  661. """Manages FTP and MQTT TLS proxies for a single printer target."""
  662. # Bambu printer ports
  663. PRINTER_FTP_PORT = 990
  664. PRINTER_MQTT_PORT = 8883
  665. PRINTER_FILE_TRANSFER_PORT = 6000
  666. PRINTER_RTSP_PORT = 322 # X1/H2/P2 series camera (A1/P1 use port 6000)
  667. # Undocumented proprietary ports used by some models (A1, P1S, etc.)
  668. # BambuStudio requires port 2024 for printing; OrcaSlicer also needs 2025.
  669. PRINTER_AUX_PORTS = [2024, 2025, 2026]
  670. PRINTER_BIND_PORTS = [3000, 3002]
  671. # Local listen ports - must match what Bambu Studio expects
  672. # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
  673. LOCAL_FTP_PORT = 990
  674. LOCAL_MQTT_PORT = 8883
  675. def __init__(
  676. self,
  677. target_host: str,
  678. cert_path: Path,
  679. key_path: Path,
  680. on_activity: Callable[[str, str], None] | None = None,
  681. bind_address: str = "0.0.0.0", # nosec B104
  682. bind_identity: dict[str, str] | None = None,
  683. ):
  684. """Initialize the slicer proxy manager.
  685. Args:
  686. target_host: Target printer IP address
  687. cert_path: Path to server certificate
  688. key_path: Path to server private key
  689. on_activity: Optional callback for activity logging (name, message)
  690. bind_address: IP address to bind proxy listeners to
  691. bind_identity: Optional dict with keys (serial, model, name, version)
  692. for the bind/detect response. When provided, the proxy responds
  693. to detect requests itself instead of forwarding to the printer.
  694. This ensures the slicer sees the VP identity, not the real printer.
  695. """
  696. self.target_host = target_host
  697. self.cert_path = cert_path
  698. self.key_path = key_path
  699. self.on_activity = on_activity
  700. self.bind_address = bind_address
  701. self.bind_identity = bind_identity
  702. self._ftp_proxy: TCPProxy | None = None
  703. self._mqtt_proxy: TLSProxy | None = None
  704. self._file_transfer_proxy: TCPProxy | None = None
  705. self._rtsp_proxy: TCPProxy | None = None
  706. self._aux_proxies: list[TCPProxy] = []
  707. self._bind_proxies: list[TCPProxy] = []
  708. self._bind_server = None
  709. self._probe_servers: list[asyncio.Server] = []
  710. self._tasks: list[asyncio.Task] = []
  711. # Pre-bind the lifetime-coupled collections so ``stop()`` works if
  712. # called before ``start()`` finishes (rapid mode-switch races).
  713. # Previously ``_ftp_data_proxies`` was first assigned inside ``start()``
  714. # at line ~1520, so an early stop hit AttributeError and left
  715. # sockets stranded.
  716. self._ftp_data_proxies: list[TCPProxy] = []
  717. # Actual FTP listen port — class constant by default; ``start()``
  718. # overwrites with the redirect target when iptables-redirect is
  719. # active. ``get_status()`` reads this so diagnostics probe the
  720. # port that actually has a listener, not the static LOCAL_FTP_PORT.
  721. self._actual_ftp_port: int = self.LOCAL_FTP_PORT
  722. # FTP passive data port range — Bambu printers typically use ports in
  723. # this range for EPSV/PASV data connections. We pre-listen on all of
  724. # them so EPSV works transparently without decrypting FTP control.
  725. FTP_DATA_PORT_MIN = 50000
  726. FTP_DATA_PORT_MAX = 50100
  727. async def start(self) -> None:
  728. """Start proxy services.
  729. Uses transparent TCP proxying for most protocols (FTP, FileTransfer,
  730. Camera) — raw bytes are forwarded without TLS termination, so the
  731. slicer gets the printer's real TLS certificate end-to-end.
  732. Only MQTT is TLS-terminated because we must decrypt the payload to
  733. rewrite the printer's real IP with the proxy's bind IP.
  734. """
  735. logger.info("Starting slicer proxy to %s (transparent mode)", self.target_host)
  736. # Detect iptables port redirect for FTP
  737. ftp_listen_port = self.LOCAL_FTP_PORT
  738. redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
  739. if redirect_target:
  740. logger.info(
  741. "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
  742. self.LOCAL_FTP_PORT,
  743. redirect_target,
  744. redirect_target,
  745. )
  746. ftp_listen_port = redirect_target
  747. # Cache the actual listen port for get_status() / diagnostic so the
  748. # port_ftps check probes the port that actually has a socket.
  749. self._actual_ftp_port = ftp_listen_port
  750. # FTP control — raw TCP pass-through (end-to-end TLS with printer).
  751. # A TLS 1.3 → 1.2 ClientHello-rewrite was attempted to work around
  752. # BambuStudio's libcurl bug on the X1C FTPS data channel (PSK
  753. # session-resumption + CURLE_PARTIAL_FILE). Reverted because the
  754. # rewrite broke the control-channel TLS handshake itself: replacing
  755. # 0x0304 with a duplicate 0x0303 in supported_versions while leaving
  756. # the TLS-1.3-only extensions (key_share, psk_key_exchange_modes,
  757. # signature_algorithms_cert) in place produced a malformed ClientHello
  758. # that the printer or slicer rejected, and the connection closed
  759. # before any data channel was opened. A proper fix needs full TLS
  760. # bumping (terminate + re-establish) with packet-capture work
  761. # that's out of scope for now. X1C proxy-mode FTP uploads remain
  762. # broken — users with X1C should use the non-proxy modes (immediate
  763. # / review / print_queue) which work end-to-end via the VP's own
  764. # FTP server on TLS 1.2.
  765. self._ftp_proxy = TCPProxy(
  766. name="FTP",
  767. listen_port=ftp_listen_port,
  768. target_host=self.target_host,
  769. target_port=self.PRINTER_FTP_PORT,
  770. on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
  771. on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
  772. bind_address=self.bind_address,
  773. )
  774. # FTP data ports — pre-listen on the entire passive port range.
  775. # Since FTP control is encrypted end-to-end, we can't read EPSV
  776. # responses to know which port the printer chose. Instead, we
  777. # listen on every port in the range and forward to the same port
  778. # on the printer. The slicer connects to bind_ip:PORT (from EPSV)
  779. # and we transparently relay to printer_ip:PORT.
  780. self._ftp_data_proxies: list[TCPProxy] = []
  781. for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):
  782. dp = TCPProxy(
  783. name=f"FTP-Data-{port}",
  784. listen_port=port,
  785. target_host=self.target_host,
  786. target_port=port,
  787. bind_address=self.bind_address,
  788. )
  789. self._ftp_data_proxies.append(dp)
  790. # MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)
  791. self._mqtt_proxy = TLSProxy(
  792. name="MQTT",
  793. listen_port=self.LOCAL_MQTT_PORT,
  794. target_host=self.target_host,
  795. target_port=self.PRINTER_MQTT_PORT,
  796. server_cert_path=self.cert_path,
  797. server_key_path=self.key_path,
  798. on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
  799. on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
  800. bind_address=self.bind_address,
  801. rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None, # nosec B104
  802. )
  803. # File transfer — raw TCP pass-through (port 6000)
  804. self._file_transfer_proxy = TCPProxy(
  805. name="FileTransfer",
  806. listen_port=self.PRINTER_FILE_TRANSFER_PORT,
  807. target_host=self.target_host,
  808. target_port=self.PRINTER_FILE_TRANSFER_PORT,
  809. on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
  810. on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
  811. bind_address=self.bind_address,
  812. )
  813. # RTSP camera — raw TCP pass-through (port 322)
  814. self._rtsp_proxy = TCPProxy(
  815. name="RTSP",
  816. listen_port=self.PRINTER_RTSP_PORT,
  817. target_host=self.target_host,
  818. target_port=self.PRINTER_RTSP_PORT,
  819. on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
  820. on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
  821. bind_address=self.bind_address,
  822. )
  823. # Auxiliary ports (2024-2026) — raw TCP pass-through for undocumented
  824. # proprietary services. Required by BambuStudio/OrcaSlicer for some
  825. # models (A1, P1S). Silently ignored if the printer doesn't listen.
  826. for aux_port in self.PRINTER_AUX_PORTS:
  827. self._aux_proxies.append(
  828. TCPProxy(
  829. name=f"Aux-{aux_port}",
  830. listen_port=aux_port,
  831. target_host=self.target_host,
  832. target_port=aux_port,
  833. on_connect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"connected: {cid}"),
  834. on_disconnect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"disconnected: {cid}"),
  835. bind_address=self.bind_address,
  836. )
  837. )
  838. # Bind/auth — respond with VP identity instead of proxying to printer.
  839. # The detect response contains the printer name, serial, model, and
  840. # bind status. Proxying it would leak the real printer's identity and
  841. # cause the slicer to treat it as a different device.
  842. if self.bind_identity:
  843. from backend.app.services.virtual_printer.bind_server import BindServer
  844. self._bind_server = BindServer(
  845. serial=self.bind_identity["serial"],
  846. model=self.bind_identity["model"],
  847. name=self.bind_identity["name"],
  848. version=self.bind_identity.get("version", "01.00.00.00"),
  849. bind_address=self.bind_address,
  850. cert_path=self.cert_path,
  851. key_path=self.key_path,
  852. )
  853. else:
  854. # Fallback: proxy bind requests to the real printer
  855. for bind_port in self.PRINTER_BIND_PORTS:
  856. if bind_port == 3002:
  857. proxy = TLSProxy(
  858. name="Bind-TLS",
  859. listen_port=bind_port,
  860. target_host=self.target_host,
  861. target_port=bind_port,
  862. server_cert_path=self.cert_path,
  863. server_key_path=self.key_path,
  864. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  865. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  866. bind_address=self.bind_address,
  867. )
  868. else:
  869. proxy = TCPProxy(
  870. name="Bind",
  871. listen_port=bind_port,
  872. target_host=self.target_host,
  873. target_port=bind_port,
  874. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  875. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  876. bind_address=self.bind_address,
  877. )
  878. self._bind_proxies.append(proxy)
  879. # Start as background tasks
  880. async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
  881. try:
  882. await proxy.start()
  883. except Exception as e:
  884. logger.error("Slicer proxy %s failed: %s", proxy.name, e)
  885. self._tasks = [
  886. asyncio.create_task(
  887. run_with_logging(self._ftp_proxy),
  888. name="slicer_proxy_ftp",
  889. ),
  890. asyncio.create_task(
  891. run_with_logging(self._mqtt_proxy),
  892. name="slicer_proxy_mqtt",
  893. ),
  894. asyncio.create_task(
  895. run_with_logging(self._file_transfer_proxy),
  896. name="slicer_proxy_file_transfer",
  897. ),
  898. asyncio.create_task(
  899. run_with_logging(self._rtsp_proxy),
  900. name="slicer_proxy_rtsp",
  901. ),
  902. ]
  903. for ap in self._aux_proxies:
  904. self._tasks.append(
  905. asyncio.create_task(
  906. run_with_logging(ap),
  907. name=f"slicer_proxy_aux_{ap.listen_port}",
  908. )
  909. )
  910. if self._bind_server:
  911. self._tasks.append(
  912. asyncio.create_task(
  913. run_with_logging(self._bind_server),
  914. name="slicer_proxy_bind_server",
  915. )
  916. )
  917. for bp in self._bind_proxies:
  918. self._tasks.append(
  919. asyncio.create_task(
  920. run_with_logging(bp),
  921. name=f"slicer_proxy_bind_{bp.listen_port}",
  922. )
  923. )
  924. # FTP data port proxies (50000-50100)
  925. for dp in self._ftp_data_proxies:
  926. self._tasks.append(
  927. asyncio.create_task(
  928. run_with_logging(dp),
  929. name=f"slicer_proxy_ftp_data_{dp.listen_port}",
  930. )
  931. )
  932. # Diagnostic probe: listen on common un-proxied ports to detect
  933. # if the slicer tries to reach a service we don't handle.
  934. if self.bind_address and self.bind_address != "0.0.0.0": # nosec B104
  935. for probe_port in (21, 80, 443):
  936. try:
  937. srv = await asyncio.start_server(
  938. lambda r, w, p=probe_port: self._probe_handler(r, w, p),
  939. self.bind_address,
  940. probe_port,
  941. )
  942. self._probe_servers.append(srv)
  943. except OSError:
  944. pass # Port in use or no permission — skip
  945. if self._probe_servers:
  946. probed = [s.sockets[0].getsockname()[1] for s in self._probe_servers if s.sockets]
  947. logger.info("Proxy diagnostic: probing un-proxied ports %s on %s", probed, self.bind_address)
  948. logger.info(
  949. "Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
  950. self.target_host,
  951. len(self._ftp_data_proxies),
  952. )
  953. # Wait for tasks to complete (they run until cancelled)
  954. # This keeps the start() coroutine alive so the parent task doesn't complete
  955. try:
  956. await asyncio.gather(*self._tasks)
  957. except asyncio.CancelledError:
  958. logger.debug("Slicer proxy start cancelled")
  959. async def stop(self) -> None:
  960. """Stop all proxies."""
  961. logger.info("Stopping slicer proxy")
  962. # Stop proxies
  963. if self._ftp_proxy:
  964. await self._ftp_proxy.stop()
  965. self._ftp_proxy = None
  966. if self._mqtt_proxy:
  967. await self._mqtt_proxy.stop()
  968. self._mqtt_proxy = None
  969. if self._file_transfer_proxy:
  970. await self._file_transfer_proxy.stop()
  971. self._file_transfer_proxy = None
  972. if self._rtsp_proxy:
  973. await self._rtsp_proxy.stop()
  974. self._rtsp_proxy = None
  975. for ap in self._aux_proxies:
  976. await ap.stop()
  977. self._aux_proxies = []
  978. if self._bind_server:
  979. await self._bind_server.stop()
  980. self._bind_server = None
  981. for bp in self._bind_proxies:
  982. await bp.stop()
  983. self._bind_proxies = []
  984. for dp in self._ftp_data_proxies:
  985. await dp.stop()
  986. self._ftp_data_proxies = []
  987. # Probe servers need wait_closed — without it the OS releases the
  988. # bind socket asynchronously and a rapid stop+start cycle (e.g.
  989. # config-change-driven mode switch) can race "address already in
  990. # use" on the probe ports.
  991. for srv in self._probe_servers:
  992. try:
  993. srv.close()
  994. await srv.wait_closed()
  995. except OSError:
  996. pass # Best-effort — port may already be released
  997. self._probe_servers = []
  998. # Cancel tasks
  999. for task in self._tasks:
  1000. task.cancel()
  1001. if self._tasks:
  1002. try:
  1003. await asyncio.wait_for(
  1004. asyncio.gather(*self._tasks, return_exceptions=True),
  1005. timeout=2.0,
  1006. )
  1007. except TimeoutError:
  1008. logger.debug("Some proxy tasks didn't stop in time")
  1009. self._tasks = []
  1010. logger.info("Slicer proxy stopped")
  1011. async def _probe_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int) -> None:
  1012. """Log unexpected connections on un-proxied ports for diagnostics."""
  1013. peername = writer.get_extra_info("peername")
  1014. client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  1015. logger.warning(
  1016. "PROBE: slicer connected to un-proxied port %d from %s — this port may need proxying",
  1017. port,
  1018. client,
  1019. )
  1020. writer.close()
  1021. try:
  1022. await writer.wait_closed()
  1023. except OSError:
  1024. pass
  1025. def _log_activity(self, name: str, message: str) -> None:
  1026. """Log activity via callback if configured."""
  1027. if self.on_activity:
  1028. try:
  1029. self.on_activity(name, message)
  1030. except Exception:
  1031. pass # Ignore activity callback errors; logging is non-critical
  1032. @property
  1033. def is_running(self) -> bool:
  1034. """Check if proxies are running."""
  1035. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  1036. def get_status(self) -> dict:
  1037. """Get proxy status."""
  1038. return {
  1039. "running": self.is_running,
  1040. "target_host": self.target_host,
  1041. # ``_actual_ftp_port`` reflects the iptables-redirected listen
  1042. # port when the docker-host deployment uses
  1043. # ``iptables -t nat -A PREROUTING ... REDIRECT --to-port`` to
  1044. # let non-root containers serve on the printer's 990. Returning
  1045. # the class constant here made the diagnostic probe a port
  1046. # nothing was listening on and report a false fail on every
  1047. # working redirect deployment.
  1048. "ftp_port": self._actual_ftp_port,
  1049. "mqtt_port": self.LOCAL_MQTT_PORT,
  1050. "bind_ports": self.PRINTER_BIND_PORTS,
  1051. "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
  1052. "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
  1053. "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
  1054. }