tcp_proxy.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570
  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. rewrite_ip: tuple[str, str] | None = None,
  72. ):
  73. """Initialize the TLS proxy.
  74. Args:
  75. name: Friendly name for logging (e.g., "FTP", "MQTT")
  76. listen_port: Port to listen on for incoming connections
  77. target_host: Target printer IP/hostname
  78. target_port: Target printer port
  79. server_cert_path: Path to server certificate (for accepting slicer connections)
  80. server_key_path: Path to server private key
  81. on_connect: Optional callback when client connects (receives client_id)
  82. on_disconnect: Optional callback when client disconnects (receives client_id)
  83. bind_address: IP address to bind to (default: all interfaces)
  84. rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
  85. the printer's real IP with the proxy's bind IP in printer→client data.
  86. This prevents the slicer from discovering the printer's real IP
  87. in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
  88. """
  89. self.name = name
  90. self.listen_port = listen_port
  91. self.target_host = target_host
  92. self.target_port = target_port
  93. self.server_cert_path = server_cert_path
  94. self.server_key_path = server_key_path
  95. self.on_connect = on_connect
  96. self.on_disconnect = on_disconnect
  97. self.bind_address = bind_address
  98. # IP rewriting for printer→client direction
  99. if rewrite_ip:
  100. self._rewrite_old = rewrite_ip[0].encode("utf-8")
  101. self._rewrite_new = rewrite_ip[1].encode("utf-8")
  102. # Also rewrite the integer IP in net.info[].ip fields.
  103. # Bambu printers encode their IP as a little-endian uint32 integer
  104. # in the JSON payload. BambuStudio reads this to set dev_ip.
  105. self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
  106. self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
  107. else:
  108. self._rewrite_old = None
  109. self._rewrite_new = None
  110. self._rewrite_old_int = None
  111. self._rewrite_new_int = None
  112. self._server: asyncio.Server | None = None
  113. self._running = False
  114. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  115. self._server_ssl_context: ssl.SSLContext | None = None
  116. self._client_ssl_context: ssl.SSLContext | None = None
  117. @staticmethod
  118. def _ip_to_le_int_bytes(ip: str) -> bytes:
  119. """Convert an IP address to its little-endian integer JSON representation.
  120. E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
  121. as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
  122. """
  123. import struct as _struct
  124. parts = ip.split(".")
  125. packed = bytes(int(p) for p in parts)
  126. le_int = _struct.unpack("<I", packed)[0]
  127. return str(le_int).encode("utf-8")
  128. def _create_server_ssl_context(self) -> ssl.SSLContext:
  129. """Create SSL context for accepting client (slicer) connections."""
  130. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  131. ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
  132. # Allow older TLS versions for compatibility with slicers
  133. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  134. # Don't require client certificates
  135. ctx.verify_mode = ssl.CERT_NONE
  136. return ctx
  137. def _create_client_ssl_context(self) -> ssl.SSLContext:
  138. """Create SSL context for connecting to printer."""
  139. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
  140. # Don't verify printer's certificate (self-signed)
  141. ctx.check_hostname = False
  142. ctx.verify_mode = ssl.CERT_NONE
  143. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  144. # Bambu printers use plain RSA key exchange (no ECDHE/DHE),
  145. # which modern OpenSSL 3.x defaults exclude. Add them back.
  146. ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
  147. return ctx
  148. async def start(self) -> None:
  149. """Start the TLS proxy server."""
  150. if self._running:
  151. return
  152. logger.info(
  153. f"Starting {self.name} TLS proxy: {self.bind_address}:{self.listen_port} → {self.target_host}:{self.target_port}"
  154. )
  155. try:
  156. self._running = True
  157. # Create SSL contexts
  158. self._server_ssl_context = self._create_server_ssl_context()
  159. self._client_ssl_context = self._create_client_ssl_context()
  160. # Start server with TLS
  161. self._server = await asyncio.start_server(
  162. self._handle_client,
  163. self.bind_address,
  164. self.listen_port,
  165. ssl=self._server_ssl_context,
  166. )
  167. logger.info("%s TLS proxy listening on port %s", self.name, self.listen_port)
  168. async with self._server:
  169. await self._server.serve_forever()
  170. except OSError as e:
  171. if e.errno == 98: # Address already in use
  172. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  173. elif e.errno == 13: # Permission denied
  174. logger.error(
  175. "%s proxy: cannot bind to port %s (permission denied). "
  176. "Port %s requires root or CAP_NET_BIND_SERVICE. "
  177. "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
  178. "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
  179. "or redirect with iptables.",
  180. self.name,
  181. self.listen_port,
  182. self.listen_port,
  183. )
  184. else:
  185. logger.error("%s proxy error: %s", self.name, e)
  186. except asyncio.CancelledError:
  187. logger.debug("%s proxy task cancelled", self.name)
  188. except Exception as e:
  189. logger.error("%s proxy error: %s", self.name, e)
  190. finally:
  191. await self.stop()
  192. async def stop(self) -> None:
  193. """Stop the TLS proxy server."""
  194. logger.info("Stopping %s proxy", self.name)
  195. self._running = False
  196. # Cancel all active connection tasks
  197. for client_id, (task1, task2) in list(self._active_connections.items()):
  198. task1.cancel()
  199. task2.cancel()
  200. if self.on_disconnect:
  201. try:
  202. self.on_disconnect(client_id)
  203. except Exception:
  204. pass # Ignore disconnect callback errors during shutdown
  205. self._active_connections.clear()
  206. if self._server:
  207. try:
  208. self._server.close()
  209. await self._server.wait_closed()
  210. except OSError as e:
  211. logger.debug("Error closing %s proxy server: %s", self.name, e)
  212. self._server = None
  213. async def _handle_client(
  214. self,
  215. client_reader: asyncio.StreamReader,
  216. client_writer: asyncio.StreamWriter,
  217. ) -> None:
  218. """Handle a new client connection by proxying to target."""
  219. peername = client_writer.get_extra_info("peername")
  220. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  221. logger.info("%s proxy: client connected from %s", self.name, client_id)
  222. if self.on_connect:
  223. try:
  224. self.on_connect(client_id)
  225. except Exception:
  226. pass # Ignore connect callback errors; connection proceeds regardless
  227. # Connect to target printer with TLS
  228. try:
  229. printer_reader, printer_writer = await asyncio.wait_for(
  230. asyncio.open_connection(
  231. self.target_host,
  232. self.target_port,
  233. ssl=self._client_ssl_context,
  234. ),
  235. timeout=10.0,
  236. )
  237. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  238. except TimeoutError:
  239. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  240. client_writer.close()
  241. await client_writer.wait_closed()
  242. return
  243. except ssl.SSLError as e:
  244. logger.error(
  245. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  246. )
  247. client_writer.close()
  248. await client_writer.wait_closed()
  249. return
  250. except OSError as e:
  251. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  252. client_writer.close()
  253. await client_writer.wait_closed()
  254. return
  255. # Create bidirectional forwarding tasks
  256. client_to_printer = asyncio.create_task(
  257. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  258. name=f"{self.name}_c2p_{client_id}",
  259. )
  260. printer_to_client = asyncio.create_task(
  261. self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
  262. name=f"{self.name}_p2c_{client_id}",
  263. )
  264. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  265. try:
  266. # Wait for either direction to complete (connection closed)
  267. done, pending = await asyncio.wait(
  268. [client_to_printer, printer_to_client],
  269. return_when=asyncio.FIRST_COMPLETED,
  270. )
  271. # Cancel the other direction
  272. for task in pending:
  273. task.cancel()
  274. try:
  275. await task
  276. except asyncio.CancelledError:
  277. pass # Expected when cancelling the other forwarding direction
  278. except Exception as e:
  279. logger.debug("%s proxy connection error: %s", self.name, e)
  280. finally:
  281. # Clean up
  282. self._active_connections.pop(client_id, None)
  283. for writer in [client_writer, printer_writer]:
  284. try:
  285. writer.close()
  286. await writer.wait_closed()
  287. except OSError:
  288. pass # Best-effort connection cleanup; peer may have disconnected
  289. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  290. if self.on_disconnect:
  291. try:
  292. self.on_disconnect(client_id)
  293. except Exception:
  294. pass # Ignore disconnect callback errors; cleanup continues
  295. @staticmethod
  296. def _rewrite_mqtt_ip(
  297. data: bytes,
  298. old_ip: bytes,
  299. new_ip: bytes,
  300. buffer: bytearray,
  301. extra_replacements: list[tuple[bytes, bytes]] | None = None,
  302. ) -> tuple[bytes, bytearray]:
  303. """Rewrite IP addresses inside MQTT packets, preserving packet framing.
  304. MQTT packets have a variable-length header encoding the remaining
  305. packet length. A naive bytes.replace() would corrupt this framing
  306. when old_ip and new_ip differ in length.
  307. This method parses individual MQTT packets out of the data stream,
  308. performs the replacement only on PUBLISH payloads, and re-encodes
  309. the remaining-length field to match the new size.
  310. Incomplete packets are buffered and returned for the next call.
  311. Args:
  312. extra_replacements: Additional (old, new) byte pairs to replace
  313. (e.g. the integer IP representation in net.info[].ip).
  314. Returns (output_data, remaining_buffer).
  315. """
  316. buffer.extend(data)
  317. # Check if any replacement target exists in the buffer
  318. has_target = old_ip in buffer
  319. if not has_target and extra_replacements:
  320. has_target = any(old in buffer for old, _new in extra_replacements)
  321. if not has_target:
  322. # Fast path: no IP in buffer, but we still need to check for
  323. # incomplete packets at the end that might contain a partial IP.
  324. # For safety, try to parse and emit only complete packets.
  325. result = bytearray()
  326. pos = 0
  327. length = len(buffer)
  328. while pos < length:
  329. packet_start = pos
  330. if pos + 1 >= length:
  331. break
  332. pos += 1 # header byte
  333. # Parse remaining length
  334. remaining_length = 0
  335. multiplier = 1
  336. length_bytes = 0
  337. while pos < length:
  338. encoded_byte = buffer[pos]
  339. pos += 1
  340. remaining_length += (encoded_byte & 0x7F) * multiplier
  341. multiplier *= 128
  342. length_bytes += 1
  343. if (encoded_byte & 0x80) == 0:
  344. break
  345. if length_bytes >= 4:
  346. break
  347. if pos + remaining_length > length:
  348. # Incomplete — keep in buffer
  349. new_buffer = bytearray(buffer[packet_start:])
  350. return bytes(result), new_buffer
  351. pos += remaining_length
  352. result.extend(buffer[packet_start:pos])
  353. # All complete
  354. buffer.clear()
  355. return bytes(result) if result else bytes(data), buffer
  356. # Buffer contains old_ip — parse packets and rewrite
  357. result = bytearray()
  358. pos = 0
  359. length = len(buffer)
  360. while pos < length:
  361. packet_start = pos
  362. if pos >= length:
  363. break
  364. header_byte = buffer[pos]
  365. pos += 1
  366. # Remaining length: variable-length encoding (1-4 bytes)
  367. remaining_length = 0
  368. multiplier = 1
  369. length_bytes = 0
  370. while pos < length:
  371. encoded_byte = buffer[pos]
  372. pos += 1
  373. remaining_length += (encoded_byte & 0x7F) * multiplier
  374. multiplier *= 128
  375. length_bytes += 1
  376. if (encoded_byte & 0x80) == 0:
  377. break
  378. if length_bytes >= 4:
  379. break
  380. # Check if we have enough data for the full packet
  381. if pos + remaining_length > length:
  382. # Incomplete packet — keep in buffer for next call
  383. new_buffer = bytearray(buffer[packet_start:])
  384. return bytes(result), new_buffer
  385. packet_type = (header_byte >> 4) & 0x0F
  386. packet_body = buffer[pos : pos + remaining_length]
  387. pos += remaining_length
  388. # Only rewrite PUBLISH packets (type 3)
  389. needs_rewrite = packet_type == 3 and (
  390. old_ip in packet_body
  391. or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
  392. )
  393. if needs_rewrite:
  394. new_body = bytes(packet_body).replace(old_ip, new_ip)
  395. if extra_replacements:
  396. for old_val, new_val in extra_replacements:
  397. new_body = new_body.replace(old_val, new_val)
  398. # Re-encode: header byte + new remaining length + new body
  399. result.append(header_byte)
  400. # Encode remaining length (MQTT variable-length encoding)
  401. new_remaining = len(new_body)
  402. while True:
  403. encoded_byte = new_remaining % 128
  404. new_remaining //= 128
  405. if new_remaining > 0:
  406. encoded_byte |= 0x80
  407. result.append(encoded_byte)
  408. if new_remaining == 0:
  409. break
  410. result.extend(new_body)
  411. else:
  412. # Pass through unchanged
  413. result.extend(buffer[packet_start:pos])
  414. buffer.clear()
  415. return bytes(result), buffer
  416. async def _forward(
  417. self,
  418. reader: asyncio.StreamReader,
  419. writer: asyncio.StreamWriter,
  420. direction: str,
  421. rewrite_ip: bool = False,
  422. ) -> None:
  423. """Forward data from reader to writer.
  424. Args:
  425. reader: Source stream (already TLS-decrypted)
  426. writer: Destination stream (will be TLS-encrypted by the stream)
  427. direction: Description for logging (e.g., "client→printer")
  428. rewrite_ip: If True and rewrite_ip was configured, replace the
  429. printer's real IP with the proxy's bind IP in the data.
  430. """
  431. do_rewrite = rewrite_ip and self._rewrite_old is not None
  432. rewrite_buffer = bytearray() if do_rewrite else None
  433. rewrite_logged = False
  434. total_bytes = 0
  435. try:
  436. while self._running:
  437. # Read chunk - use reasonable buffer size
  438. data = await reader.read(65536)
  439. if not data:
  440. # Connection closed
  441. break
  442. # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
  443. # to prevent the slicer from bypassing the proxy.
  444. if do_rewrite:
  445. original_len = len(data)
  446. extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
  447. data, rewrite_buffer = self._rewrite_mqtt_ip(
  448. data,
  449. self._rewrite_old,
  450. self._rewrite_new,
  451. rewrite_buffer,
  452. extra_replacements=extra,
  453. )
  454. if not rewrite_logged and data and len(data) != original_len:
  455. logger.info(
  456. "%s proxy IP rewrite active: %s → %s",
  457. self.name,
  458. self._rewrite_old.decode(),
  459. self._rewrite_new.decode(),
  460. )
  461. rewrite_logged = True
  462. if not data:
  463. continue # All data buffered, waiting for more
  464. # Forward to destination
  465. writer.write(data)
  466. await writer.drain()
  467. total_bytes += len(data)
  468. except asyncio.CancelledError:
  469. pass # Expected when the other forwarding direction closes first
  470. except ConnectionResetError:
  471. logger.debug("%s proxy %s: connection reset", self.name, direction)
  472. except BrokenPipeError:
  473. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  474. except OSError as e:
  475. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  476. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  477. class TCPProxy:
  478. """Raw TCP proxy that forwards data without TLS termination.
  479. Used for protocols where the printer doesn't use TLS (e.g., port 3000
  480. binding/authentication protocol).
  481. """
  482. def __init__(
  483. self,
  484. name: str,
  485. listen_port: int,
  486. target_host: str,
  487. target_port: int,
  488. on_connect: Callable[[str], None] | None = None,
  489. on_disconnect: Callable[[str], None] | None = None,
  490. bind_address: str = "0.0.0.0", # nosec B104
  491. ):
  492. self.name = name
  493. self.listen_port = listen_port
  494. self.target_host = target_host
  495. self.target_port = target_port
  496. self.on_connect = on_connect
  497. self.on_disconnect = on_disconnect
  498. self.bind_address = bind_address
  499. self._server: asyncio.Server | None = None
  500. self._running = False
  501. self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
  502. async def start(self) -> None:
  503. """Start the TCP proxy server."""
  504. if self._running:
  505. return
  506. logger.info(
  507. "Starting %s TCP proxy: %s:%s → %s:%s",
  508. self.name,
  509. self.bind_address,
  510. self.listen_port,
  511. self.target_host,
  512. self.target_port,
  513. )
  514. try:
  515. self._running = True
  516. self._server = await asyncio.start_server(
  517. self._handle_client,
  518. self.bind_address,
  519. self.listen_port,
  520. )
  521. logger.info("%s TCP proxy listening on port %s", self.name, self.listen_port)
  522. async with self._server:
  523. await self._server.serve_forever()
  524. except OSError as e:
  525. if e.errno == 98: # Address already in use
  526. logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
  527. else:
  528. logger.error("%s proxy error: %s", self.name, e)
  529. except asyncio.CancelledError:
  530. logger.debug("%s proxy task cancelled", self.name)
  531. except Exception as e:
  532. logger.error("%s proxy error: %s", self.name, e)
  533. finally:
  534. await self.stop()
  535. async def stop(self) -> None:
  536. """Stop the TCP proxy server."""
  537. logger.info("Stopping %s proxy", self.name)
  538. self._running = False
  539. for client_id, (task1, task2) in list(self._active_connections.items()):
  540. task1.cancel()
  541. task2.cancel()
  542. if self.on_disconnect:
  543. try:
  544. self.on_disconnect(client_id)
  545. except Exception:
  546. pass
  547. self._active_connections.clear()
  548. if self._server:
  549. try:
  550. self._server.close()
  551. await self._server.wait_closed()
  552. except OSError as e:
  553. logger.debug("Error closing %s proxy server: %s", self.name, e)
  554. self._server = None
  555. async def _handle_client(
  556. self,
  557. client_reader: asyncio.StreamReader,
  558. client_writer: asyncio.StreamWriter,
  559. ) -> None:
  560. """Handle a new client connection by proxying to target."""
  561. peername = client_writer.get_extra_info("peername")
  562. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  563. logger.info("%s proxy: client connected from %s", self.name, client_id)
  564. if self.on_connect:
  565. try:
  566. self.on_connect(client_id)
  567. except Exception:
  568. pass
  569. try:
  570. printer_reader, printer_writer = await asyncio.wait_for(
  571. asyncio.open_connection(self.target_host, self.target_port),
  572. timeout=10.0,
  573. )
  574. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  575. except TimeoutError:
  576. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  577. client_writer.close()
  578. await client_writer.wait_closed()
  579. return
  580. except OSError as e:
  581. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  582. client_writer.close()
  583. await client_writer.wait_closed()
  584. return
  585. client_to_printer = asyncio.create_task(
  586. self._forward(client_reader, printer_writer, f"{client_id}→printer"),
  587. name=f"{self.name}_c2p_{client_id}",
  588. )
  589. printer_to_client = asyncio.create_task(
  590. self._forward(printer_reader, client_writer, f"printer→{client_id}"),
  591. name=f"{self.name}_p2c_{client_id}",
  592. )
  593. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  594. try:
  595. done, pending = await asyncio.wait(
  596. [client_to_printer, printer_to_client],
  597. return_when=asyncio.FIRST_COMPLETED,
  598. )
  599. for task in pending:
  600. task.cancel()
  601. try:
  602. await task
  603. except asyncio.CancelledError:
  604. pass
  605. except Exception as e:
  606. logger.debug("%s proxy connection error: %s", self.name, e)
  607. finally:
  608. self._active_connections.pop(client_id, None)
  609. for writer in [client_writer, printer_writer]:
  610. try:
  611. writer.close()
  612. await writer.wait_closed()
  613. except OSError:
  614. pass
  615. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  616. if self.on_disconnect:
  617. try:
  618. self.on_disconnect(client_id)
  619. except Exception:
  620. pass
  621. async def _forward(
  622. self,
  623. reader: asyncio.StreamReader,
  624. writer: asyncio.StreamWriter,
  625. direction: str,
  626. ) -> None:
  627. """Forward data from reader to writer."""
  628. total_bytes = 0
  629. try:
  630. while self._running:
  631. data = await reader.read(65536)
  632. if not data:
  633. break
  634. writer.write(data)
  635. await writer.drain()
  636. total_bytes += len(data)
  637. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  638. except asyncio.CancelledError:
  639. pass
  640. except ConnectionResetError:
  641. logger.debug("%s proxy %s: connection reset", self.name, direction)
  642. except BrokenPipeError:
  643. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  644. except OSError as e:
  645. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  646. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  647. class FTPTLSProxy(TLSProxy):
  648. """FTP-aware TLS proxy that handles passive data connections.
  649. Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
  650. channel, dynamically create TLS data proxies on local ports, and rewrite
  651. the responses so the slicer connects to the proxy instead of the printer.
  652. Without this, FTP passive data connections bypass the proxy and go directly
  653. to the printer, which fails when the slicer can't reach the printer's IP.
  654. """
  655. PASV_PORT_MIN = 50000
  656. PASV_PORT_MAX = 50100
  657. async def stop(self) -> None:
  658. """Stop proxy and clean up data connection servers."""
  659. # Close all data servers first
  660. for server in list(self._data_servers):
  661. try:
  662. server.close()
  663. await server.wait_closed()
  664. except OSError:
  665. pass # Best-effort cleanup of data proxy servers
  666. self._data_servers.clear()
  667. await super().stop()
  668. async def start(self) -> None:
  669. """Start the FTP TLS proxy."""
  670. self._data_servers: list[asyncio.Server] = []
  671. await super().start()
  672. async def _handle_client(
  673. self,
  674. client_reader: asyncio.StreamReader,
  675. client_writer: asyncio.StreamWriter,
  676. ) -> None:
  677. """Handle FTP client with PASV/EPSV-aware response forwarding."""
  678. peername = client_writer.get_extra_info("peername")
  679. client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  680. logger.info("%s proxy: client connected from %s", self.name, client_id)
  681. if self.on_connect:
  682. try:
  683. self.on_connect(client_id)
  684. except Exception:
  685. pass # Ignore connect callback errors; connection proceeds regardless
  686. # Determine our local IP from the control connection socket
  687. sockname = client_writer.get_extra_info("sockname")
  688. local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
  689. if local_ip in ("0.0.0.0", "::"): # nosec B104
  690. local_ip = "127.0.0.1"
  691. # Connect to target printer with TLS
  692. try:
  693. printer_reader, printer_writer = await asyncio.wait_for(
  694. asyncio.open_connection(
  695. self.target_host,
  696. self.target_port,
  697. ssl=self._client_ssl_context,
  698. ),
  699. timeout=10.0,
  700. )
  701. logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
  702. except TimeoutError:
  703. logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
  704. client_writer.close()
  705. await client_writer.wait_closed()
  706. return
  707. except ssl.SSLError as e:
  708. logger.error(
  709. "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
  710. )
  711. client_writer.close()
  712. await client_writer.wait_closed()
  713. return
  714. except OSError as e:
  715. logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
  716. client_writer.close()
  717. await client_writer.wait_closed()
  718. return
  719. # Track data channel protection level per session.
  720. # PROT C = cleartext data, PROT P = TLS data.
  721. # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
  722. # If the slicer sends PROT P, we switch to TLS for data connections.
  723. session_state: dict[str, str] = {"prot": "C"}
  724. # Client→Printer: intercept EPSV and replace with PASV
  725. # EPSV responses only contain a port (no IP), so the slicer reuses
  726. # the control connection IP. If that IP is the real printer (via
  727. # iptables REDIRECT), the data connection bypasses the proxy.
  728. # PASV responses include an explicit IP that we can rewrite.
  729. client_to_printer = asyncio.create_task(
  730. self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
  731. name=f"{self.name}_c2p_{client_id}",
  732. )
  733. # Printer→Client: intercept PASV/EPSV responses
  734. printer_to_client = asyncio.create_task(
  735. self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
  736. name=f"{self.name}_p2c_{client_id}",
  737. )
  738. self._active_connections[client_id] = (client_to_printer, printer_to_client)
  739. try:
  740. done, pending = await asyncio.wait(
  741. [client_to_printer, printer_to_client],
  742. return_when=asyncio.FIRST_COMPLETED,
  743. )
  744. for task in pending:
  745. task.cancel()
  746. try:
  747. await task
  748. except asyncio.CancelledError:
  749. pass # Expected when cancelling the other forwarding direction
  750. except Exception as e:
  751. logger.debug("%s proxy connection error: %s", self.name, e)
  752. finally:
  753. self._active_connections.pop(client_id, None)
  754. for writer in [client_writer, printer_writer]:
  755. try:
  756. writer.close()
  757. await writer.wait_closed()
  758. except OSError:
  759. pass # Best-effort connection cleanup; peer may have disconnected
  760. logger.info("%s proxy: client %s disconnected", self.name, client_id)
  761. if self.on_disconnect:
  762. try:
  763. self.on_disconnect(client_id)
  764. except Exception:
  765. pass # Ignore disconnect callback errors; cleanup continues
  766. async def _forward_ftp_commands(
  767. self,
  768. reader: asyncio.StreamReader,
  769. writer: asyncio.StreamWriter,
  770. direction: str,
  771. session_state: dict[str, str],
  772. ) -> None:
  773. """Forward FTP client commands, replacing EPSV with PASV.
  774. EPSV responses only contain a port number — the client reuses the
  775. control connection IP for data. When the control IP is the real
  776. printer (due to iptables REDIRECT), EPSV data connections bypass
  777. the proxy. PASV responses include an explicit IP that the proxy
  778. can rewrite to its own address.
  779. Also tracks PROT P/C commands to know whether data connections
  780. should use TLS or cleartext.
  781. """
  782. buffer = b""
  783. total_bytes = 0
  784. try:
  785. while self._running:
  786. data = await reader.read(65536)
  787. if not data:
  788. break
  789. total_bytes += len(data)
  790. buffer += data
  791. output = b""
  792. while b"\r\n" in buffer:
  793. idx = buffer.index(b"\r\n")
  794. line = buffer[:idx]
  795. buffer = buffer[idx + 2 :]
  796. cmd_upper = line.strip().upper()
  797. # Replace EPSV with PASV so response includes an IP
  798. if cmd_upper == b"EPSV":
  799. line = b"PASV"
  800. logger.info("FTP command rewrite: EPSV → PASV")
  801. # Track PROT level for data channel encryption
  802. elif cmd_upper == b"PROT P":
  803. session_state["prot"] = "P"
  804. logger.info("FTP data protection: PROT P (TLS)")
  805. elif cmd_upper == b"PROT C":
  806. session_state["prot"] = "C"
  807. logger.info("FTP data protection: PROT C (cleartext)")
  808. output += line + b"\r\n"
  809. if output:
  810. writer.write(output)
  811. await writer.drain()
  812. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  813. except asyncio.CancelledError:
  814. pass # Expected when the other forwarding direction closes first
  815. except ConnectionResetError:
  816. logger.debug("%s proxy %s: connection reset", self.name, direction)
  817. except BrokenPipeError:
  818. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  819. except OSError as e:
  820. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  821. if buffer:
  822. try:
  823. writer.write(buffer)
  824. await writer.drain()
  825. except OSError:
  826. pass # Best-effort flush of remaining FTP command data
  827. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  828. async def _forward_ftp_control(
  829. self,
  830. reader: asyncio.StreamReader,
  831. writer: asyncio.StreamWriter,
  832. direction: str,
  833. local_ip: str,
  834. session_state: dict[str, str],
  835. ) -> None:
  836. """Forward FTP control channel responses, rewriting PASV/EPSV.
  837. FTP control channel is line-based (\\r\\n terminated). We buffer data
  838. and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
  839. responses to create local data proxies.
  840. """
  841. buffer = b""
  842. total_bytes = 0
  843. try:
  844. while self._running:
  845. data = await reader.read(65536)
  846. if not data:
  847. break
  848. total_bytes += len(data)
  849. buffer += data
  850. output = b""
  851. # Process all complete lines
  852. while b"\r\n" in buffer:
  853. idx = buffer.index(b"\r\n")
  854. line = buffer[:idx]
  855. buffer = buffer[idx + 2 :]
  856. rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
  857. output += rewritten + b"\r\n"
  858. if output:
  859. writer.write(output)
  860. await writer.drain()
  861. logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
  862. except asyncio.CancelledError:
  863. pass # Expected when the other forwarding direction closes first
  864. except ConnectionResetError:
  865. logger.debug("%s proxy %s: connection reset", self.name, direction)
  866. except BrokenPipeError:
  867. logger.debug("%s proxy %s: broken pipe", self.name, direction)
  868. except OSError as e:
  869. logger.debug("%s proxy %s error: %s", self.name, direction, e)
  870. # Flush any remaining buffered data
  871. if buffer:
  872. try:
  873. writer.write(buffer)
  874. await writer.drain()
  875. except OSError:
  876. pass # Best-effort flush of remaining FTP control data
  877. logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
  878. async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
  879. """Rewrite PASV/EPSV response to point to a local data proxy."""
  880. try:
  881. text = line.decode("utf-8")
  882. except UnicodeDecodeError:
  883. return line
  884. # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
  885. if text.startswith("227 "):
  886. match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
  887. if match:
  888. h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
  889. printer_ip = f"{h1}.{h2}.{h3}.{h4}"
  890. printer_port = p1 * 256 + p2
  891. local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
  892. if local_port:
  893. ip_parts = local_ip.split(".")
  894. lp1 = local_port // 256
  895. lp2 = local_port % 256
  896. rewritten = (
  897. f"227 Entering Passive Mode "
  898. f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
  899. )
  900. logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
  901. return rewritten.encode("utf-8")
  902. else:
  903. logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
  904. else:
  905. logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
  906. # 229 Entering Extended Passive Mode (|||port|)
  907. elif text.startswith("229 "):
  908. match = re.search(r"\(\|\|\|(\d+)\|\)", text)
  909. if match:
  910. printer_port = int(match.group(1))
  911. local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
  912. if local_port:
  913. rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
  914. logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
  915. return rewritten.encode("utf-8")
  916. else:
  917. logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
  918. else:
  919. logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
  920. return line
  921. async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
  922. """Create a one-shot proxy for an FTP data connection.
  923. Prefers the printer's original passive port so the port number stays
  924. the same in the rewritten PASV/EPSV response. This is critical when
  925. the slicer's FTP bounce-attack protection overrides the IP in the PASV
  926. response: the slicer connects to <control_IP>:<port>, and if iptables
  927. REDIRECT maps that port to the local machine, the data proxy must be
  928. listening on the *same* port number.
  929. Falls back to a random port if the original is unavailable.
  930. Uses TLS or cleartext based on the session's PROT level:
  931. - PROT P: TLS on both slicer and printer data connections
  932. - PROT C: cleartext on both sides (common for A1/H2D printers)
  933. Returns the local port number, or None if binding failed.
  934. """
  935. use_tls = session_state.get("prot") == "P"
  936. logger.info(
  937. "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
  938. printer_ip,
  939. printer_port,
  940. "TLS" if use_tls else "cleartext",
  941. )
  942. # Try the printer's original port first — this ensures the port
  943. # matches even when bounce protection or iptables REDIRECT is in play.
  944. try:
  945. await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
  946. logger.info("FTP data proxy: using printer's port %s", printer_port)
  947. return printer_port
  948. except OSError as e:
  949. logger.debug(
  950. "FTP data proxy: printer port %s unavailable (%s), trying random",
  951. printer_port,
  952. e,
  953. )
  954. for _attempt in range(10):
  955. port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
  956. try:
  957. await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
  958. logger.info("FTP data proxy: using random port %s", port)
  959. return port
  960. except OSError:
  961. continue
  962. logger.error("Failed to bind FTP data proxy port after 10 attempts")
  963. return None
  964. async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
  965. """Start a one-shot server for one FTP data connection.
  966. When the slicer connects, immediately connects to the printer's data
  967. port and buffers any slicer data until the printer connection is ready.
  968. This handles zero-byte uploads (verify_job) where the slicer closes
  969. the data channel before a naive proxy would finish its TLS handshake.
  970. The slicer-side listener is ALWAYS cleartext. Even when the slicer
  971. sends PROT P on the control channel, Bambu Studio does not perform
  972. a TLS handshake on the data connection — it relies on the implicit
  973. FTPS control channel for authentication and sends data unencrypted.
  974. The printer-side outbound connection follows the PROT level:
  975. - PROT P (use_tls=True): TLS to the printer's data port
  976. - PROT C (use_tls=False): cleartext to the printer's data port
  977. This mirrors the control channel's TLS-termination architecture.
  978. Raises OSError if the port is already in use.
  979. """
  980. connected = asyncio.Event()
  981. server_holder: list[asyncio.Server] = []
  982. # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
  983. # the data channel even after sending PROT P.
  984. # Printer side: TLS if PROT P, cleartext if PROT C.
  985. client_ssl = self._client_ssl_context if use_tls else None
  986. printer_mode = "TLS" if use_tls else "cleartext"
  987. async def handle_data(
  988. client_reader: asyncio.StreamReader,
  989. client_writer: asyncio.StreamWriter,
  990. ) -> None:
  991. """Handle one FTP data connection, then close the server."""
  992. peername = client_writer.get_extra_info("peername")
  993. data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
  994. logger.info(
  995. "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
  996. port,
  997. printer_mode,
  998. data_client,
  999. printer_ip,
  1000. printer_port,
  1001. )
  1002. connected.set()
  1003. # One-shot: close server after accepting first connection
  1004. if server_holder:
  1005. server_holder[0].close()
  1006. printer_writer = None
  1007. try:
  1008. # Buffer any slicer data while connecting to printer.
  1009. # This handles the race where the slicer sends data (or closes
  1010. # for zero-byte files) before the TLS handshake completes.
  1011. slicer_buffer = bytearray()
  1012. slicer_eof = False
  1013. async def buffer_slicer():
  1014. nonlocal slicer_eof
  1015. while True:
  1016. chunk = await client_reader.read(65536)
  1017. if not chunk:
  1018. slicer_eof = True
  1019. return
  1020. slicer_buffer.extend(chunk)
  1021. buffer_task = asyncio.create_task(buffer_slicer())
  1022. # Connect to printer's data port
  1023. printer_reader, printer_writer = await asyncio.wait_for(
  1024. asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
  1025. timeout=10.0,
  1026. )
  1027. logger.info(
  1028. "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
  1029. port,
  1030. printer_mode,
  1031. printer_ip,
  1032. printer_port,
  1033. )
  1034. # Stop buffering
  1035. buffer_task.cancel()
  1036. try:
  1037. await buffer_task
  1038. except asyncio.CancelledError:
  1039. pass
  1040. # Flush buffered slicer data to printer
  1041. if slicer_buffer:
  1042. printer_writer.write(bytes(slicer_buffer))
  1043. await printer_writer.drain()
  1044. if slicer_eof:
  1045. # Slicer already closed (zero-byte upload like verify_job).
  1046. # Close the printer write side to signal upload complete.
  1047. if printer_writer.can_write_eof():
  1048. printer_writer.write_eof()
  1049. else:
  1050. # Continue bidirectional forwarding
  1051. c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
  1052. p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
  1053. done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
  1054. for task in pending:
  1055. task.cancel()
  1056. try:
  1057. await task
  1058. except asyncio.CancelledError:
  1059. pass # Expected when other data direction closes
  1060. except Exception as e:
  1061. logger.error("FTP data proxy port %s: error: %s", port, e)
  1062. finally:
  1063. for w in [client_writer, printer_writer]:
  1064. if w:
  1065. try:
  1066. w.close()
  1067. await w.wait_closed()
  1068. except OSError:
  1069. pass # Best-effort data connection cleanup
  1070. logger.info("FTP data proxy port %s: connection closed", port)
  1071. server = await asyncio.start_server(
  1072. handle_data,
  1073. "0.0.0.0", # nosec B104
  1074. port,
  1075. # No TLS on slicer side — Bambu Studio doesn't do TLS on data
  1076. # channel even after PROT P. The proxy terminates TLS only on
  1077. # the printer side (inside handle_data).
  1078. )
  1079. server_holder.append(server)
  1080. self._data_servers.append(server)
  1081. # Auto-close after 60s if no connection arrives
  1082. async def auto_close() -> None:
  1083. try:
  1084. await asyncio.wait_for(connected.wait(), timeout=60.0)
  1085. except TimeoutError:
  1086. logger.debug("FTP data proxy on port %s timed out, closing", port)
  1087. try:
  1088. server.close()
  1089. await server.wait_closed()
  1090. except OSError:
  1091. pass # Best-effort timeout cleanup
  1092. finally:
  1093. if server in self._data_servers:
  1094. self._data_servers.remove(server)
  1095. asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
  1096. logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
  1097. class SlicerProxyManager:
  1098. """Manages FTP and MQTT TLS proxies for a single printer target."""
  1099. # Bambu printer ports
  1100. PRINTER_FTP_PORT = 990
  1101. PRINTER_MQTT_PORT = 8883
  1102. PRINTER_FILE_TRANSFER_PORT = 6000
  1103. PRINTER_RTSP_PORT = 322 # X1/H2/P2 series camera (A1/P1 use port 6000)
  1104. PRINTER_BIND_PORTS = [3000, 3002]
  1105. # Local listen ports - must match what Bambu Studio expects
  1106. # Note: Port 990 requires root or CAP_NET_BIND_SERVICE capability
  1107. LOCAL_FTP_PORT = 990
  1108. LOCAL_MQTT_PORT = 8883
  1109. def __init__(
  1110. self,
  1111. target_host: str,
  1112. cert_path: Path,
  1113. key_path: Path,
  1114. on_activity: Callable[[str, str], None] | None = None,
  1115. bind_address: str = "0.0.0.0", # nosec B104
  1116. bind_identity: dict[str, str] | None = None,
  1117. ):
  1118. """Initialize the slicer proxy manager.
  1119. Args:
  1120. target_host: Target printer IP address
  1121. cert_path: Path to server certificate
  1122. key_path: Path to server private key
  1123. on_activity: Optional callback for activity logging (name, message)
  1124. bind_address: IP address to bind proxy listeners to
  1125. bind_identity: Optional dict with keys (serial, model, name, version)
  1126. for the bind/detect response. When provided, the proxy responds
  1127. to detect requests itself instead of forwarding to the printer.
  1128. This ensures the slicer sees the VP identity, not the real printer.
  1129. """
  1130. self.target_host = target_host
  1131. self.cert_path = cert_path
  1132. self.key_path = key_path
  1133. self.on_activity = on_activity
  1134. self.bind_address = bind_address
  1135. self.bind_identity = bind_identity
  1136. self._ftp_proxy: TLSProxy | None = None
  1137. self._mqtt_proxy: TLSProxy | None = None
  1138. self._file_transfer_proxy: TLSProxy | None = None
  1139. self._rtsp_proxy: TLSProxy | None = None
  1140. self._bind_proxies: list[TCPProxy] = []
  1141. self._bind_server = None
  1142. self._tasks: list[asyncio.Task] = []
  1143. async def start(self) -> None:
  1144. """Start FTP and MQTT TLS proxies."""
  1145. logger.info("Starting slicer TLS proxy to %s", self.target_host)
  1146. # Detect iptables port redirect (e.g. if an external redirect exists).
  1147. # If active, connections get intercepted by iptables PREROUTING
  1148. # and sent to the redirect target — our socket never sees them.
  1149. ftp_listen_port = self.LOCAL_FTP_PORT
  1150. redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
  1151. if redirect_target:
  1152. logger.info(
  1153. "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
  1154. self.LOCAL_FTP_PORT,
  1155. redirect_target,
  1156. redirect_target,
  1157. )
  1158. ftp_listen_port = redirect_target
  1159. # Create FTP proxy with PASV/EPSV awareness for data connections
  1160. self._ftp_proxy = FTPTLSProxy(
  1161. name="FTP",
  1162. listen_port=ftp_listen_port,
  1163. target_host=self.target_host,
  1164. target_port=self.PRINTER_FTP_PORT,
  1165. server_cert_path=self.cert_path,
  1166. server_key_path=self.key_path,
  1167. on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
  1168. on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
  1169. bind_address=self.bind_address,
  1170. )
  1171. self._mqtt_proxy = TLSProxy(
  1172. name="MQTT",
  1173. listen_port=self.LOCAL_MQTT_PORT,
  1174. target_host=self.target_host,
  1175. target_port=self.PRINTER_MQTT_PORT,
  1176. server_cert_path=self.cert_path,
  1177. server_key_path=self.key_path,
  1178. on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
  1179. on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
  1180. bind_address=self.bind_address,
  1181. rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
  1182. )
  1183. # File transfer proxy — port 6000 (TLS)
  1184. # BambuStudio connects here for verify_job and actual file uploads.
  1185. self._file_transfer_proxy = TLSProxy(
  1186. name="FileTransfer",
  1187. listen_port=self.PRINTER_FILE_TRANSFER_PORT,
  1188. target_host=self.target_host,
  1189. target_port=self.PRINTER_FILE_TRANSFER_PORT,
  1190. server_cert_path=self.cert_path,
  1191. server_key_path=self.key_path,
  1192. on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
  1193. on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
  1194. bind_address=self.bind_address,
  1195. )
  1196. # RTSP camera proxy — port 322 (TLS)
  1197. # X1/H2/P2 series use RTSP on port 322 for camera streaming.
  1198. # A1/P1 series use port 6000 (already proxied via file transfer proxy).
  1199. self._rtsp_proxy = TLSProxy(
  1200. name="RTSP",
  1201. listen_port=self.PRINTER_RTSP_PORT,
  1202. target_host=self.target_host,
  1203. target_port=self.PRINTER_RTSP_PORT,
  1204. server_cert_path=self.cert_path,
  1205. server_key_path=self.key_path,
  1206. on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
  1207. on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
  1208. bind_address=self.bind_address,
  1209. )
  1210. # Bind/auth — respond with VP identity instead of proxying to printer.
  1211. # The detect response contains the printer name, serial, model, and
  1212. # bind status. Proxying it would leak the real printer's identity and
  1213. # cause the slicer to treat it as a different device.
  1214. if self.bind_identity:
  1215. from backend.app.services.virtual_printer.bind_server import BindServer
  1216. self._bind_server = BindServer(
  1217. serial=self.bind_identity["serial"],
  1218. model=self.bind_identity["model"],
  1219. name=self.bind_identity["name"],
  1220. version=self.bind_identity.get("version", "01.00.00.00"),
  1221. bind_address=self.bind_address,
  1222. cert_path=self.cert_path,
  1223. key_path=self.key_path,
  1224. )
  1225. else:
  1226. # Fallback: proxy bind requests to the real printer
  1227. for bind_port in self.PRINTER_BIND_PORTS:
  1228. if bind_port == 3002:
  1229. proxy = TLSProxy(
  1230. name="Bind-TLS",
  1231. listen_port=bind_port,
  1232. target_host=self.target_host,
  1233. target_port=bind_port,
  1234. server_cert_path=self.cert_path,
  1235. server_key_path=self.key_path,
  1236. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  1237. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  1238. bind_address=self.bind_address,
  1239. )
  1240. else:
  1241. proxy = TCPProxy(
  1242. name="Bind",
  1243. listen_port=bind_port,
  1244. target_host=self.target_host,
  1245. target_port=bind_port,
  1246. on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
  1247. on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
  1248. bind_address=self.bind_address,
  1249. )
  1250. self._bind_proxies.append(proxy)
  1251. # Start as background tasks
  1252. async def run_with_logging(proxy: TLSProxy) -> None:
  1253. try:
  1254. await proxy.start()
  1255. except Exception as e:
  1256. logger.error("Slicer proxy %s failed: %s", proxy.name, e)
  1257. self._tasks = [
  1258. asyncio.create_task(
  1259. run_with_logging(self._ftp_proxy),
  1260. name="slicer_proxy_ftp",
  1261. ),
  1262. asyncio.create_task(
  1263. run_with_logging(self._mqtt_proxy),
  1264. name="slicer_proxy_mqtt",
  1265. ),
  1266. asyncio.create_task(
  1267. run_with_logging(self._file_transfer_proxy),
  1268. name="slicer_proxy_file_transfer",
  1269. ),
  1270. asyncio.create_task(
  1271. run_with_logging(self._rtsp_proxy),
  1272. name="slicer_proxy_rtsp",
  1273. ),
  1274. ]
  1275. if self._bind_server:
  1276. self._tasks.append(
  1277. asyncio.create_task(
  1278. run_with_logging(self._bind_server),
  1279. name="slicer_proxy_bind_server",
  1280. )
  1281. )
  1282. for bp in self._bind_proxies:
  1283. self._tasks.append(
  1284. asyncio.create_task(
  1285. run_with_logging(bp),
  1286. name=f"slicer_proxy_bind_{bp.listen_port}",
  1287. )
  1288. )
  1289. logger.info("Slicer TLS proxy started for %s", self.target_host)
  1290. # Wait for tasks to complete (they run until cancelled)
  1291. # This keeps the start() coroutine alive so the parent task doesn't complete
  1292. try:
  1293. await asyncio.gather(*self._tasks)
  1294. except asyncio.CancelledError:
  1295. logger.debug("Slicer proxy start cancelled")
  1296. async def stop(self) -> None:
  1297. """Stop all proxies."""
  1298. logger.info("Stopping slicer proxy")
  1299. # Stop proxies
  1300. if self._ftp_proxy:
  1301. await self._ftp_proxy.stop()
  1302. self._ftp_proxy = None
  1303. if self._mqtt_proxy:
  1304. await self._mqtt_proxy.stop()
  1305. self._mqtt_proxy = None
  1306. if self._file_transfer_proxy:
  1307. await self._file_transfer_proxy.stop()
  1308. self._file_transfer_proxy = None
  1309. if self._rtsp_proxy:
  1310. await self._rtsp_proxy.stop()
  1311. self._rtsp_proxy = None
  1312. if self._bind_server:
  1313. await self._bind_server.stop()
  1314. self._bind_server = None
  1315. for bp in self._bind_proxies:
  1316. await bp.stop()
  1317. self._bind_proxies = []
  1318. # Cancel tasks
  1319. for task in self._tasks:
  1320. task.cancel()
  1321. if self._tasks:
  1322. try:
  1323. await asyncio.wait_for(
  1324. asyncio.gather(*self._tasks, return_exceptions=True),
  1325. timeout=2.0,
  1326. )
  1327. except TimeoutError:
  1328. logger.debug("Some proxy tasks didn't stop in time")
  1329. self._tasks = []
  1330. logger.info("Slicer proxy stopped")
  1331. def _log_activity(self, name: str, message: str) -> None:
  1332. """Log activity via callback if configured."""
  1333. if self.on_activity:
  1334. try:
  1335. self.on_activity(name, message)
  1336. except Exception:
  1337. pass # Ignore activity callback errors; logging is non-critical
  1338. @property
  1339. def is_running(self) -> bool:
  1340. """Check if proxies are running."""
  1341. return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
  1342. def get_status(self) -> dict:
  1343. """Get proxy status."""
  1344. return {
  1345. "running": self.is_running,
  1346. "target_host": self.target_host,
  1347. "ftp_port": self.LOCAL_FTP_PORT,
  1348. "mqtt_port": self.LOCAL_MQTT_PORT,
  1349. "bind_ports": self.PRINTER_BIND_PORTS,
  1350. "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
  1351. "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
  1352. "bind_connections": sum(len(bp._active_connections) for bp in self._bind_proxies),
  1353. }