tcp_proxy.py 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872
  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 random
  14. import re
  15. import ssl
  16. import subprocess
  17. from collections.abc import Callable
  18. from pathlib import Path
  19. logger = logging.getLogger(__name__)
  20. class _SessionReuseSSLContext:
  21. """Proxy around SSLContext that injects a TLS session into wrap_bio().
  22. vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
  23. on FTP data channels — the data connection must reuse the TLS session from
  24. the control channel. Without this, the printer rejects the data connection
  25. with "522 SSL connection failed: session reuse required".
  26. asyncio's open_connection() calls SSLContext.wrap_bio() internally but
  27. doesn't expose a session parameter. This wrapper intercepts wrap_bio()
  28. to inject the saved control-channel session, enabling session reuse.
  29. """
  30. def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
  31. object.__setattr__(self, "_ctx", ctx)
  32. object.__setattr__(self, "_session", session)
  33. def __getattr__(self, name: str) -> object:
  34. return getattr(self._ctx, name)
  35. def wrap_bio(
  36. self,
  37. incoming: ssl.MemoryBIO,
  38. outgoing: ssl.MemoryBIO,
  39. server_side: bool = False,
  40. server_hostname: str | None = None,
  41. **kwargs: object,
  42. ) -> ssl.SSLObject:
  43. return self._ctx.wrap_bio(
  44. incoming,
  45. outgoing,
  46. server_side=server_side,
  47. server_hostname=server_hostname,
  48. session=self._session,
  49. **kwargs,
  50. )
  51. def detect_port_redirect(port: int) -> int | None:
  52. """Detect if iptables redirects a port to another port.
  53. When iptables NAT REDIRECT rules exist (e.g. port redirects), connections
  54. to the original port never reach our socket because iptables intercepts
  55. them in PREROUTING. We must listen on the redirect target instead.
  56. Returns the redirect target port, or None if no redirect is active.
  57. """
  58. # Method 1: Read persistent rules file (doesn't require root)
  59. for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"):
  60. try:
  61. with open(rules_path) as f:
  62. content = f.read()
  63. match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content)
  64. if match:
  65. target = int(match.group(1))
  66. if target != port:
  67. return target
  68. except (FileNotFoundError, PermissionError, OSError):
  69. continue
  70. # Method 2: Query live iptables rules (may require root)
  71. try:
  72. result = subprocess.run( # noqa: S603, S607
  73. ["iptables-save", "-t", "nat"],
  74. capture_output=True,
  75. text=True,
  76. timeout=5,
  77. )
  78. if result.returncode == 0:
  79. match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout)
  80. if match:
  81. target = int(match.group(1))
  82. if target != port:
  83. return target
  84. except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
  85. pass
  86. return None
  87. class TLSProxy:
  88. """TLS terminating proxy that forwards data between client and target.
  89. This proxy terminates TLS on both ends, allowing the slicer to connect
  90. to Bambuddy's certificate while Bambuddy connects to the real printer.
  91. """
  92. def __init__(
  93. self,
  94. name: str,
  95. listen_port: int,
  96. target_host: str,
  97. target_port: int,
  98. server_cert_path: Path,
  99. server_key_path: Path,
  100. on_connect: Callable[[str], None] | None = None,
  101. on_disconnect: Callable[[str], None] | None = None,
  102. bind_address: str = "0.0.0.0", # nosec B104
  103. rewrite_ip: tuple[str, str] | None = None,
  104. ):
  105. """Initialize the TLS proxy.
  106. Args:
  107. name: Friendly name for logging (e.g., "FTP", "MQTT")
  108. listen_port: Port to listen on for incoming connections
  109. target_host: Target printer IP/hostname
  110. target_port: Target printer port
  111. server_cert_path: Path to server certificate (for accepting slicer connections)
  112. server_key_path: Path to server private key
  113. on_connect: Optional callback when client connects (receives client_id)
  114. on_disconnect: Optional callback when client disconnects (receives client_id)
  115. bind_address: IP address to bind to (default: all interfaces)
  116. rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
  117. the printer's real IP with the proxy's bind IP in printer→client data.
  118. This prevents the slicer from discovering the printer's real IP
  119. in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
  120. """
  121. self.name = name
  122. self.listen_port = listen_port
  123. self.target_host = target_host
  124. self.target_port = target_port
  125. self.server_cert_path = server_cert_path
  126. self.server_key_path = server_key_path
  127. self.on_connect = on_connect
  128. self.on_disconnect = on_disconnect
  129. self.bind_address = bind_address
  130. # IP rewriting for printer→client direction
  131. if rewrite_ip:
  132. self._rewrite_old = rewrite_ip[0].encode("utf-8")
  133. self._rewrite_new = rewrite_ip[1].encode("utf-8")
  134. # Also rewrite the integer IP in net.info[].ip fields.
  135. # Bambu printers encode their IP as a little-endian uint32 integer
  136. # in the JSON payload. BambuStudio reads this to set dev_ip.
  137. self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
  138. self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
  139. else:
  140. self._rewrite_old = None
  141. self._rewrite_new = None
  142. self._rewrite_old_int = None
  143. self._rewrite_new_int = None
  144. self._server: asyncio.Server | None = None
  145. self._running = False
  146. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  147. self._server_ssl_context: ssl.SSLContext | None = None
  148. self._client_ssl_context: ssl.SSLContext | None = None
  149. @staticmethod
  150. def _ip_to_le_int_bytes(ip: str) -> bytes:
  151. """Convert an IP address to its little-endian integer JSON representation.
  152. E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
  153. as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
  154. """
  155. import struct as _struct
  156. parts = ip.split(".")
  157. packed = bytes(int(p) for p in parts)
  158. le_int = _struct.unpack("<I", packed)[0]
  159. return str(le_int).encode("utf-8")
  160. def _create_server_ssl_context(self) -> ssl.SSLContext:
  161. """Create SSL context for accepting client (slicer) connections."""
  162. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  163. ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
  164. # Allow older TLS versions for compatibility with slicers
  165. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  166. # Don't require client certificates
  167. ctx.verify_mode = ssl.CERT_NONE
  168. # Match real Bambu printer cipher behaviour on the slicer-facing side
  169. # as well (the #620 fix only patched the printer-facing client context
  170. # below). On hardened distros where OpenSSL `DEFAULT` strips the
  171. # plain-RSA AES-GCM suites, the slicer's ClientHello has no overlap
  172. # with our server's offered suites and the handshake fails before any
  173. # data flows (#1610 audit).
  174. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  175. return ctx
  176. def _create_client_ssl_context(self) -> ssl.SSLContext:
  177. """Create SSL context for connecting to printer."""
  178. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  179. # Don't verify printer's certificate (self-signed)
  180. ctx.check_hostname = False
  181. ctx.verify_mode = ssl.CERT_NONE
  182. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  183. # Bambu printers use plain RSA key exchange (no ECDHE/DHE),
  184. # which modern OpenSSL 3.x defaults exclude. Add them back.
  185. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  186. return ctx
  187. async def start(self) -> None:
  188. """Start the TLS proxy server."""
  189. if self._running:
  190. return
  191. logger.info(
  192. f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
  193. )
  194. try:
  195. self._running = True
  196. # Create SSL contexts
  197. self._server_ssl_context = self._create_server_ssl_context()
  198. self._client_ssl_context = self._create_client_ssl_context()
  199. # Start server with TLS
  200. self._server = await asyncio.start_server(
  201. self._handle_client,
  202. self.bind_address,
  203. self.listen_port,
  204. ssl=self._server_ssl_context,
  205. )
  206. logger.info("%s TLS proxy listening on port %s", self.name, self.listen_port)
  207. async with self._server:
  208. await self._server.serve_forever()
  209. except OSError as e:
  210. if e.errno == 98: # Address already in use
  211. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  212. elif e.errno == 13: # Permission denied
  213. logger.error(
  214. "%s proxy: cannot bind to port %s (permission denied). "
  215. "Port %s requires root or CAP_NET_BIND_SERVICE. "
  216. "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
  217. "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
  218. "or redirect with iptables.",
  219. self.name,
  220. self.listen_port,
  221. self.listen_port,
  222. )
  223. else:
  224. logger.error("%s proxy error: %s", self.name, e)
  225. except asyncio.CancelledError:
  226. logger.debug("%s proxy task cancelled", self.name)
  227. except Exception as e:
  228. logger.error("%s proxy error: %s", self.name, e)
  229. finally:
  230. await self.stop()
  231. async def stop(self) -> None:
  232. """Stop the TLS proxy server."""
  233. logger.info("Stopping %s proxy", self.name)
  234. self._running = False
  235. # Cancel all active connection tasks
  236. for client_id, (task1, task2) in list(self._active_connections.items()):
  237. task1.cancel()
  238. task2.cancel()
  239. if self.on_disconnect:
  240. try:
  241. self.on_disconnect(client_id)
  242. except Exception:
  243. pass # Ignore disconnect callback errors during shutdown
  244. self._active_connections.clear()
  245. if self._server:
  246. try:
  247. self._server.close()
  248. await self._server.wait_closed()
  249. except OSError as e:
  250. logger.debug("Error closing %s proxy server: %s", self.name, e)
  251. self._server = None
  252. async def _handle_client(
  253. self,
  254. client_reader: asyncio.StreamReader,
  255. client_writer: asyncio.StreamWriter,
  256. ) -> None:
  257. """Handle a new client connection by proxying to target."""
  258. peername = client_writer.get_extra_info("peername")
  259. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  260. logger.info("%s proxy: client connected from %s", self.name, client_id)
  261. if self.on_connect:
  262. try:
  263. self.on_connect(client_id)
  264. except Exception:
  265. pass # Ignore connect callback errors; connection proceeds regardless
  266. # Connect to target printer with TLS
  267. try:
  268. printer_reader, printer_writer = await asyncio.wait_for(
  269. asyncio.open_connection(
  270. self.target_host,
  271. self.target_port,
  272. ssl=self._client_ssl_context,
  273. ),
  274. timeout=10.0,
  275. )
  276. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  277. except TimeoutError:
  278. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  279. client_writer.close()
  280. await client_writer.wait_closed()
  281. return
  282. except ssl.SSLError as e:
  283. logger.error(
  284. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  285. )
  286. client_writer.close()
  287. await client_writer.wait_closed()
  288. return
  289. except OSError as e:
  290. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  291. client_writer.close()
  292. await client_writer.wait_closed()
  293. return
  294. # Create bidirectional forwarding tasks
  295. client_to_printer = asyncio.create_task(
  296. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  297. name=f"{self.name}_c2p_{client_id}",
  298. )
  299. printer_to_client = asyncio.create_task(
  300. self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
  301. name=f"{self.name}_p2c_{client_id}",
  302. )
  303. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  304. try:
  305. # Wait for either direction to complete (connection closed)
  306. done, pending = await asyncio.wait(
  307. [client_to_printer, printer_to_client],
  308. return_when=asyncio.FIRST_COMPLETED,
  309. )
  310. # Cancel the other direction
  311. for task in pending:
  312. task.cancel()
  313. try:
  314. await task
  315. except asyncio.CancelledError:
  316. pass # Expected when cancelling the other forwarding direction
  317. except Exception as e:
  318. logger.debug("%s proxy connection error: %s", self.name, e)
  319. finally:
  320. # Clean up
  321. self._active_connections.pop(client_id, None)
  322. for writer in [client_writer, printer_writer]:
  323. try:
  324. writer.close()
  325. await writer.wait_closed()
  326. except OSError:
  327. pass # Best-effort connection cleanup; peer may have disconnected
  328. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  329. if self.on_disconnect:
  330. try:
  331. self.on_disconnect(client_id)
  332. except Exception:
  333. pass # Ignore disconnect callback errors; cleanup continues
  334. @staticmethod
  335. def _rewrite_mqtt_ip(
  336. data: bytes,
  337. old_ip: bytes,
  338. new_ip: bytes,
  339. buffer: bytearray,
  340. extra_replacements: list[tuple[bytes, bytes]] | None = None,
  341. ) -> tuple[bytes, bytearray]:
  342. """Rewrite IP addresses inside MQTT packets, preserving packet framing.
  343. MQTT packets have a variable-length header encoding the remaining
  344. packet length. A naive bytes.replace() would corrupt this framing
  345. when old_ip and new_ip differ in length.
  346. This method parses individual MQTT packets out of the data stream,
  347. performs the replacement only on PUBLISH payloads, and re-encodes
  348. the remaining-length field to match the new size.
  349. Incomplete packets are buffered and returned for the next call.
  350. Args:
  351. extra_replacements: Additional (old, new) byte pairs to replace
  352. (e.g. the integer IP representation in net.info[].ip).
  353. Returns (output_data, remaining_buffer).
  354. """
  355. buffer.extend(data)
  356. # Check if any replacement target exists in the buffer
  357. has_target = old_ip in buffer
  358. if not has_target and extra_replacements:
  359. has_target = any(old in buffer for old, _new in extra_replacements)
  360. if not has_target:
  361. # Fast path: no IP in buffer, but we still need to check for
  362. # incomplete packets at the end that might contain a partial IP.
  363. # For safety, try to parse and emit only complete packets.
  364. result = bytearray()
  365. pos = 0
  366. length = len(buffer)
  367. while pos < length:
  368. packet_start = pos
  369. if pos + 1 >= length:
  370. break
  371. pos += 1 # header byte
  372. # Parse remaining length
  373. remaining_length = 0
  374. multiplier = 1
  375. length_bytes = 0
  376. while pos < length:
  377. encoded_byte = buffer[pos]
  378. pos += 1
  379. remaining_length += (encoded_byte & 0x7F) * multiplier
  380. multiplier *= 128
  381. length_bytes += 1
  382. if (encoded_byte & 0x80) == 0:
  383. break
  384. if length_bytes >= 4:
  385. break
  386. if pos + remaining_length > length:
  387. # Incomplete — keep in buffer
  388. new_buffer = bytearray(buffer[packet_start:])
  389. return bytes(result), new_buffer
  390. pos += remaining_length
  391. result.extend(buffer[packet_start:pos])
  392. # All complete
  393. buffer.clear()
  394. return bytes(result) if result else bytes(data), buffer
  395. # Buffer contains old_ip — parse packets and rewrite
  396. result = bytearray()
  397. pos = 0
  398. length = len(buffer)
  399. while pos < length:
  400. packet_start = pos
  401. if pos >= length:
  402. break
  403. header_byte = buffer[pos]
  404. pos += 1
  405. # Remaining length: variable-length encoding (1-4 bytes)
  406. remaining_length = 0
  407. multiplier = 1
  408. length_bytes = 0
  409. while pos < length:
  410. encoded_byte = buffer[pos]
  411. pos += 1
  412. remaining_length += (encoded_byte & 0x7F) * multiplier
  413. multiplier *= 128
  414. length_bytes += 1
  415. if (encoded_byte & 0x80) == 0:
  416. break
  417. if length_bytes >= 4:
  418. break
  419. # Check if we have enough data for the full packet
  420. if pos + remaining_length > length:
  421. # Incomplete packet — keep in buffer for next call
  422. new_buffer = bytearray(buffer[packet_start:])
  423. return bytes(result), new_buffer
  424. packet_type = (header_byte >> 4) & 0x0F
  425. packet_body = buffer[pos : pos + remaining_length]
  426. pos += remaining_length
  427. # Only rewrite PUBLISH packets (type 3)
  428. needs_rewrite = packet_type == 3 and (
  429. old_ip in packet_body
  430. or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
  431. )
  432. if needs_rewrite:
  433. new_body = bytes(packet_body).replace(old_ip, new_ip)
  434. if extra_replacements:
  435. for old_val, new_val in extra_replacements:
  436. new_body = new_body.replace(old_val, new_val)
  437. # Re-encode: header byte + new remaining length + new body
  438. result.append(header_byte)
  439. # Encode remaining length (MQTT variable-length encoding)
  440. new_remaining = len(new_body)
  441. while True:
  442. encoded_byte = new_remaining % 128
  443. new_remaining //= 128
  444. if new_remaining > 0:
  445. encoded_byte |= 0x80
  446. result.append(encoded_byte)
  447. if new_remaining == 0:
  448. break
  449. result.extend(new_body)
  450. else:
  451. # Pass through unchanged
  452. result.extend(buffer[packet_start:pos])
  453. buffer.clear()
  454. return bytes(result), buffer
  455. async def _forward(
  456. self,
  457. reader: asyncio.StreamReader,
  458. writer: asyncio.StreamWriter,
  459. direction: str,
  460. rewrite_ip: bool = False,
  461. ) -> None:
  462. """Forward data from reader to writer.
  463. Args:
  464. reader: Source stream (already TLS-decrypted)
  465. writer: Destination stream (will be TLS-encrypted by the stream)
  466. direction: Description for logging (e.g., "client→printer")
  467. rewrite_ip: If True and rewrite_ip was configured, replace the
  468. printer's real IP with the proxy's bind IP in the data.
  469. """
  470. do_rewrite = rewrite_ip and self._rewrite_old is not None
  471. rewrite_buffer = bytearray() if do_rewrite else None
  472. rewrite_logged = False
  473. total_bytes = 0
  474. try:
  475. while self._running:
  476. # Read chunk - use reasonable buffer size
  477. data = await reader.read(65536)
  478. if not data:
  479. # Connection closed
  480. break
  481. # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
  482. # to prevent the slicer from bypassing the proxy.
  483. if do_rewrite:
  484. extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
  485. data, rewrite_buffer = self._rewrite_mqtt_ip(
  486. data,
  487. self._rewrite_old,
  488. self._rewrite_new,
  489. rewrite_buffer,
  490. extra_replacements=extra,
  491. )
  492. if not rewrite_logged and data:
  493. if self._rewrite_old in data:
  494. logger.warning(
  495. "%s proxy IP rewrite FAILED — %s still present after rewrite!",
  496. self.name,
  497. self._rewrite_old.decode(),
  498. )
  499. else:
  500. logger.info(
  501. "%s proxy IP rewrite active: %s → %s",
  502. self.name,
  503. self._rewrite_old.decode(),
  504. self._rewrite_new.decode(),
  505. )
  506. rewrite_logged = True
  507. if not data:
  508. continue # All data buffered, waiting for more
  509. # Forward to destination
  510. writer.write(data)
  511. await writer.drain()
  512. total_bytes += len(data)
  513. except asyncio.CancelledError:
  514. pass # Expected when the other forwarding direction closes first
  515. except ConnectionResetError:
  516. logger.debug("%s proxy %s: connection reset", self.name, direction)
  517. except BrokenPipeError:
  518. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  519. except OSError as e:
  520. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  521. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  522. class TCPProxy:
  523. """Raw TCP proxy that forwards data without TLS termination.
  524. Used for protocols where the printer doesn't use TLS (e.g., port 3000
  525. binding/authentication protocol).
  526. """
  527. def __init__(
  528. self,
  529. name: str,
  530. listen_port: int,
  531. target_host: str,
  532. target_port: int,
  533. on_connect: Callable[[str], None] | None = None,
  534. on_disconnect: Callable[[str], None] | None = None,
  535. bind_address: str = "0.0.0.0", # nosec B104
  536. ):
  537. self.name = name
  538. self.listen_port = listen_port
  539. self.target_host = target_host
  540. self.target_port = target_port
  541. self.on_connect = on_connect
  542. self.on_disconnect = on_disconnect
  543. self.bind_address = bind_address
  544. self._server: asyncio.Server | None = None
  545. self._running = False
  546. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  547. async def start(self) -> None:
  548. """Start the TCP proxy server."""
  549. if self._running:
  550. return
  551. logger.info(
  552. "Starting %s TCP proxy: %s:%s → %s:%s",
  553. self.name,
  554. self.bind_address,
  555. self.listen_port,
  556. self.target_host,
  557. self.target_port,
  558. )
  559. try:
  560. self._running = True
  561. self._server = await asyncio.start_server(
  562. self._handle_client,
  563. self.bind_address,
  564. self.listen_port,
  565. )
  566. logger.info("%s TCP proxy listening on port %s", self.name, self.listen_port)
  567. async with self._server:
  568. await self._server.serve_forever()
  569. except OSError as e:
  570. if e.errno == 98: # Address already in use
  571. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  572. else:
  573. logger.error("%s proxy error: %s", self.name, e)
  574. except asyncio.CancelledError:
  575. logger.debug("%s proxy task cancelled", self.name)
  576. except Exception as e:
  577. logger.error("%s proxy error: %s", self.name, e)
  578. finally:
  579. await self.stop()
  580. async def stop(self) -> None:
  581. """Stop the TCP proxy server."""
  582. logger.info("Stopping %s proxy", self.name)
  583. self._running = False
  584. for client_id, (task1, task2) in list(self._active_connections.items()):
  585. task1.cancel()
  586. task2.cancel()
  587. if self.on_disconnect:
  588. try:
  589. self.on_disconnect(client_id)
  590. except Exception:
  591. pass
  592. self._active_connections.clear()
  593. if self._server:
  594. try:
  595. self._server.close()
  596. await self._server.wait_closed()
  597. except OSError as e:
  598. logger.debug("Error closing %s proxy server: %s", self.name, e)
  599. self._server = None
  600. async def _handle_client(
  601. self,
  602. client_reader: asyncio.StreamReader,
  603. client_writer: asyncio.StreamWriter,
  604. ) -> None:
  605. """Handle a new client connection by proxying to target."""
  606. peername = client_writer.get_extra_info("peername")
  607. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  608. logger.info("%s proxy: client connected from %s", self.name, client_id)
  609. if self.on_connect:
  610. try:
  611. self.on_connect(client_id)
  612. except Exception:
  613. pass
  614. try:
  615. printer_reader, printer_writer = await asyncio.wait_for(
  616. asyncio.open_connection(self.target_host, self.target_port),
  617. timeout=10.0,
  618. )
  619. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  620. except TimeoutError:
  621. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  622. client_writer.close()
  623. await client_writer.wait_closed()
  624. return
  625. except OSError as e:
  626. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  627. client_writer.close()
  628. await client_writer.wait_closed()
  629. return
  630. client_to_printer = asyncio.create_task(
  631. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  632. name=f"{self.name}_c2p_{client_id}",
  633. )
  634. printer_to_client = asyncio.create_task(
  635. self._forward(printer_reader, client_writer, f"printer→{client_id}"),
  636. name=f"{self.name}_p2c_{client_id}",
  637. )
  638. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  639. try:
  640. done, pending = await asyncio.wait(
  641. [client_to_printer, printer_to_client],
  642. return_when=asyncio.FIRST_COMPLETED,
  643. )
  644. for task in pending:
  645. task.cancel()
  646. try:
  647. await task
  648. except asyncio.CancelledError:
  649. pass
  650. except Exception as e:
  651. logger.debug("%s proxy connection error: %s", self.name, e)
  652. finally:
  653. self._active_connections.pop(client_id, None)
  654. for writer in [client_writer, printer_writer]:
  655. try:
  656. writer.close()
  657. await writer.wait_closed()
  658. except OSError:
  659. pass
  660. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  661. if self.on_disconnect:
  662. try:
  663. self.on_disconnect(client_id)
  664. except Exception:
  665. pass
  666. async def _forward(
  667. self,
  668. reader: asyncio.StreamReader,
  669. writer: asyncio.StreamWriter,
  670. direction: str,
  671. ) -> None:
  672. """Forward data from reader to writer."""
  673. total_bytes = 0
  674. try:
  675. while self._running:
  676. data = await reader.read(65536)
  677. if not data:
  678. break
  679. writer.write(data)
  680. await writer.drain()
  681. total_bytes += len(data)
  682. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  683. except asyncio.CancelledError:
  684. pass
  685. except ConnectionResetError:
  686. logger.debug("%s proxy %s: connection reset", self.name, direction)
  687. except BrokenPipeError:
  688. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  689. except OSError as e:
  690. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  691. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  692. class FTPTLSProxy(TLSProxy):
  693. """FTP-aware TLS proxy that handles passive data connections.
  694. Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
  695. channel, dynamically create TLS data proxies on local ports, and rewrite
  696. the responses so the slicer connects to the proxy instead of the printer.
  697. Without this, FTP passive data connections bypass the proxy and go directly
  698. to the printer, which fails when the slicer can't reach the printer's IP.
  699. """
  700. PASV_PORT_MIN = 50000
  701. PASV_PORT_MAX = 50100
  702. async def stop(self) -> None:
  703. """Stop proxy and clean up data connection servers."""
  704. # Cancel any pending auto_close timeouts so they don't outlive the
  705. # proxy holding a dead server reference. Without this, up to 101
  706. # tasks lingered for ~60 s after stop(), each gripping a server
  707. # ref + an active_connections slot; under rapid mode-switch the
  708. # ports stayed bound long enough to fail the next start().
  709. for task in list(self._auto_close_tasks):
  710. task.cancel()
  711. if self._auto_close_tasks:
  712. await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
  713. self._auto_close_tasks.clear()
  714. # Close all data servers
  715. for server in list(self._data_servers):
  716. try:
  717. server.close()
  718. await server.wait_closed()
  719. except OSError:
  720. pass # Best-effort cleanup of data proxy servers
  721. self._data_servers.clear()
  722. await super().stop()
  723. async def start(self) -> None:
  724. """Start the FTP TLS proxy."""
  725. self._data_servers: list[asyncio.Server] = []
  726. self._auto_close_tasks: list[asyncio.Task] = []
  727. await super().start()
  728. async def _handle_client(
  729. self,
  730. client_reader: asyncio.StreamReader,
  731. client_writer: asyncio.StreamWriter,
  732. ) -> None:
  733. """Handle FTP client with PASV/EPSV-aware response forwarding."""
  734. peername = client_writer.get_extra_info("peername")
  735. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  736. logger.info("%s proxy: client connected from %s", self.name, client_id)
  737. if self.on_connect:
  738. try:
  739. self.on_connect(client_id)
  740. except Exception:
  741. pass # Ignore connect callback errors; connection proceeds regardless
  742. # Determine our local IP from the control connection socket
  743. sockname = client_writer.get_extra_info("sockname")
  744. local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
  745. if local_ip in ("0.0.0.0", "::"): # nosec B104
  746. local_ip = "127.0.0.1"
  747. # Connect to target printer with TLS
  748. try:
  749. printer_reader, printer_writer = await asyncio.wait_for(
  750. asyncio.open_connection(
  751. self.target_host,
  752. self.target_port,
  753. ssl=self._client_ssl_context,
  754. ),
  755. timeout=10.0,
  756. )
  757. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  758. except TimeoutError:
  759. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  760. client_writer.close()
  761. await client_writer.wait_closed()
  762. return
  763. except ssl.SSLError as e:
  764. logger.error(
  765. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  766. )
  767. client_writer.close()
  768. await client_writer.wait_closed()
  769. return
  770. except OSError as e:
  771. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  772. client_writer.close()
  773. await client_writer.wait_closed()
  774. return
  775. # Capture the TLS session from the control channel for data channel
  776. # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
  777. # must present the same TLS session as the control channel.
  778. ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
  779. ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
  780. if ctrl_tls_session:
  781. logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
  782. # Track data channel protection level per session.
  783. # PROT C = cleartext data, PROT P = TLS data.
  784. # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
  785. # If the slicer sends PROT P, we switch to TLS for data connections.
  786. session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
  787. if ctrl_tls_session:
  788. session_state["tls_session"] = ctrl_tls_session
  789. # Client→Printer: intercept EPSV and replace with PASV
  790. # EPSV responses only contain a port (no IP), so the slicer reuses
  791. # the control connection IP. If that IP is the real printer (via
  792. # iptables REDIRECT), the data connection bypasses the proxy.
  793. # PASV responses include an explicit IP that we can rewrite.
  794. client_to_printer = asyncio.create_task(
  795. self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
  796. name=f"{self.name}_c2p_{client_id}",
  797. )
  798. # Printer→Client: intercept PASV/EPSV responses
  799. printer_to_client = asyncio.create_task(
  800. self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
  801. name=f"{self.name}_p2c_{client_id}",
  802. )
  803. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  804. try:
  805. done, pending = await asyncio.wait(
  806. [client_to_printer, printer_to_client],
  807. return_when=asyncio.FIRST_COMPLETED,
  808. )
  809. for task in pending:
  810. task.cancel()
  811. try:
  812. await task
  813. except asyncio.CancelledError:
  814. pass # Expected when cancelling the other forwarding direction
  815. except Exception as e:
  816. logger.debug("%s proxy connection error: %s", self.name, e)
  817. finally:
  818. self._active_connections.pop(client_id, None)
  819. for writer in [client_writer, printer_writer]:
  820. try:
  821. writer.close()
  822. await writer.wait_closed()
  823. except OSError:
  824. pass # Best-effort connection cleanup; peer may have disconnected
  825. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  826. if self.on_disconnect:
  827. try:
  828. self.on_disconnect(client_id)
  829. except Exception:
  830. pass # Ignore disconnect callback errors; cleanup continues
  831. async def _forward_ftp_commands(
  832. self,
  833. reader: asyncio.StreamReader,
  834. writer: asyncio.StreamWriter,
  835. direction: str,
  836. session_state: dict[str, str | ssl.SSLSession],
  837. ) -> None:
  838. """Forward FTP client commands, replacing EPSV with PASV.
  839. EPSV responses only contain a port number — the client reuses the
  840. control connection IP for data. When the control IP is the real
  841. printer (due to iptables REDIRECT), EPSV data connections bypass
  842. the proxy. PASV responses include an explicit IP that the proxy
  843. can rewrite to its own address.
  844. Also tracks PROT P/C commands to know whether data connections
  845. should use TLS or cleartext.
  846. """
  847. buffer = b""
  848. total_bytes = 0
  849. try:
  850. while self._running:
  851. data = await reader.read(65536)
  852. if not data:
  853. break
  854. total_bytes += len(data)
  855. buffer += data
  856. output = b""
  857. while b"\r\n" in buffer:
  858. idx = buffer.index(b"\r\n")
  859. line = buffer[:idx]
  860. buffer = buffer[idx + 2 :]
  861. cmd_upper = line.strip().upper()
  862. # Track PROT level for data channel encryption
  863. if cmd_upper == b"PROT P":
  864. session_state["prot"] = "P"
  865. logger.info("FTP data protection: PROT P (TLS)")
  866. elif cmd_upper == b"PROT C":
  867. session_state["prot"] = "C"
  868. logger.info("FTP data protection: PROT C (cleartext)")
  869. output += line + b"\r\n"
  870. if output:
  871. writer.write(output)
  872. await writer.drain()
  873. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  874. except asyncio.CancelledError:
  875. pass # Expected when the other forwarding direction closes first
  876. except ConnectionResetError:
  877. logger.debug("%s proxy %s: connection reset", self.name, direction)
  878. except BrokenPipeError:
  879. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  880. except OSError as e:
  881. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  882. if buffer:
  883. try:
  884. writer.write(buffer)
  885. await writer.drain()
  886. except OSError:
  887. pass # Best-effort flush of remaining FTP command data
  888. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  889. async def _forward_ftp_control(
  890. self,
  891. reader: asyncio.StreamReader,
  892. writer: asyncio.StreamWriter,
  893. direction: str,
  894. local_ip: str,
  895. session_state: dict[str, str | ssl.SSLSession],
  896. ) -> None:
  897. """Forward FTP control channel responses, rewriting PASV/EPSV.
  898. FTP control channel is line-based (\\r\\n terminated). We buffer data
  899. and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
  900. responses to create local data proxies.
  901. """
  902. buffer = b""
  903. total_bytes = 0
  904. try:
  905. while self._running:
  906. data = await reader.read(65536)
  907. if not data:
  908. break
  909. total_bytes += len(data)
  910. buffer += data
  911. output = b""
  912. # Process all complete lines
  913. while b"\r\n" in buffer:
  914. idx = buffer.index(b"\r\n")
  915. line = buffer[:idx]
  916. buffer = buffer[idx + 2 :]
  917. rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
  918. output += rewritten + b"\r\n"
  919. if output:
  920. writer.write(output)
  921. await writer.drain()
  922. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  923. except asyncio.CancelledError:
  924. pass # Expected when the other forwarding direction closes first
  925. except ConnectionResetError:
  926. logger.debug("%s proxy %s: connection reset", self.name, direction)
  927. except BrokenPipeError:
  928. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  929. except OSError as e:
  930. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  931. # Flush any remaining buffered data
  932. if buffer:
  933. try:
  934. writer.write(buffer)
  935. await writer.drain()
  936. except OSError:
  937. pass # Best-effort flush of remaining FTP control data
  938. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  939. async def _maybe_rewrite_pasv(
  940. self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
  941. ) -> bytes:
  942. """Rewrite PASV/EPSV response to point to a local data proxy."""
  943. try:
  944. text = line.decode("utf-8")
  945. except UnicodeDecodeError:
  946. return line
  947. # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
  948. if text.startswith("227 "):
  949. match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
  950. if match:
  951. h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
  952. printer_ip = f"{h1}.{h2}.{h3}.{h4}"
  953. printer_port = p1 * 256 + p2
  954. local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
  955. if local_port:
  956. ip_parts = local_ip.split(".")
  957. lp1 = local_port // 256
  958. lp2 = local_port % 256
  959. rewritten = (
  960. f"227 Entering Passive Mode "
  961. f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
  962. )
  963. logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
  964. return rewritten.encode("utf-8")
  965. else:
  966. logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
  967. else:
  968. logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
  969. # 229 Entering Extended Passive Mode (|||port|)
  970. elif text.startswith("229 "):
  971. match = re.search(r"\(\|\|\|(\d+)\|\)", text)
  972. if match:
  973. printer_port = int(match.group(1))
  974. local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
  975. if local_port:
  976. rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
  977. logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
  978. return rewritten.encode("utf-8")
  979. else:
  980. logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
  981. else:
  982. logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
  983. return line
  984. async def _create_data_proxy(
  985. self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
  986. ) -> int | None:
  987. """Create a one-shot proxy for an FTP data connection.
  988. Prefers the printer's original passive port so the port number stays
  989. the same in the rewritten PASV/EPSV response. This is critical when
  990. the slicer's FTP bounce-attack protection overrides the IP in the PASV
  991. response: the slicer connects to <control_IP>:<port>, and if iptables
  992. REDIRECT maps that port to the local machine, the data proxy must be
  993. listening on the *same* port number.
  994. Falls back to a random port if the original is unavailable.
  995. Uses TLS or cleartext based on the session's PROT level:
  996. - PROT P: TLS on both slicer and printer data connections
  997. - PROT C: cleartext on both sides (common for A1/H2D printers)
  998. Returns the local port number, or None if binding failed.
  999. """
  1000. use_tls = session_state.get("prot") == "P"
  1001. logger.info(
  1002. "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
  1003. printer_ip,
  1004. printer_port,
  1005. "TLS" if use_tls else "cleartext",
  1006. )
  1007. # Get control channel TLS session for data channel reuse
  1008. tls_session = session_state.get("tls_session") if use_tls else None
  1009. # Try the printer's original port first — this ensures the port
  1010. # matches even when bounce protection or iptables REDIRECT is in play.
  1011. try:
  1012. await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
  1013. logger.info("FTP data proxy: using printer's port %s", printer_port)
  1014. return printer_port
  1015. except OSError as e:
  1016. logger.debug(
  1017. "FTP data proxy: printer port %s unavailable (%s), trying random",
  1018. printer_port,
  1019. e,
  1020. )
  1021. for _attempt in range(10):
  1022. port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
  1023. try:
  1024. await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
  1025. logger.info("FTP data proxy: using random port %s", port)
  1026. return port
  1027. except OSError:
  1028. continue
  1029. logger.error("Failed to bind FTP data proxy port after 10 attempts")
  1030. return None
  1031. async def _start_data_proxy_server(
  1032. self,
  1033. port: int,
  1034. printer_ip: str,
  1035. printer_port: int,
  1036. use_tls: bool,
  1037. tls_session: ssl.SSLSession | None = None,
  1038. ) -> None:
  1039. """Start a one-shot server for one FTP data connection.
  1040. When the slicer connects, immediately connects to the printer's data
  1041. port and buffers any slicer data until the printer connection is ready.
  1042. This handles zero-byte uploads (verify_job) where the slicer closes
  1043. the data channel before a naive proxy would finish its TLS handshake.
  1044. The slicer-side listener is ALWAYS cleartext. Even when the slicer
  1045. sends PROT P on the control channel, Bambu Studio does not perform
  1046. a TLS handshake on the data connection — it relies on the implicit
  1047. FTPS control channel for authentication and sends data unencrypted.
  1048. The printer-side outbound connection follows the PROT level:
  1049. - PROT P (use_tls=True): TLS to the printer's data port
  1050. - PROT C (use_tls=False): cleartext to the printer's data port
  1051. This mirrors the control channel's TLS-termination architecture.
  1052. Raises OSError if the port is already in use.
  1053. """
  1054. connected = asyncio.Event()
  1055. server_holder: list[asyncio.Server] = []
  1056. # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
  1057. # the data channel even after sending PROT P.
  1058. # Printer side: TLS if PROT P, cleartext if PROT C.
  1059. # For TLS data connections, wrap the SSL context to reuse the
  1060. # control channel's TLS session if available. vsFTPd (X1C) requires
  1061. # require_ssl_reuse — without this, data connections are rejected
  1062. # with "522 SSL connection failed: session reuse required".
  1063. if use_tls and tls_session:
  1064. client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
  1065. logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
  1066. else:
  1067. client_ssl = self._client_ssl_context if use_tls else None
  1068. # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
  1069. # the data channel even after PROT P (confirmed for both H2D and X1C).
  1070. printer_mode = "TLS" if use_tls else "cleartext"
  1071. async def handle_data(
  1072. client_reader: asyncio.StreamReader,
  1073. client_writer: asyncio.StreamWriter,
  1074. ) -> None:
  1075. """Handle one FTP data connection, then close the server."""
  1076. peername = client_writer.get_extra_info("peername")
  1077. data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  1078. logger.info(
  1079. "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
  1080. port,
  1081. printer_mode,
  1082. data_client,
  1083. printer_ip,
  1084. printer_port,
  1085. )
  1086. connected.set()
  1087. # One-shot: close server after accepting first connection
  1088. if server_holder:
  1089. server_holder[0].close()
  1090. printer_writer = None
  1091. try:
  1092. # Buffer any slicer data while connecting to printer.
  1093. # This handles the race where the slicer sends data (or closes
  1094. # for zero-byte files) before the TLS handshake completes.
  1095. slicer_buffer = bytearray()
  1096. slicer_eof = False
  1097. async def buffer_slicer():
  1098. nonlocal slicer_eof
  1099. while True:
  1100. chunk = await client_reader.read(65536)
  1101. if not chunk:
  1102. slicer_eof = True
  1103. return
  1104. slicer_buffer.extend(chunk)
  1105. buffer_task = asyncio.create_task(buffer_slicer())
  1106. # Connect to printer's data port
  1107. printer_reader, printer_writer = await asyncio.wait_for(
  1108. asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
  1109. timeout=10.0,
  1110. )
  1111. logger.info(
  1112. "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
  1113. port,
  1114. printer_mode,
  1115. printer_ip,
  1116. printer_port,
  1117. )
  1118. # Stop buffering
  1119. buffer_task.cancel()
  1120. try:
  1121. await buffer_task
  1122. except asyncio.CancelledError:
  1123. pass
  1124. # Flush buffered slicer data to printer
  1125. logger.info(
  1126. "FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
  1127. port,
  1128. len(slicer_buffer),
  1129. slicer_eof,
  1130. )
  1131. if slicer_buffer:
  1132. printer_writer.write(bytes(slicer_buffer))
  1133. await printer_writer.drain()
  1134. # Forward remaining slicer data to printer, then close the
  1135. # printer side to signal upload complete.
  1136. #
  1137. # Bambu Studio does NOT close the FTP data channel after sending
  1138. # STOR data — it keeps the connection open and waits for the
  1139. # printer to close its side + send 226 on the control channel.
  1140. # A naive bidirectional proxy deadlocks here because the proxy
  1141. # waits for the slicer EOF that never comes.
  1142. #
  1143. # Fix: read slicer data with an idle timeout. Once data has been
  1144. # received and the slicer goes quiet, close the printer side so
  1145. # the printer can send 226. For RETR (download), the printer
  1146. # sends data and closes — the slicer reads until EOF — so this
  1147. # unidirectional approach works for both directions.
  1148. total_c2p = len(slicer_buffer)
  1149. if not slicer_eof:
  1150. # Read remaining slicer data with idle detection.
  1151. # Must be short — Bambu Studio expects 226 almost instantly
  1152. # after sending data. Too long and the slicer times out.
  1153. idle_timeout = 0.3
  1154. while True:
  1155. try:
  1156. chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
  1157. except TimeoutError:
  1158. if total_c2p > 0:
  1159. # Slicer sent data then went idle — upload done
  1160. logger.debug(
  1161. "FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
  1162. port,
  1163. total_c2p,
  1164. )
  1165. break
  1166. continue # No data yet, keep waiting
  1167. if not chunk:
  1168. break # Slicer closed
  1169. printer_writer.write(chunk)
  1170. await printer_writer.drain()
  1171. total_c2p += len(chunk)
  1172. logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
  1173. # Close printer side to signal upload complete.
  1174. # For TLS, close() sends close_notify which the printer treats
  1175. # as end-of-data. The printer then sends 226 on the control
  1176. # channel. For RETR, this is a no-op since the printer closes
  1177. # first and we'd have exited the loop above via EOF.
  1178. try:
  1179. printer_writer.close()
  1180. await printer_writer.wait_closed()
  1181. except OSError:
  1182. pass
  1183. # Wait for 226 response to propagate through the FTP control
  1184. # channel before closing the slicer's data channel.
  1185. #
  1186. # Without this delay, the data channel FIN arrives at the
  1187. # slicer before the 226 response on the control channel.
  1188. # BambuStudio reacts to the data channel FIN within <1ms
  1189. # by sending QUIT + closing the control channel — before
  1190. # 226 arrives (~2-3ms network RTT). This causes verify_job
  1191. # to be treated as failed and shows the login modal.
  1192. #
  1193. # In a direct connection, the printer sends 226 AND closes
  1194. # the data channel simultaneously, so the slicer gets both
  1195. # at once. The delay here emulates that timing.
  1196. if total_c2p > 0:
  1197. await asyncio.sleep(0.5)
  1198. except Exception as e:
  1199. logger.error("FTP data proxy port %s: error: %s", port, e)
  1200. finally:
  1201. for w in [client_writer, printer_writer]:
  1202. if w:
  1203. try:
  1204. w.close()
  1205. await w.wait_closed()
  1206. except OSError:
  1207. pass # Best-effort data connection cleanup
  1208. logger.info("FTP data proxy port %s: connection closed", port)
  1209. server = await asyncio.start_server(
  1210. handle_data,
  1211. "0.0.0.0", # nosec B104
  1212. port,
  1213. # No TLS on slicer side — Bambu Studio doesn't do TLS on data
  1214. # channel even after PROT P (confirmed by connection hang test).
  1215. )
  1216. server_holder.append(server)
  1217. self._data_servers.append(server)
  1218. # Auto-close after 60s if no connection arrives
  1219. async def auto_close() -> None:
  1220. try:
  1221. await asyncio.wait_for(connected.wait(), timeout=60.0)
  1222. except TimeoutError:
  1223. logger.debug("FTP data proxy on port %s timed out, closing", port)
  1224. try:
  1225. server.close()
  1226. await server.wait_closed()
  1227. except OSError:
  1228. pass # Best-effort timeout cleanup
  1229. finally:
  1230. if server in self._data_servers:
  1231. self._data_servers.remove(server)
  1232. # Track the auto_close task so stop() can cancel it; otherwise the
  1233. # 60 s timeout would hold a server reference past proxy teardown.
  1234. ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
  1235. self._auto_close_tasks.append(ac_task)
  1236. ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
  1237. logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
  1238. class SlicerProxyManager:
  1239. """Manages FTP and MQTT TLS proxies for a single printer target."""
  1240. # Bambu printer ports
  1241. PRINTER_FTP_PORT = 990
  1242. PRINTER_MQTT_PORT = 8883
  1243. PRINTER_FILE_TRANSFER_PORT = 6000
  1244. PRINTER_RTSP_PORT = 322 # X1/H2/P2 series camera (A1/P1 use port 6000)
  1245. # Undocumented proprietary ports used by some models (A1, P1S, etc.)
  1246. # BambuStudio requires port 2024 for printing; OrcaSlicer also needs 2025.
  1247. PRINTER_AUX_PORTS = [2024, 2025, 2026]
  1248. PRINTER_BIND_PORTS = [3000, 3002]
  1249. # Local listen ports - must match what Bambu Studio expects
  1250. # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
  1251. LOCAL_FTP_PORT = 990
  1252. LOCAL_MQTT_PORT = 8883
  1253. def __init__(
  1254. self,
  1255. target_host: str,
  1256. cert_path: Path,
  1257. key_path: Path,
  1258. on_activity: Callable[[str, str], None] | None = None,
  1259. bind_address: str = "0.0.0.0", # nosec B104
  1260. bind_identity: dict[str, str] | None = None,
  1261. ):
  1262. """Initialize the slicer proxy manager.
  1263. Args:
  1264. target_host: Target printer IP address
  1265. cert_path: Path to server certificate
  1266. key_path: Path to server private key
  1267. on_activity: Optional callback for activity logging (name, message)
  1268. bind_address: IP address to bind proxy listeners to
  1269. bind_identity: Optional dict with keys (serial, model, name, version)
  1270. for the bind/detect response. When provided, the proxy responds
  1271. to detect requests itself instead of forwarding to the printer.
  1272. This ensures the slicer sees the VP identity, not the real printer.
  1273. """
  1274. self.target_host = target_host
  1275. self.cert_path = cert_path
  1276. self.key_path = key_path
  1277. self.on_activity = on_activity
  1278. self.bind_address = bind_address
  1279. self.bind_identity = bind_identity
  1280. self._ftp_proxy: TCPProxy | None = None
  1281. self._mqtt_proxy: TLSProxy | None = None
  1282. self._file_transfer_proxy: TCPProxy | None = None
  1283. self._rtsp_proxy: TCPProxy | None = None
  1284. self._aux_proxies: list[TCPProxy] = []
  1285. self._bind_proxies: list[TCPProxy] = []
  1286. self._bind_server = None
  1287. self._probe_servers: list[asyncio.Server] = []
  1288. self._tasks: list[asyncio.Task] = []
  1289. # Pre-bind the lifetime-coupled collections so ``stop()`` works if
  1290. # called before ``start()`` finishes (rapid mode-switch races).
  1291. # Previously ``_ftp_data_proxies`` was first assigned inside ``start()``
  1292. # at line ~1520, so an early stop hit AttributeError and left
  1293. # sockets stranded.
  1294. self._ftp_data_proxies: list[TCPProxy] = []
  1295. # Actual FTP listen port — class constant by default; ``start()``
  1296. # overwrites with the redirect target when iptables-redirect is
  1297. # active. ``get_status()`` reads this so diagnostics probe the
  1298. # port that actually has a listener, not the static LOCAL_FTP_PORT.
  1299. self._actual_ftp_port: int = self.LOCAL_FTP_PORT
  1300. # FTP passive data port range — Bambu printers typically use ports in
  1301. # this range for EPSV/PASV data connections. We pre-listen on all of
  1302. # them so EPSV works transparently without decrypting FTP control.
  1303. FTP_DATA_PORT_MIN = 50000
  1304. FTP_DATA_PORT_MAX = 50100
  1305. async def start(self) -> None:
  1306. """Start proxy services.
  1307. Uses transparent TCP proxying for most protocols (FTP, FileTransfer,
  1308. Camera) — raw bytes are forwarded without TLS termination, so the
  1309. slicer gets the printer's real TLS certificate end-to-end.
  1310. Only MQTT is TLS-terminated because we must decrypt the payload to
  1311. rewrite the printer's real IP with the proxy's bind IP.
  1312. """
  1313. logger.info("Starting slicer proxy to %s (transparent mode)", self.target_host)
  1314. # Detect iptables port redirect for FTP
  1315. ftp_listen_port = self.LOCAL_FTP_PORT
  1316. redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
  1317. if redirect_target:
  1318. logger.info(
  1319. "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
  1320. self.LOCAL_FTP_PORT,
  1321. redirect_target,
  1322. redirect_target,
  1323. )
  1324. ftp_listen_port = redirect_target
  1325. # Cache the actual listen port for get_status() / diagnostic so the
  1326. # port_ftps check probes the port that actually has a socket.
  1327. self._actual_ftp_port = ftp_listen_port
  1328. # FTP control — raw TCP pass-through (end-to-end TLS with printer).
  1329. # A TLS 1.3 → 1.2 ClientHello-rewrite was attempted to work around
  1330. # BambuStudio's libcurl bug on the X1C FTPS data channel (PSK
  1331. # session-resumption + CURLE_PARTIAL_FILE). Reverted because the
  1332. # rewrite broke the control-channel TLS handshake itself: replacing
  1333. # 0x0304 with a duplicate 0x0303 in supported_versions while leaving
  1334. # the TLS-1.3-only extensions (key_share, psk_key_exchange_modes,
  1335. # signature_algorithms_cert) in place produced a malformed ClientHello
  1336. # that the printer or slicer rejected, and the connection closed
  1337. # before any data channel was opened. A proper fix needs full TLS
  1338. # bumping (terminate + re-establish) with packet-capture work
  1339. # that's out of scope for now. X1C proxy-mode FTP uploads remain
  1340. # broken — users with X1C should use the non-proxy modes (immediate
  1341. # / review / print_queue) which work end-to-end via the VP's own
  1342. # FTP server on TLS 1.2.
  1343. self._ftp_proxy = TCPProxy(
  1344. name="FTP",
  1345. listen_port=ftp_listen_port,
  1346. target_host=self.target_host,
  1347. target_port=self.PRINTER_FTP_PORT,
  1348. on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
  1349. on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
  1350. bind_address=self.bind_address,
  1351. )
  1352. # FTP data ports — pre-listen on the entire passive port range.
  1353. # Since FTP control is encrypted end-to-end, we can't read EPSV
  1354. # responses to know which port the printer chose. Instead, we
  1355. # listen on every port in the range and forward to the same port
  1356. # on the printer. The slicer connects to bind_ip:PORT (from EPSV)
  1357. # and we transparently relay to printer_ip:PORT.
  1358. self._ftp_data_proxies: list[TCPProxy] = []
  1359. for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):
  1360. dp = TCPProxy(
  1361. name=f"FTP-Data-{port}",
  1362. listen_port=port,
  1363. target_host=self.target_host,
  1364. target_port=port,
  1365. bind_address=self.bind_address,
  1366. )
  1367. self._ftp_data_proxies.append(dp)
  1368. # MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)
  1369. self._mqtt_proxy = TLSProxy(
  1370. name="MQTT",
  1371. listen_port=self.LOCAL_MQTT_PORT,
  1372. target_host=self.target_host,
  1373. target_port=self.PRINTER_MQTT_PORT,
  1374. server_cert_path=self.cert_path,
  1375. server_key_path=self.key_path,
  1376. on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
  1377. on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
  1378. bind_address=self.bind_address,
  1379. rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None, # nosec B104
  1380. )
  1381. # File transfer — raw TCP pass-through (port 6000)
  1382. self._file_transfer_proxy = TCPProxy(
  1383. name="FileTransfer",
  1384. listen_port=self.PRINTER_FILE_TRANSFER_PORT,
  1385. target_host=self.target_host,
  1386. target_port=self.PRINTER_FILE_TRANSFER_PORT,
  1387. on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
  1388. on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
  1389. bind_address=self.bind_address,
  1390. )
  1391. # RTSP camera — raw TCP pass-through (port 322)
  1392. self._rtsp_proxy = TCPProxy(
  1393. name="RTSP",
  1394. listen_port=self.PRINTER_RTSP_PORT,
  1395. target_host=self.target_host,
  1396. target_port=self.PRINTER_RTSP_PORT,
  1397. on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
  1398. on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
  1399. bind_address=self.bind_address,
  1400. )
  1401. # Auxiliary ports (2024-2026) — raw TCP pass-through for undocumented
  1402. # proprietary services. Required by BambuStudio/OrcaSlicer for some
  1403. # models (A1, P1S). Silently ignored if the printer doesn't listen.
  1404. for aux_port in self.PRINTER_AUX_PORTS:
  1405. self._aux_proxies.append(
  1406. TCPProxy(
  1407. name=f"Aux-{aux_port}",
  1408. listen_port=aux_port,
  1409. target_host=self.target_host,
  1410. target_port=aux_port,
  1411. on_connect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"connected: {cid}"),
  1412. on_disconnect=lambda cid, p=aux_port: self._log_activity(f"Aux-{p}", f"disconnected: {cid}"),
  1413. bind_address=self.bind_address,
  1414. )
  1415. )
  1416. # Bind/auth — respond with VP identity instead of proxying to printer.
  1417. # The detect response contains the printer name, serial, model, and
  1418. # bind status. Proxying it would leak the real printer's identity and
  1419. # cause the slicer to treat it as a different device.
  1420. if self.bind_identity:
  1421. from backend.app.services.virtual_printer.bind_server import BindServer
  1422. self._bind_server = BindServer(
  1423. serial=self.bind_identity["serial"],
  1424. model=self.bind_identity["model"],
  1425. name=self.bind_identity["name"],
  1426. version=self.bind_identity.get("version", "01.00.00.00"),
  1427. bind_address=self.bind_address,
  1428. cert_path=self.cert_path,
  1429. key_path=self.key_path,
  1430. )
  1431. else:
  1432. # Fallback: proxy bind requests to the real printer
  1433. for bind_port in self.PRINTER_BIND_PORTS:
  1434. if bind_port == 3002:
  1435. proxy = TLSProxy(
  1436. name="Bind-TLS",
  1437. listen_port=bind_port,
  1438. target_host=self.target_host,
  1439. target_port=bind_port,
  1440. server_cert_path=self.cert_path,
  1441. server_key_path=self.key_path,
  1442. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  1443. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  1444. bind_address=self.bind_address,
  1445. )
  1446. else:
  1447. proxy = TCPProxy(
  1448. name="Bind",
  1449. listen_port=bind_port,
  1450. target_host=self.target_host,
  1451. target_port=bind_port,
  1452. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  1453. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  1454. bind_address=self.bind_address,
  1455. )
  1456. self._bind_proxies.append(proxy)
  1457. # Start as background tasks
  1458. async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
  1459. try:
  1460. await proxy.start()
  1461. except Exception as e:
  1462. logger.error("Slicer proxy %s failed: %s", proxy.name, e)
  1463. self._tasks = [
  1464. asyncio.create_task(
  1465. run_with_logging(self._ftp_proxy),
  1466. name="slicer_proxy_ftp",
  1467. ),
  1468. asyncio.create_task(
  1469. run_with_logging(self._mqtt_proxy),
  1470. name="slicer_proxy_mqtt",
  1471. ),
  1472. asyncio.create_task(
  1473. run_with_logging(self._file_transfer_proxy),
  1474. name="slicer_proxy_file_transfer",
  1475. ),
  1476. asyncio.create_task(
  1477. run_with_logging(self._rtsp_proxy),
  1478. name="slicer_proxy_rtsp",
  1479. ),
  1480. ]
  1481. for ap in self._aux_proxies:
  1482. self._tasks.append(
  1483. asyncio.create_task(
  1484. run_with_logging(ap),
  1485. name=f"slicer_proxy_aux_{ap.listen_port}",
  1486. )
  1487. )
  1488. if self._bind_server:
  1489. self._tasks.append(
  1490. asyncio.create_task(
  1491. run_with_logging(self._bind_server),
  1492. name="slicer_proxy_bind_server",
  1493. )
  1494. )
  1495. for bp in self._bind_proxies:
  1496. self._tasks.append(
  1497. asyncio.create_task(
  1498. run_with_logging(bp),
  1499. name=f"slicer_proxy_bind_{bp.listen_port}",
  1500. )
  1501. )
  1502. # FTP data port proxies (50000-50100)
  1503. for dp in self._ftp_data_proxies:
  1504. self._tasks.append(
  1505. asyncio.create_task(
  1506. run_with_logging(dp),
  1507. name=f"slicer_proxy_ftp_data_{dp.listen_port}",
  1508. )
  1509. )
  1510. # Diagnostic probe: listen on common un-proxied ports to detect
  1511. # if the slicer tries to reach a service we don't handle.
  1512. if self.bind_address and self.bind_address != "0.0.0.0": # nosec B104
  1513. for probe_port in (21, 80, 443):
  1514. try:
  1515. srv = await asyncio.start_server(
  1516. lambda r, w, p=probe_port: self._probe_handler(r, w, p),
  1517. self.bind_address,
  1518. probe_port,
  1519. )
  1520. self._probe_servers.append(srv)
  1521. except OSError:
  1522. pass # Port in use or no permission — skip
  1523. if self._probe_servers:
  1524. probed = [s.sockets[0].getsockname()[1] for s in self._probe_servers if s.sockets]
  1525. logger.info("Proxy diagnostic: probing un-proxied ports %s on %s", probed, self.bind_address)
  1526. logger.info(
  1527. "Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
  1528. self.target_host,
  1529. len(self._ftp_data_proxies),
  1530. )
  1531. # Wait for tasks to complete (they run until cancelled)
  1532. # This keeps the start() coroutine alive so the parent task doesn't complete
  1533. try:
  1534. await asyncio.gather(*self._tasks)
  1535. except asyncio.CancelledError:
  1536. logger.debug("Slicer proxy start cancelled")
  1537. async def stop(self) -> None:
  1538. """Stop all proxies."""
  1539. logger.info("Stopping slicer proxy")
  1540. # Stop proxies
  1541. if self._ftp_proxy:
  1542. await self._ftp_proxy.stop()
  1543. self._ftp_proxy = None
  1544. if self._mqtt_proxy:
  1545. await self._mqtt_proxy.stop()
  1546. self._mqtt_proxy = None
  1547. if self._file_transfer_proxy:
  1548. await self._file_transfer_proxy.stop()
  1549. self._file_transfer_proxy = None
  1550. if self._rtsp_proxy:
  1551. await self._rtsp_proxy.stop()
  1552. self._rtsp_proxy = None
  1553. for ap in self._aux_proxies:
  1554. await ap.stop()
  1555. self._aux_proxies = []
  1556. if self._bind_server:
  1557. await self._bind_server.stop()
  1558. self._bind_server = None
  1559. for bp in self._bind_proxies:
  1560. await bp.stop()
  1561. self._bind_proxies = []
  1562. for dp in self._ftp_data_proxies:
  1563. await dp.stop()
  1564. self._ftp_data_proxies = []
  1565. # Probe servers need wait_closed — without it the OS releases the
  1566. # bind socket asynchronously and a rapid stop+start cycle (e.g.
  1567. # config-change-driven mode switch) can race "address already in
  1568. # use" on the probe ports.
  1569. for srv in self._probe_servers:
  1570. try:
  1571. srv.close()
  1572. await srv.wait_closed()
  1573. except OSError:
  1574. pass # Best-effort — port may already be released
  1575. self._probe_servers = []
  1576. # Cancel tasks
  1577. for task in self._tasks:
  1578. task.cancel()
  1579. if self._tasks:
  1580. try:
  1581. await asyncio.wait_for(
  1582. asyncio.gather(*self._tasks, return_exceptions=True),
  1583. timeout=2.0,
  1584. )
  1585. except TimeoutError:
  1586. logger.debug("Some proxy tasks didn't stop in time")
  1587. self._tasks = []
  1588. logger.info("Slicer proxy stopped")
  1589. async def _probe_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int) -> None:
  1590. """Log unexpected connections on un-proxied ports for diagnostics."""
  1591. peername = writer.get_extra_info("peername")
  1592. client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  1593. logger.warning(
  1594. "PROBE: slicer connected to un-proxied port %d from %s — this port may need proxying",
  1595. port,
  1596. client,
  1597. )
  1598. writer.close()
  1599. try:
  1600. await writer.wait_closed()
  1601. except OSError:
  1602. pass
  1603. def _log_activity(self, name: str, message: str) -> None:
  1604. """Log activity via callback if configured."""
  1605. if self.on_activity:
  1606. try:
  1607. self.on_activity(name, message)
  1608. except Exception:
  1609. pass # Ignore activity callback errors; logging is non-critical
  1610. @property
  1611. def is_running(self) -> bool:
  1612. """Check if proxies are running."""
  1613. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  1614. def get_status(self) -> dict:
  1615. """Get proxy status."""
  1616. return {
  1617. "running": self.is_running,
  1618. "target_host": self.target_host,
  1619. # ``_actual_ftp_port`` reflects the iptables-redirected listen
  1620. # port when the docker-host deployment uses
  1621. # ``iptables -t nat -A PREROUTING ... REDIRECT --to-port`` to
  1622. # let non-root containers serve on the printer's 990. Returning
  1623. # the class constant here made the diagnostic probe a port
  1624. # nothing was listening on and report a false fail on every
  1625. # working redirect deployment.
  1626. "ftp_port": self._actual_ftp_port,
  1627. "mqtt_port": self.LOCAL_MQTT_PORT,
  1628. "bind_ports": self.PRINTER_BIND_PORTS,
  1629. "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
  1630. "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
  1631. "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
  1632. }