tcp_proxy.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246
  1. """TLS proxy for slicer-to-printer communication.
  2. This module provides a TLS terminating proxy that forwards data between
  3. a slicer and a real Bambu printer, enabling remote printing over
  4. any network connection.
  5. Unlike a transparent TCP proxy, this terminates TLS on both ends:
  6. - Slicer connects to Bambuddy using Bambuddy's certificate
  7. - Bambuddy connects to printer using printer's certificate
  8. - Data is decrypted, forwarded, and re-encrypted
  9. """
  10. import asyncio
  11. import logging
  12. import random
  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. ):
  72. """Initialize the TLS proxy.
  73. Args:
  74. name: Friendly name for logging (e.g., "FTP", "MQTT")
  75. listen_port: Port to listen on for incoming connections
  76. target_host: Target printer IP/hostname
  77. target_port: Target printer port
  78. server_cert_path: Path to server certificate (for accepting slicer connections)
  79. server_key_path: Path to server private key
  80. on_connect: Optional callback when client connects (receives client_id)
  81. on_disconnect: Optional callback when client disconnects (receives client_id)
  82. bind_address: IP address to bind to (default: all interfaces)
  83. """
  84. self.name = name
  85. self.listen_port = listen_port
  86. self.target_host = target_host
  87. self.target_port = target_port
  88. self.server_cert_path = server_cert_path
  89. self.server_key_path = server_key_path
  90. self.on_connect = on_connect
  91. self.on_disconnect = on_disconnect
  92. self.bind_address = bind_address
  93. self._server: asyncio.Server | None = None
  94. self._running = False
  95. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  96. self._server_ssl_context: ssl.SSLContext | None = None
  97. self._client_ssl_context: ssl.SSLContext | None = None
  98. def _create_server_ssl_context(self) -> ssl.SSLContext:
  99. """Create SSL context for accepting client (slicer) connections."""
  100. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  101. ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
  102. # Allow older TLS versions for compatibility with slicers
  103. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  104. # Don't require client certificates
  105. ctx.verify_mode = ssl.CERT_NONE
  106. return ctx
  107. def _create_client_ssl_context(self) -> ssl.SSLContext:
  108. """Create SSL context for connecting to printer."""
  109. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  110. # Don't verify printer's certificate (self-signed)
  111. ctx.check_hostname = False
  112. ctx.verify_mode = ssl.CERT_NONE
  113. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  114. # Bambu printers use plain RSA key exchange (no ECDHE/DHE),
  115. # which modern OpenSSL 3.x defaults exclude. Add them back.
  116. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  117. return ctx
  118. async def start(self) -> None:
  119. """Start the TLS proxy server."""
  120. if self._running:
  121. return
  122. logger.info(
  123. f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
  124. )
  125. try:
  126. self._running = True
  127. # Create SSL contexts
  128. self._server_ssl_context = self._create_server_ssl_context()
  129. self._client_ssl_context = self._create_client_ssl_context()
  130. # Start server with TLS
  131. self._server = await asyncio.start_server(
  132. self._handle_client,
  133. self.bind_address,
  134. self.listen_port,
  135. ssl=self._server_ssl_context,
  136. )
  137. logger.info("%s TLS proxy listening on port %s", self.name, self.listen_port)
  138. async with self._server:
  139. await self._server.serve_forever()
  140. except OSError as e:
  141. if e.errno == 98: # Address already in use
  142. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  143. elif e.errno == 13: # Permission denied
  144. logger.error(
  145. "%s proxy: cannot bind to port %s (permission denied). "
  146. "Port %s requires root or CAP_NET_BIND_SERVICE. "
  147. "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
  148. "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
  149. "or redirect with iptables.",
  150. self.name,
  151. self.listen_port,
  152. self.listen_port,
  153. )
  154. else:
  155. logger.error("%s proxy error: %s", self.name, e)
  156. except asyncio.CancelledError:
  157. logger.debug("%s proxy task cancelled", self.name)
  158. except Exception as e:
  159. logger.error("%s proxy error: %s", self.name, e)
  160. finally:
  161. await self.stop()
  162. async def stop(self) -> None:
  163. """Stop the TLS proxy server."""
  164. logger.info("Stopping %s proxy", self.name)
  165. self._running = False
  166. # Cancel all active connection tasks
  167. for client_id, (task1, task2) in list(self._active_connections.items()):
  168. task1.cancel()
  169. task2.cancel()
  170. if self.on_disconnect:
  171. try:
  172. self.on_disconnect(client_id)
  173. except Exception:
  174. pass # Ignore disconnect callback errors during shutdown
  175. self._active_connections.clear()
  176. if self._server:
  177. try:
  178. self._server.close()
  179. await self._server.wait_closed()
  180. except OSError as e:
  181. logger.debug("Error closing %s proxy server: %s", self.name, e)
  182. self._server = None
  183. async def _handle_client(
  184. self,
  185. client_reader: asyncio.StreamReader,
  186. client_writer: asyncio.StreamWriter,
  187. ) -> None:
  188. """Handle a new client connection by proxying to target."""
  189. peername = client_writer.get_extra_info("peername")
  190. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  191. logger.info("%s proxy: client connected from %s", self.name, client_id)
  192. if self.on_connect:
  193. try:
  194. self.on_connect(client_id)
  195. except Exception:
  196. pass # Ignore connect callback errors; connection proceeds regardless
  197. # Connect to target printer with TLS
  198. try:
  199. printer_reader, printer_writer = await asyncio.wait_for(
  200. asyncio.open_connection(
  201. self.target_host,
  202. self.target_port,
  203. ssl=self._client_ssl_context,
  204. ),
  205. timeout=10.0,
  206. )
  207. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  208. except TimeoutError:
  209. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  210. client_writer.close()
  211. await client_writer.wait_closed()
  212. return
  213. except ssl.SSLError as e:
  214. logger.error(
  215. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  216. )
  217. client_writer.close()
  218. await client_writer.wait_closed()
  219. return
  220. except OSError as e:
  221. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  222. client_writer.close()
  223. await client_writer.wait_closed()
  224. return
  225. # Create bidirectional forwarding tasks
  226. client_to_printer = asyncio.create_task(
  227. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  228. name=f"{self.name}_c2p_{client_id}",
  229. )
  230. printer_to_client = asyncio.create_task(
  231. self._forward(printer_reader, client_writer, f"printer→{client_id}"),
  232. name=f"{self.name}_p2c_{client_id}",
  233. )
  234. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  235. try:
  236. # Wait for either direction to complete (connection closed)
  237. done, pending = await asyncio.wait(
  238. [client_to_printer, printer_to_client],
  239. return_when=asyncio.FIRST_COMPLETED,
  240. )
  241. # Cancel the other direction
  242. for task in pending:
  243. task.cancel()
  244. try:
  245. await task
  246. except asyncio.CancelledError:
  247. pass # Expected when cancelling the other forwarding direction
  248. except Exception as e:
  249. logger.debug("%s proxy connection error: %s", self.name, e)
  250. finally:
  251. # Clean up
  252. self._active_connections.pop(client_id, None)
  253. for writer in [client_writer, printer_writer]:
  254. try:
  255. writer.close()
  256. await writer.wait_closed()
  257. except OSError:
  258. pass # Best-effort connection cleanup; peer may have disconnected
  259. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  260. if self.on_disconnect:
  261. try:
  262. self.on_disconnect(client_id)
  263. except Exception:
  264. pass # Ignore disconnect callback errors; cleanup continues
  265. async def _forward(
  266. self,
  267. reader: asyncio.StreamReader,
  268. writer: asyncio.StreamWriter,
  269. direction: str,
  270. ) -> None:
  271. """Forward data from reader to writer.
  272. Args:
  273. reader: Source stream (already TLS-decrypted)
  274. writer: Destination stream (will be TLS-encrypted by the stream)
  275. direction: Description for logging (e.g., "client→printer")
  276. """
  277. total_bytes = 0
  278. try:
  279. while self._running:
  280. # Read chunk - use reasonable buffer size
  281. data = await reader.read(65536)
  282. if not data:
  283. # Connection closed
  284. break
  285. # Forward to destination
  286. writer.write(data)
  287. await writer.drain()
  288. total_bytes += len(data)
  289. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  290. except asyncio.CancelledError:
  291. pass # Expected when the other forwarding direction closes first
  292. except ConnectionResetError:
  293. logger.debug("%s proxy %s: connection reset", self.name, direction)
  294. except BrokenPipeError:
  295. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  296. except OSError as e:
  297. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  298. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  299. class TCPProxy:
  300. """Raw TCP proxy that forwards data without TLS termination.
  301. Used for protocols where the printer doesn't use TLS (e.g., port 3000
  302. binding/authentication protocol).
  303. """
  304. def __init__(
  305. self,
  306. name: str,
  307. listen_port: int,
  308. target_host: str,
  309. target_port: int,
  310. on_connect: Callable[[str], None] | None = None,
  311. on_disconnect: Callable[[str], None] | None = None,
  312. bind_address: str = "0.0.0.0", # nosec B104
  313. ):
  314. self.name = name
  315. self.listen_port = listen_port
  316. self.target_host = target_host
  317. self.target_port = target_port
  318. self.on_connect = on_connect
  319. self.on_disconnect = on_disconnect
  320. self.bind_address = bind_address
  321. self._server: asyncio.Server | None = None
  322. self._running = False
  323. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  324. async def start(self) -> None:
  325. """Start the TCP proxy server."""
  326. if self._running:
  327. return
  328. logger.info(
  329. "Starting %s TCP proxy: %s:%s → %s:%s",
  330. self.name,
  331. self.bind_address,
  332. self.listen_port,
  333. self.target_host,
  334. self.target_port,
  335. )
  336. try:
  337. self._running = True
  338. self._server = await asyncio.start_server(
  339. self._handle_client,
  340. self.bind_address,
  341. self.listen_port,
  342. )
  343. logger.info("%s TCP proxy listening on port %s", self.name, self.listen_port)
  344. async with self._server:
  345. await self._server.serve_forever()
  346. except OSError as e:
  347. if e.errno == 98: # Address already in use
  348. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  349. else:
  350. logger.error("%s proxy error: %s", self.name, e)
  351. except asyncio.CancelledError:
  352. logger.debug("%s proxy task cancelled", self.name)
  353. except Exception as e:
  354. logger.error("%s proxy error: %s", self.name, e)
  355. finally:
  356. await self.stop()
  357. async def stop(self) -> None:
  358. """Stop the TCP proxy server."""
  359. logger.info("Stopping %s proxy", self.name)
  360. self._running = False
  361. for client_id, (task1, task2) in list(self._active_connections.items()):
  362. task1.cancel()
  363. task2.cancel()
  364. if self.on_disconnect:
  365. try:
  366. self.on_disconnect(client_id)
  367. except Exception:
  368. pass
  369. self._active_connections.clear()
  370. if self._server:
  371. try:
  372. self._server.close()
  373. await self._server.wait_closed()
  374. except OSError as e:
  375. logger.debug("Error closing %s proxy server: %s", self.name, e)
  376. self._server = None
  377. async def _handle_client(
  378. self,
  379. client_reader: asyncio.StreamReader,
  380. client_writer: asyncio.StreamWriter,
  381. ) -> None:
  382. """Handle a new client connection by proxying to target."""
  383. peername = client_writer.get_extra_info("peername")
  384. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  385. logger.info("%s proxy: client connected from %s", self.name, client_id)
  386. if self.on_connect:
  387. try:
  388. self.on_connect(client_id)
  389. except Exception:
  390. pass
  391. try:
  392. printer_reader, printer_writer = await asyncio.wait_for(
  393. asyncio.open_connection(self.target_host, self.target_port),
  394. timeout=10.0,
  395. )
  396. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  397. except TimeoutError:
  398. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  399. client_writer.close()
  400. await client_writer.wait_closed()
  401. return
  402. except OSError as e:
  403. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  404. client_writer.close()
  405. await client_writer.wait_closed()
  406. return
  407. client_to_printer = asyncio.create_task(
  408. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  409. name=f"{self.name}_c2p_{client_id}",
  410. )
  411. printer_to_client = asyncio.create_task(
  412. self._forward(printer_reader, client_writer, f"printer→{client_id}"),
  413. name=f"{self.name}_p2c_{client_id}",
  414. )
  415. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  416. try:
  417. done, pending = await asyncio.wait(
  418. [client_to_printer, printer_to_client],
  419. return_when=asyncio.FIRST_COMPLETED,
  420. )
  421. for task in pending:
  422. task.cancel()
  423. try:
  424. await task
  425. except asyncio.CancelledError:
  426. pass
  427. except Exception as e:
  428. logger.debug("%s proxy connection error: %s", self.name, e)
  429. finally:
  430. self._active_connections.pop(client_id, None)
  431. for writer in [client_writer, printer_writer]:
  432. try:
  433. writer.close()
  434. await writer.wait_closed()
  435. except OSError:
  436. pass
  437. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  438. if self.on_disconnect:
  439. try:
  440. self.on_disconnect(client_id)
  441. except Exception:
  442. pass
  443. async def _forward(
  444. self,
  445. reader: asyncio.StreamReader,
  446. writer: asyncio.StreamWriter,
  447. direction: str,
  448. ) -> None:
  449. """Forward data from reader to writer."""
  450. total_bytes = 0
  451. try:
  452. while self._running:
  453. data = await reader.read(65536)
  454. if not data:
  455. break
  456. writer.write(data)
  457. await writer.drain()
  458. total_bytes += len(data)
  459. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  460. except asyncio.CancelledError:
  461. pass
  462. except ConnectionResetError:
  463. logger.debug("%s proxy %s: connection reset", self.name, direction)
  464. except BrokenPipeError:
  465. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  466. except OSError as e:
  467. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  468. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  469. class FTPTLSProxy(TLSProxy):
  470. """FTP-aware TLS proxy that handles passive data connections.
  471. Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
  472. channel, dynamically create TLS data proxies on local ports, and rewrite
  473. the responses so the slicer connects to the proxy instead of the printer.
  474. Without this, FTP passive data connections bypass the proxy and go directly
  475. to the printer, which fails when the slicer can't reach the printer's IP.
  476. """
  477. PASV_PORT_MIN = 50000
  478. PASV_PORT_MAX = 50100
  479. async def stop(self) -> None:
  480. """Stop proxy and clean up data connection servers."""
  481. # Close all data servers first
  482. for server in list(self._data_servers):
  483. try:
  484. server.close()
  485. await server.wait_closed()
  486. except OSError:
  487. pass # Best-effort cleanup of data proxy servers
  488. self._data_servers.clear()
  489. await super().stop()
  490. async def start(self) -> None:
  491. """Start the FTP TLS proxy."""
  492. self._data_servers: list[asyncio.Server] = []
  493. await super().start()
  494. async def _handle_client(
  495. self,
  496. client_reader: asyncio.StreamReader,
  497. client_writer: asyncio.StreamWriter,
  498. ) -> None:
  499. """Handle FTP client with PASV/EPSV-aware response forwarding."""
  500. peername = client_writer.get_extra_info("peername")
  501. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  502. logger.info("%s proxy: client connected from %s", self.name, client_id)
  503. if self.on_connect:
  504. try:
  505. self.on_connect(client_id)
  506. except Exception:
  507. pass # Ignore connect callback errors; connection proceeds regardless
  508. # Determine our local IP from the control connection socket
  509. sockname = client_writer.get_extra_info("sockname")
  510. local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
  511. if local_ip in ("0.0.0.0", "::"): # nosec B104
  512. local_ip = "127.0.0.1"
  513. # Connect to target printer with TLS
  514. try:
  515. printer_reader, printer_writer = await asyncio.wait_for(
  516. asyncio.open_connection(
  517. self.target_host,
  518. self.target_port,
  519. ssl=self._client_ssl_context,
  520. ),
  521. timeout=10.0,
  522. )
  523. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  524. except TimeoutError:
  525. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  526. client_writer.close()
  527. await client_writer.wait_closed()
  528. return
  529. except ssl.SSLError as e:
  530. logger.error(
  531. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  532. )
  533. client_writer.close()
  534. await client_writer.wait_closed()
  535. return
  536. except OSError as e:
  537. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  538. client_writer.close()
  539. await client_writer.wait_closed()
  540. return
  541. # Track data channel protection level per session.
  542. # PROT C = cleartext data, PROT P = TLS data.
  543. # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
  544. # If the slicer sends PROT P, we switch to TLS for data connections.
  545. session_state: dict[str, str] = {"prot": "C"}
  546. # Client→Printer: intercept EPSV and replace with PASV
  547. # EPSV responses only contain a port (no IP), so the slicer reuses
  548. # the control connection IP. If that IP is the real printer (via
  549. # iptables REDIRECT), the data connection bypasses the proxy.
  550. # PASV responses include an explicit IP that we can rewrite.
  551. client_to_printer = asyncio.create_task(
  552. self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
  553. name=f"{self.name}_c2p_{client_id}",
  554. )
  555. # Printer→Client: intercept PASV/EPSV responses
  556. printer_to_client = asyncio.create_task(
  557. self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
  558. name=f"{self.name}_p2c_{client_id}",
  559. )
  560. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  561. try:
  562. done, pending = await asyncio.wait(
  563. [client_to_printer, printer_to_client],
  564. return_when=asyncio.FIRST_COMPLETED,
  565. )
  566. for task in pending:
  567. task.cancel()
  568. try:
  569. await task
  570. except asyncio.CancelledError:
  571. pass # Expected when cancelling the other forwarding direction
  572. except Exception as e:
  573. logger.debug("%s proxy connection error: %s", self.name, e)
  574. finally:
  575. self._active_connections.pop(client_id, None)
  576. for writer in [client_writer, printer_writer]:
  577. try:
  578. writer.close()
  579. await writer.wait_closed()
  580. except OSError:
  581. pass # Best-effort connection cleanup; peer may have disconnected
  582. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  583. if self.on_disconnect:
  584. try:
  585. self.on_disconnect(client_id)
  586. except Exception:
  587. pass # Ignore disconnect callback errors; cleanup continues
  588. async def _forward_ftp_commands(
  589. self,
  590. reader: asyncio.StreamReader,
  591. writer: asyncio.StreamWriter,
  592. direction: str,
  593. session_state: dict[str, str],
  594. ) -> None:
  595. """Forward FTP client commands, replacing EPSV with PASV.
  596. EPSV responses only contain a port number — the client reuses the
  597. control connection IP for data. When the control IP is the real
  598. printer (due to iptables REDIRECT), EPSV data connections bypass
  599. the proxy. PASV responses include an explicit IP that the proxy
  600. can rewrite to its own address.
  601. Also tracks PROT P/C commands to know whether data connections
  602. should use TLS or cleartext.
  603. """
  604. buffer = b""
  605. total_bytes = 0
  606. try:
  607. while self._running:
  608. data = await reader.read(65536)
  609. if not data:
  610. break
  611. total_bytes += len(data)
  612. buffer += data
  613. output = b""
  614. while b"\r\n" in buffer:
  615. idx = buffer.index(b"\r\n")
  616. line = buffer[:idx]
  617. buffer = buffer[idx + 2 :]
  618. cmd_upper = line.strip().upper()
  619. # Replace EPSV with PASV so response includes an IP
  620. if cmd_upper == b"EPSV":
  621. line = b"PASV"
  622. logger.info("FTP command rewrite: EPSV → PASV")
  623. # Track PROT level for data channel encryption
  624. elif cmd_upper == b"PROT P":
  625. session_state["prot"] = "P"
  626. logger.info("FTP data protection: PROT P (TLS)")
  627. elif cmd_upper == b"PROT C":
  628. session_state["prot"] = "C"
  629. logger.info("FTP data protection: PROT C (cleartext)")
  630. output += line + b"\r\n"
  631. if output:
  632. writer.write(output)
  633. await writer.drain()
  634. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  635. except asyncio.CancelledError:
  636. pass # Expected when the other forwarding direction closes first
  637. except ConnectionResetError:
  638. logger.debug("%s proxy %s: connection reset", self.name, direction)
  639. except BrokenPipeError:
  640. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  641. except OSError as e:
  642. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  643. if buffer:
  644. try:
  645. writer.write(buffer)
  646. await writer.drain()
  647. except OSError:
  648. pass # Best-effort flush of remaining FTP command data
  649. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  650. async def _forward_ftp_control(
  651. self,
  652. reader: asyncio.StreamReader,
  653. writer: asyncio.StreamWriter,
  654. direction: str,
  655. local_ip: str,
  656. session_state: dict[str, str],
  657. ) -> None:
  658. """Forward FTP control channel responses, rewriting PASV/EPSV.
  659. FTP control channel is line-based (\\r\\n terminated). We buffer data
  660. and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
  661. responses to create local data proxies.
  662. """
  663. buffer = b""
  664. total_bytes = 0
  665. try:
  666. while self._running:
  667. data = await reader.read(65536)
  668. if not data:
  669. break
  670. total_bytes += len(data)
  671. buffer += data
  672. output = b""
  673. # Process all complete lines
  674. while b"\r\n" in buffer:
  675. idx = buffer.index(b"\r\n")
  676. line = buffer[:idx]
  677. buffer = buffer[idx + 2 :]
  678. rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
  679. output += rewritten + b"\r\n"
  680. if output:
  681. writer.write(output)
  682. await writer.drain()
  683. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  684. except asyncio.CancelledError:
  685. pass # Expected when the other forwarding direction closes first
  686. except ConnectionResetError:
  687. logger.debug("%s proxy %s: connection reset", self.name, direction)
  688. except BrokenPipeError:
  689. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  690. except OSError as e:
  691. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  692. # Flush any remaining buffered data
  693. if buffer:
  694. try:
  695. writer.write(buffer)
  696. await writer.drain()
  697. except OSError:
  698. pass # Best-effort flush of remaining FTP control data
  699. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  700. async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
  701. """Rewrite PASV/EPSV response to point to a local data proxy."""
  702. try:
  703. text = line.decode("utf-8")
  704. except UnicodeDecodeError:
  705. return line
  706. # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
  707. if text.startswith("227 "):
  708. match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
  709. if match:
  710. h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
  711. printer_ip = f"{h1}.{h2}.{h3}.{h4}"
  712. printer_port = p1 * 256 + p2
  713. local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
  714. if local_port:
  715. ip_parts = local_ip.split(".")
  716. lp1 = local_port // 256
  717. lp2 = local_port % 256
  718. rewritten = (
  719. f"227 Entering Passive Mode "
  720. f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
  721. )
  722. logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
  723. return rewritten.encode("utf-8")
  724. else:
  725. logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
  726. else:
  727. logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
  728. # 229 Entering Extended Passive Mode (|||port|)
  729. elif text.startswith("229 "):
  730. match = re.search(r"\(\|\|\|(\d+)\|\)", text)
  731. if match:
  732. printer_port = int(match.group(1))
  733. local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
  734. if local_port:
  735. rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
  736. logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
  737. return rewritten.encode("utf-8")
  738. else:
  739. logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
  740. else:
  741. logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
  742. return line
  743. async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
  744. """Create a one-shot proxy for an FTP data connection.
  745. Prefers the printer's original passive port so the port number stays
  746. the same in the rewritten PASV/EPSV response. This is critical when
  747. the slicer's FTP bounce-attack protection overrides the IP in the PASV
  748. response: the slicer connects to <control_IP>:<port>, and if iptables
  749. REDIRECT maps that port to the local machine, the data proxy must be
  750. listening on the *same* port number.
  751. Falls back to a random port if the original is unavailable.
  752. Uses TLS or cleartext based on the session's PROT level:
  753. - PROT P: TLS on both slicer and printer data connections
  754. - PROT C: cleartext on both sides (common for A1/H2D printers)
  755. Returns the local port number, or None if binding failed.
  756. """
  757. use_tls = session_state.get("prot") == "P"
  758. logger.info(
  759. "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
  760. printer_ip,
  761. printer_port,
  762. "TLS" if use_tls else "cleartext",
  763. )
  764. # Try the printer's original port first — this ensures the port
  765. # matches even when bounce protection or iptables REDIRECT is in play.
  766. try:
  767. await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
  768. logger.info("FTP data proxy: using printer's port %s", printer_port)
  769. return printer_port
  770. except OSError as e:
  771. logger.debug(
  772. "FTP data proxy: printer port %s unavailable (%s), trying random",
  773. printer_port,
  774. e,
  775. )
  776. for _attempt in range(10):
  777. port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
  778. try:
  779. await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
  780. logger.info("FTP data proxy: using random port %s", port)
  781. return port
  782. except OSError:
  783. continue
  784. logger.error("Failed to bind FTP data proxy port after 10 attempts")
  785. return None
  786. async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
  787. """Start a one-shot server for one FTP data connection.
  788. The slicer-side listener is ALWAYS cleartext. Even when the slicer
  789. sends PROT P on the control channel, Bambu Studio does not perform
  790. a TLS handshake on the data connection — it relies on the implicit
  791. FTPS control channel for authentication and sends data unencrypted.
  792. The printer-side outbound connection follows the PROT level:
  793. - PROT P (use_tls=True): TLS to the printer's data port
  794. - PROT C (use_tls=False): cleartext to the printer's data port
  795. This mirrors the control channel's TLS-termination architecture.
  796. Raises OSError if the port is already in use.
  797. """
  798. connected = asyncio.Event()
  799. server_holder: list[asyncio.Server] = []
  800. # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
  801. # the data channel even after sending PROT P.
  802. # Printer side: TLS if PROT P, cleartext if PROT C.
  803. client_ssl = self._client_ssl_context if use_tls else None
  804. printer_mode = "TLS" if use_tls else "cleartext"
  805. async def handle_data(
  806. client_reader: asyncio.StreamReader,
  807. client_writer: asyncio.StreamWriter,
  808. ) -> None:
  809. """Handle one FTP data connection, then close the server."""
  810. peername = client_writer.get_extra_info("peername")
  811. data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  812. logger.info(
  813. "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
  814. port,
  815. printer_mode,
  816. data_client,
  817. printer_ip,
  818. printer_port,
  819. )
  820. connected.set()
  821. # One-shot: close server after accepting first connection
  822. if server_holder:
  823. server_holder[0].close()
  824. printer_writer = None
  825. try:
  826. # Connect to printer's data port
  827. printer_reader, printer_writer = await asyncio.wait_for(
  828. asyncio.open_connection(
  829. printer_ip,
  830. printer_port,
  831. ssl=client_ssl,
  832. ),
  833. timeout=10.0,
  834. )
  835. logger.info(
  836. "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
  837. port,
  838. printer_mode,
  839. printer_ip,
  840. printer_port,
  841. )
  842. # Bidirectional data forwarding
  843. c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
  844. p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
  845. done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
  846. for task in pending:
  847. task.cancel()
  848. try:
  849. await task
  850. except asyncio.CancelledError:
  851. pass # Expected when other data direction closes
  852. except TimeoutError:
  853. logger.error("FTP data proxy port %s: timeout connecting to printer", port)
  854. except ssl.SSLError as e:
  855. logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
  856. except Exception as e:
  857. logger.error("FTP data proxy port %s: error: %s", port, e)
  858. finally:
  859. for w in [client_writer, printer_writer]:
  860. if w:
  861. try:
  862. w.close()
  863. await w.wait_closed()
  864. except OSError:
  865. pass # Best-effort data connection cleanup
  866. logger.info("FTP data proxy port %s: connection closed", port)
  867. server = await asyncio.start_server(
  868. handle_data,
  869. "0.0.0.0", # nosec B104
  870. port,
  871. # No TLS on slicer side — Bambu Studio doesn't do TLS on data
  872. # channel even after PROT P. The proxy terminates TLS only on
  873. # the printer side (inside handle_data).
  874. )
  875. server_holder.append(server)
  876. self._data_servers.append(server)
  877. # Auto-close after 60s if no connection arrives
  878. async def auto_close() -> None:
  879. try:
  880. await asyncio.wait_for(connected.wait(), timeout=60.0)
  881. except TimeoutError:
  882. logger.debug("FTP data proxy on port %s timed out, closing", port)
  883. try:
  884. server.close()
  885. await server.wait_closed()
  886. except OSError:
  887. pass # Best-effort timeout cleanup
  888. finally:
  889. if server in self._data_servers:
  890. self._data_servers.remove(server)
  891. asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
  892. logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
  893. class SlicerProxyManager:
  894. """Manages FTP and MQTT TLS proxies for a single printer target."""
  895. # Bambu printer ports
  896. PRINTER_FTP_PORT = 990
  897. PRINTER_MQTT_PORT = 8883
  898. PRINTER_BIND_PORTS = [3000, 3002]
  899. # Local listen ports - must match what Bambu Studio expects
  900. # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
  901. LOCAL_FTP_PORT = 990
  902. LOCAL_MQTT_PORT = 8883
  903. def __init__(
  904. self,
  905. target_host: str,
  906. cert_path: Path,
  907. key_path: Path,
  908. on_activity: Callable[[str, str], None] | None = None,
  909. bind_address: str = "0.0.0.0", # nosec B104
  910. ):
  911. """Initialize the slicer proxy manager.
  912. Args:
  913. target_host: Target printer IP address
  914. cert_path: Path to server certificate
  915. key_path: Path to server private key
  916. on_activity: Optional callback for activity logging (name, message)
  917. bind_address: IP address to bind proxy listeners to
  918. """
  919. self.target_host = target_host
  920. self.cert_path = cert_path
  921. self.key_path = key_path
  922. self.on_activity = on_activity
  923. self.bind_address = bind_address
  924. self._ftp_proxy: TLSProxy | None = None
  925. self._mqtt_proxy: TLSProxy | None = None
  926. self._bind_proxies: list[TCPProxy] = []
  927. self._tasks: list[asyncio.Task] = []
  928. async def start(self) -> None:
  929. """Start FTP and MQTT TLS proxies."""
  930. logger.info("Starting slicer TLS proxy to %s", self.target_host)
  931. # Detect iptables port redirect (e.g. if an external redirect exists).
  932. # If active, connections get intercepted by iptables PREROUTING
  933. # and sent to the redirect target — our socket never sees them.
  934. ftp_listen_port = self.LOCAL_FTP_PORT
  935. redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
  936. if redirect_target:
  937. logger.info(
  938. "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
  939. self.LOCAL_FTP_PORT,
  940. redirect_target,
  941. redirect_target,
  942. )
  943. ftp_listen_port = redirect_target
  944. # Create FTP proxy with PASV/EPSV awareness for data connections
  945. self._ftp_proxy = FTPTLSProxy(
  946. name="FTP",
  947. listen_port=ftp_listen_port,
  948. target_host=self.target_host,
  949. target_port=self.PRINTER_FTP_PORT,
  950. server_cert_path=self.cert_path,
  951. server_key_path=self.key_path,
  952. on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
  953. on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
  954. bind_address=self.bind_address,
  955. )
  956. self._mqtt_proxy = TLSProxy(
  957. name="MQTT",
  958. listen_port=self.LOCAL_MQTT_PORT,
  959. target_host=self.target_host,
  960. target_port=self.PRINTER_MQTT_PORT,
  961. server_cert_path=self.cert_path,
  962. server_key_path=self.key_path,
  963. on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
  964. on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
  965. bind_address=self.bind_address,
  966. )
  967. # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
  968. for bind_port in self.PRINTER_BIND_PORTS:
  969. if bind_port == 3002:
  970. proxy = TLSProxy(
  971. name="Bind-TLS",
  972. listen_port=bind_port,
  973. target_host=self.target_host,
  974. target_port=bind_port,
  975. server_cert_path=self.cert_path,
  976. server_key_path=self.key_path,
  977. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  978. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  979. bind_address=self.bind_address,
  980. )
  981. else:
  982. proxy = TCPProxy(
  983. name="Bind",
  984. listen_port=bind_port,
  985. target_host=self.target_host,
  986. target_port=bind_port,
  987. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  988. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  989. bind_address=self.bind_address,
  990. )
  991. self._bind_proxies.append(proxy)
  992. # Start as background tasks
  993. async def run_with_logging(proxy: TLSProxy) -> None:
  994. try:
  995. await proxy.start()
  996. except Exception as e:
  997. logger.error("Slicer proxy %s failed: %s", proxy.name, e)
  998. self._tasks = [
  999. asyncio.create_task(
  1000. run_with_logging(self._ftp_proxy),
  1001. name="slicer_proxy_ftp",
  1002. ),
  1003. asyncio.create_task(
  1004. run_with_logging(self._mqtt_proxy),
  1005. name="slicer_proxy_mqtt",
  1006. ),
  1007. ]
  1008. for bp in self._bind_proxies:
  1009. self._tasks.append(
  1010. asyncio.create_task(
  1011. run_with_logging(bp),
  1012. name=f"slicer_proxy_bind_{bp.listen_port}",
  1013. )
  1014. )
  1015. logger.info("Slicer TLS proxy started for %s", self.target_host)
  1016. # Wait for tasks to complete (they run until cancelled)
  1017. # This keeps the start() coroutine alive so the parent task doesn't complete
  1018. try:
  1019. await asyncio.gather(*self._tasks)
  1020. except asyncio.CancelledError:
  1021. logger.debug("Slicer proxy start cancelled")
  1022. async def stop(self) -> None:
  1023. """Stop all proxies."""
  1024. logger.info("Stopping slicer proxy")
  1025. # Stop proxies
  1026. if self._ftp_proxy:
  1027. await self._ftp_proxy.stop()
  1028. self._ftp_proxy = None
  1029. if self._mqtt_proxy:
  1030. await self._mqtt_proxy.stop()
  1031. self._mqtt_proxy = None
  1032. for bp in self._bind_proxies:
  1033. await bp.stop()
  1034. self._bind_proxies = []
  1035. # Cancel tasks
  1036. for task in self._tasks:
  1037. task.cancel()
  1038. if self._tasks:
  1039. try:
  1040. await asyncio.wait_for(
  1041. asyncio.gather(*self._tasks, return_exceptions=True),
  1042. timeout=2.0,
  1043. )
  1044. except TimeoutError:
  1045. logger.debug("Some proxy tasks didn't stop in time")
  1046. self._tasks = []
  1047. logger.info("Slicer proxy stopped")
  1048. def _log_activity(self, name: str, message: str) -> None:
  1049. """Log activity via callback if configured."""
  1050. if self.on_activity:
  1051. try:
  1052. self.on_activity(name, message)
  1053. except Exception:
  1054. pass # Ignore activity callback errors; logging is non-critical
  1055. @property
  1056. def is_running(self) -> bool:
  1057. """Check if proxies are running."""
  1058. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  1059. def get_status(self) -> dict:
  1060. """Get proxy status."""
  1061. return {
  1062. "running": self.is_running,
  1063. "target_host": self.target_host,
  1064. "ftp_port": self.LOCAL_FTP_PORT,
  1065. "mqtt_port": self.LOCAL_MQTT_PORT,
  1066. "bind_ports": self.PRINTER_BIND_PORTS,
  1067. "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
  1068. "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
  1069. "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
  1070. }