|
@@ -14,7 +14,6 @@ rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
|
|
|
|
|
|
|
|
import asyncio
|
|
import asyncio
|
|
|
import logging
|
|
import logging
|
|
|
-import random
|
|
|
|
|
import re
|
|
import re
|
|
|
import ssl
|
|
import ssl
|
|
|
import subprocess
|
|
import subprocess
|
|
@@ -24,44 +23,6 @@ from pathlib import Path
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
-class _SessionReuseSSLContext:
|
|
|
|
|
- """Proxy around SSLContext that injects a TLS session into wrap_bio().
|
|
|
|
|
-
|
|
|
|
|
- vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
|
|
|
|
|
- on FTP data channels — the data connection must reuse the TLS session from
|
|
|
|
|
- the control channel. Without this, the printer rejects the data connection
|
|
|
|
|
- with "522 SSL connection failed: session reuse required".
|
|
|
|
|
-
|
|
|
|
|
- asyncio's open_connection() calls SSLContext.wrap_bio() internally but
|
|
|
|
|
- doesn't expose a session parameter. This wrapper intercepts wrap_bio()
|
|
|
|
|
- to inject the saved control-channel session, enabling session reuse.
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
|
|
|
|
|
- object.__setattr__(self, "_ctx", ctx)
|
|
|
|
|
- object.__setattr__(self, "_session", session)
|
|
|
|
|
-
|
|
|
|
|
- def __getattr__(self, name: str) -> object:
|
|
|
|
|
- return getattr(self._ctx, name)
|
|
|
|
|
-
|
|
|
|
|
- def wrap_bio(
|
|
|
|
|
- self,
|
|
|
|
|
- incoming: ssl.MemoryBIO,
|
|
|
|
|
- outgoing: ssl.MemoryBIO,
|
|
|
|
|
- server_side: bool = False,
|
|
|
|
|
- server_hostname: str | None = None,
|
|
|
|
|
- **kwargs: object,
|
|
|
|
|
- ) -> ssl.SSLObject:
|
|
|
|
|
- return self._ctx.wrap_bio(
|
|
|
|
|
- incoming,
|
|
|
|
|
- outgoing,
|
|
|
|
|
- server_side=server_side,
|
|
|
|
|
- server_hostname=server_hostname,
|
|
|
|
|
- session=self._session,
|
|
|
|
|
- **kwargs,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
def detect_port_redirect(port: int) -> int | None:
|
|
def detect_port_redirect(port: int) -> int | None:
|
|
|
"""Detect if iptables redirects a port to another port.
|
|
"""Detect if iptables redirects a port to another port.
|
|
|
|
|
|
|
@@ -805,636 +766,6 @@ class TCPProxy:
|
|
|
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
|
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
|
|
|
|
|
|
|
|
|
|
|
|
-class FTPTLSProxy(TLSProxy):
|
|
|
|
|
- """FTP-aware TLS proxy that handles passive data connections.
|
|
|
|
|
-
|
|
|
|
|
- Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
|
|
|
|
|
- channel, dynamically create TLS data proxies on local ports, and rewrite
|
|
|
|
|
- the responses so the slicer connects to the proxy instead of the printer.
|
|
|
|
|
-
|
|
|
|
|
- Without this, FTP passive data connections bypass the proxy and go directly
|
|
|
|
|
- to the printer, which fails when the slicer can't reach the printer's IP.
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- PASV_PORT_MIN = 50000
|
|
|
|
|
- PASV_PORT_MAX = 50100
|
|
|
|
|
-
|
|
|
|
|
- async def stop(self) -> None:
|
|
|
|
|
- """Stop proxy and clean up data connection servers."""
|
|
|
|
|
- # Cancel any pending auto_close timeouts so they don't outlive the
|
|
|
|
|
- # proxy holding a dead server reference. Without this, up to 101
|
|
|
|
|
- # tasks lingered for ~60 s after stop(), each gripping a server
|
|
|
|
|
- # ref + an active_connections slot; under rapid mode-switch the
|
|
|
|
|
- # ports stayed bound long enough to fail the next start().
|
|
|
|
|
- for task in list(self._auto_close_tasks):
|
|
|
|
|
- task.cancel()
|
|
|
|
|
- if self._auto_close_tasks:
|
|
|
|
|
- await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
|
|
|
|
|
- self._auto_close_tasks.clear()
|
|
|
|
|
- # Close all data servers
|
|
|
|
|
- for server in list(self._data_servers):
|
|
|
|
|
- try:
|
|
|
|
|
- server.close()
|
|
|
|
|
- await server.wait_closed()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort cleanup of data proxy servers
|
|
|
|
|
- self._data_servers.clear()
|
|
|
|
|
- await super().stop()
|
|
|
|
|
-
|
|
|
|
|
- async def start(self) -> None:
|
|
|
|
|
- """Start the FTP TLS proxy."""
|
|
|
|
|
- self._data_servers: list[asyncio.Server] = []
|
|
|
|
|
- self._auto_close_tasks: list[asyncio.Task] = []
|
|
|
|
|
- await super().start()
|
|
|
|
|
-
|
|
|
|
|
- async def _handle_client(
|
|
|
|
|
- self,
|
|
|
|
|
- client_reader: asyncio.StreamReader,
|
|
|
|
|
- client_writer: asyncio.StreamWriter,
|
|
|
|
|
- ) -> None:
|
|
|
|
|
- """Handle FTP client with PASV/EPSV-aware response forwarding."""
|
|
|
|
|
- peername = client_writer.get_extra_info("peername")
|
|
|
|
|
- client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
|
|
|
|
|
-
|
|
|
|
|
- logger.info("%s proxy: client connected from %s", self.name, client_id)
|
|
|
|
|
-
|
|
|
|
|
- if self.on_connect:
|
|
|
|
|
- try:
|
|
|
|
|
- self.on_connect(client_id)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- pass # Ignore connect callback errors; connection proceeds regardless
|
|
|
|
|
-
|
|
|
|
|
- # Determine our local IP from the control connection socket
|
|
|
|
|
- sockname = client_writer.get_extra_info("sockname")
|
|
|
|
|
- local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
|
|
|
|
|
- if local_ip in ("0.0.0.0", "::"): # nosec B104
|
|
|
|
|
- local_ip = "127.0.0.1"
|
|
|
|
|
-
|
|
|
|
|
- # Connect to target printer with TLS
|
|
|
|
|
- try:
|
|
|
|
|
- printer_reader, printer_writer = await asyncio.wait_for(
|
|
|
|
|
- asyncio.open_connection(
|
|
|
|
|
- self.target_host,
|
|
|
|
|
- self.target_port,
|
|
|
|
|
- ssl=self._client_ssl_context,
|
|
|
|
|
- ),
|
|
|
|
|
- timeout=10.0,
|
|
|
|
|
- )
|
|
|
|
|
- logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
|
|
|
|
|
- except TimeoutError:
|
|
|
|
|
- logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
|
|
|
|
|
- client_writer.close()
|
|
|
|
|
- await client_writer.wait_closed()
|
|
|
|
|
- return
|
|
|
|
|
- except ssl.SSLError as e:
|
|
|
|
|
- logger.error(
|
|
|
|
|
- "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
|
|
|
|
|
- )
|
|
|
|
|
- client_writer.close()
|
|
|
|
|
- await client_writer.wait_closed()
|
|
|
|
|
- return
|
|
|
|
|
- except OSError as e:
|
|
|
|
|
- logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
|
|
|
|
|
- client_writer.close()
|
|
|
|
|
- await client_writer.wait_closed()
|
|
|
|
|
- return
|
|
|
|
|
-
|
|
|
|
|
- # Capture the TLS session from the control channel for data channel
|
|
|
|
|
- # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
|
|
|
|
|
- # must present the same TLS session as the control channel.
|
|
|
|
|
- ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
|
|
|
|
|
- ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
|
|
|
|
|
- if ctrl_tls_session:
|
|
|
|
|
- logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
|
|
|
|
|
-
|
|
|
|
|
- # Track data channel protection level per session.
|
|
|
|
|
- # PROT C = cleartext data, PROT P = TLS data.
|
|
|
|
|
- # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
|
|
|
|
|
- # If the slicer sends PROT P, we switch to TLS for data connections.
|
|
|
|
|
- session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
|
|
|
|
|
- if ctrl_tls_session:
|
|
|
|
|
- session_state["tls_session"] = ctrl_tls_session
|
|
|
|
|
-
|
|
|
|
|
- # Client→Printer: intercept EPSV and replace with PASV
|
|
|
|
|
- # EPSV responses only contain a port (no IP), so the slicer reuses
|
|
|
|
|
- # the control connection IP. If that IP is the real printer (via
|
|
|
|
|
- # iptables REDIRECT), the data connection bypasses the proxy.
|
|
|
|
|
- # PASV responses include an explicit IP that we can rewrite.
|
|
|
|
|
- client_to_printer = asyncio.create_task(
|
|
|
|
|
- self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
|
|
|
|
|
- name=f"{self.name}_c2p_{client_id}",
|
|
|
|
|
- )
|
|
|
|
|
- # Printer→Client: intercept PASV/EPSV responses
|
|
|
|
|
- printer_to_client = asyncio.create_task(
|
|
|
|
|
- self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
|
|
|
|
|
- name=f"{self.name}_p2c_{client_id}",
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- self._active_connections[client_id] = (client_to_printer, printer_to_client)
|
|
|
|
|
-
|
|
|
|
|
- try:
|
|
|
|
|
- done, pending = await asyncio.wait(
|
|
|
|
|
- [client_to_printer, printer_to_client],
|
|
|
|
|
- return_when=asyncio.FIRST_COMPLETED,
|
|
|
|
|
- )
|
|
|
|
|
- for task in pending:
|
|
|
|
|
- task.cancel()
|
|
|
|
|
- try:
|
|
|
|
|
- await task
|
|
|
|
|
- except asyncio.CancelledError:
|
|
|
|
|
- pass # Expected when cancelling the other forwarding direction
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.debug("%s proxy connection error: %s", self.name, e)
|
|
|
|
|
- finally:
|
|
|
|
|
- self._active_connections.pop(client_id, None)
|
|
|
|
|
-
|
|
|
|
|
- for writer in [client_writer, printer_writer]:
|
|
|
|
|
- try:
|
|
|
|
|
- writer.close()
|
|
|
|
|
- await writer.wait_closed()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort connection cleanup; peer may have disconnected
|
|
|
|
|
-
|
|
|
|
|
- logger.info("%s proxy: client %s disconnected", self.name, client_id)
|
|
|
|
|
-
|
|
|
|
|
- if self.on_disconnect:
|
|
|
|
|
- try:
|
|
|
|
|
- self.on_disconnect(client_id)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- pass # Ignore disconnect callback errors; cleanup continues
|
|
|
|
|
-
|
|
|
|
|
- async def _forward_ftp_commands(
|
|
|
|
|
- self,
|
|
|
|
|
- reader: asyncio.StreamReader,
|
|
|
|
|
- writer: asyncio.StreamWriter,
|
|
|
|
|
- direction: str,
|
|
|
|
|
- session_state: dict[str, str | ssl.SSLSession],
|
|
|
|
|
- ) -> None:
|
|
|
|
|
- """Forward FTP client commands, replacing EPSV with PASV.
|
|
|
|
|
-
|
|
|
|
|
- EPSV responses only contain a port number — the client reuses the
|
|
|
|
|
- control connection IP for data. When the control IP is the real
|
|
|
|
|
- printer (due to iptables REDIRECT), EPSV data connections bypass
|
|
|
|
|
- the proxy. PASV responses include an explicit IP that the proxy
|
|
|
|
|
- can rewrite to its own address.
|
|
|
|
|
-
|
|
|
|
|
- Also tracks PROT P/C commands to know whether data connections
|
|
|
|
|
- should use TLS or cleartext.
|
|
|
|
|
- """
|
|
|
|
|
- buffer = b""
|
|
|
|
|
- total_bytes = 0
|
|
|
|
|
- try:
|
|
|
|
|
- while self._running:
|
|
|
|
|
- data = await reader.read(65536)
|
|
|
|
|
- if not data:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- total_bytes += len(data)
|
|
|
|
|
- buffer += data
|
|
|
|
|
- output = b""
|
|
|
|
|
-
|
|
|
|
|
- while b"\r\n" in buffer:
|
|
|
|
|
- idx = buffer.index(b"\r\n")
|
|
|
|
|
- line = buffer[:idx]
|
|
|
|
|
- buffer = buffer[idx + 2 :]
|
|
|
|
|
-
|
|
|
|
|
- cmd_upper = line.strip().upper()
|
|
|
|
|
-
|
|
|
|
|
- # Track PROT level for data channel encryption
|
|
|
|
|
- if cmd_upper == b"PROT P":
|
|
|
|
|
- session_state["prot"] = "P"
|
|
|
|
|
- logger.info("FTP data protection: PROT P (TLS)")
|
|
|
|
|
- elif cmd_upper == b"PROT C":
|
|
|
|
|
- session_state["prot"] = "C"
|
|
|
|
|
- logger.info("FTP data protection: PROT C (cleartext)")
|
|
|
|
|
-
|
|
|
|
|
- output += line + b"\r\n"
|
|
|
|
|
-
|
|
|
|
|
- if output:
|
|
|
|
|
- writer.write(output)
|
|
|
|
|
- await writer.drain()
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
|
|
|
|
|
-
|
|
|
|
|
- except asyncio.CancelledError:
|
|
|
|
|
- pass # Expected when the other forwarding direction closes first
|
|
|
|
|
- except ConnectionResetError:
|
|
|
|
|
- logger.debug("%s proxy %s: connection reset", self.name, direction)
|
|
|
|
|
- except BrokenPipeError:
|
|
|
|
|
- logger.debug("%s proxy %s: broken pipe", self.name, direction)
|
|
|
|
|
- except OSError as e:
|
|
|
|
|
- logger.debug("%s proxy %s error: %s", self.name, direction, e)
|
|
|
|
|
-
|
|
|
|
|
- if buffer:
|
|
|
|
|
- try:
|
|
|
|
|
- writer.write(buffer)
|
|
|
|
|
- await writer.drain()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort flush of remaining FTP command data
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
|
|
|
|
-
|
|
|
|
|
- async def _forward_ftp_control(
|
|
|
|
|
- self,
|
|
|
|
|
- reader: asyncio.StreamReader,
|
|
|
|
|
- writer: asyncio.StreamWriter,
|
|
|
|
|
- direction: str,
|
|
|
|
|
- local_ip: str,
|
|
|
|
|
- session_state: dict[str, str | ssl.SSLSession],
|
|
|
|
|
- ) -> None:
|
|
|
|
|
- """Forward FTP control channel responses, rewriting PASV/EPSV.
|
|
|
|
|
-
|
|
|
|
|
- FTP control channel is line-based (\\r\\n terminated). We buffer data
|
|
|
|
|
- and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
|
|
|
|
|
- responses to create local data proxies.
|
|
|
|
|
- """
|
|
|
|
|
- buffer = b""
|
|
|
|
|
- total_bytes = 0
|
|
|
|
|
-
|
|
|
|
|
- try:
|
|
|
|
|
- while self._running:
|
|
|
|
|
- data = await reader.read(65536)
|
|
|
|
|
- if not data:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- total_bytes += len(data)
|
|
|
|
|
- buffer += data
|
|
|
|
|
- output = b""
|
|
|
|
|
-
|
|
|
|
|
- # Process all complete lines
|
|
|
|
|
- while b"\r\n" in buffer:
|
|
|
|
|
- idx = buffer.index(b"\r\n")
|
|
|
|
|
- line = buffer[:idx]
|
|
|
|
|
- buffer = buffer[idx + 2 :]
|
|
|
|
|
-
|
|
|
|
|
- rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
|
|
|
|
|
- output += rewritten + b"\r\n"
|
|
|
|
|
-
|
|
|
|
|
- if output:
|
|
|
|
|
- writer.write(output)
|
|
|
|
|
- await writer.drain()
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
|
|
|
|
|
-
|
|
|
|
|
- except asyncio.CancelledError:
|
|
|
|
|
- pass # Expected when the other forwarding direction closes first
|
|
|
|
|
- except ConnectionResetError:
|
|
|
|
|
- logger.debug("%s proxy %s: connection reset", self.name, direction)
|
|
|
|
|
- except BrokenPipeError:
|
|
|
|
|
- logger.debug("%s proxy %s: broken pipe", self.name, direction)
|
|
|
|
|
- except OSError as e:
|
|
|
|
|
- logger.debug("%s proxy %s error: %s", self.name, direction, e)
|
|
|
|
|
-
|
|
|
|
|
- # Flush any remaining buffered data
|
|
|
|
|
- if buffer:
|
|
|
|
|
- try:
|
|
|
|
|
- writer.write(buffer)
|
|
|
|
|
- await writer.drain()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort flush of remaining FTP control data
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
|
|
|
|
|
-
|
|
|
|
|
- async def _maybe_rewrite_pasv(
|
|
|
|
|
- self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
|
|
|
|
|
- ) -> bytes:
|
|
|
|
|
- """Rewrite PASV/EPSV response to point to a local data proxy."""
|
|
|
|
|
- try:
|
|
|
|
|
- text = line.decode("utf-8")
|
|
|
|
|
- except UnicodeDecodeError:
|
|
|
|
|
- return line
|
|
|
|
|
-
|
|
|
|
|
- # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
|
|
|
|
|
- if text.startswith("227 "):
|
|
|
|
|
- match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
|
|
|
|
|
- if match:
|
|
|
|
|
- h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
|
|
|
|
|
- printer_ip = f"{h1}.{h2}.{h3}.{h4}"
|
|
|
|
|
- printer_port = p1 * 256 + p2
|
|
|
|
|
-
|
|
|
|
|
- local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
|
|
|
|
|
- if local_port:
|
|
|
|
|
- ip_parts = local_ip.split(".")
|
|
|
|
|
- lp1 = local_port // 256
|
|
|
|
|
- lp2 = local_port % 256
|
|
|
|
|
- rewritten = (
|
|
|
|
|
- f"227 Entering Passive Mode "
|
|
|
|
|
- f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
|
|
|
|
|
- )
|
|
|
|
|
- logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
|
|
|
|
|
- return rewritten.encode("utf-8")
|
|
|
|
|
- else:
|
|
|
|
|
- logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
|
|
|
|
|
- else:
|
|
|
|
|
- logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
|
|
|
|
|
-
|
|
|
|
|
- # 229 Entering Extended Passive Mode (|||port|)
|
|
|
|
|
- elif text.startswith("229 "):
|
|
|
|
|
- match = re.search(r"\(\|\|\|(\d+)\|\)", text)
|
|
|
|
|
- if match:
|
|
|
|
|
- printer_port = int(match.group(1))
|
|
|
|
|
-
|
|
|
|
|
- local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
|
|
|
|
|
- if local_port:
|
|
|
|
|
- rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
|
|
|
|
|
- logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
|
|
|
|
|
- return rewritten.encode("utf-8")
|
|
|
|
|
- else:
|
|
|
|
|
- logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
|
|
|
|
|
- else:
|
|
|
|
|
- logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
|
|
|
|
|
-
|
|
|
|
|
- return line
|
|
|
|
|
-
|
|
|
|
|
- async def _create_data_proxy(
|
|
|
|
|
- self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
|
|
|
|
|
- ) -> int | None:
|
|
|
|
|
- """Create a one-shot proxy for an FTP data connection.
|
|
|
|
|
-
|
|
|
|
|
- Prefers the printer's original passive port so the port number stays
|
|
|
|
|
- the same in the rewritten PASV/EPSV response. This is critical when
|
|
|
|
|
- the slicer's FTP bounce-attack protection overrides the IP in the PASV
|
|
|
|
|
- response: the slicer connects to <control_IP>:<port>, and if iptables
|
|
|
|
|
- REDIRECT maps that port to the local machine, the data proxy must be
|
|
|
|
|
- listening on the *same* port number.
|
|
|
|
|
-
|
|
|
|
|
- Falls back to a random port if the original is unavailable.
|
|
|
|
|
-
|
|
|
|
|
- Uses TLS or cleartext based on the session's PROT level:
|
|
|
|
|
- - PROT P: TLS on both slicer and printer data connections
|
|
|
|
|
- - PROT C: cleartext on both sides (common for A1/H2D printers)
|
|
|
|
|
-
|
|
|
|
|
- Returns the local port number, or None if binding failed.
|
|
|
|
|
- """
|
|
|
|
|
- use_tls = session_state.get("prot") == "P"
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
|
|
|
|
|
- printer_ip,
|
|
|
|
|
- printer_port,
|
|
|
|
|
- "TLS" if use_tls else "cleartext",
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # Get control channel TLS session for data channel reuse
|
|
|
|
|
- tls_session = session_state.get("tls_session") if use_tls else None
|
|
|
|
|
-
|
|
|
|
|
- # Try the printer's original port first — this ensures the port
|
|
|
|
|
- # matches even when bounce protection or iptables REDIRECT is in play.
|
|
|
|
|
- try:
|
|
|
|
|
- await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
|
|
|
|
|
- logger.info("FTP data proxy: using printer's port %s", printer_port)
|
|
|
|
|
- return printer_port
|
|
|
|
|
- except OSError as e:
|
|
|
|
|
- logger.debug(
|
|
|
|
|
- "FTP data proxy: printer port %s unavailable (%s), trying random",
|
|
|
|
|
- printer_port,
|
|
|
|
|
- e,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- for _attempt in range(10):
|
|
|
|
|
- port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
|
|
|
|
|
- try:
|
|
|
|
|
- await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
|
|
|
|
|
- logger.info("FTP data proxy: using random port %s", port)
|
|
|
|
|
- return port
|
|
|
|
|
- except OSError:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- logger.error("Failed to bind FTP data proxy port after 10 attempts")
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- async def _start_data_proxy_server(
|
|
|
|
|
- self,
|
|
|
|
|
- port: int,
|
|
|
|
|
- printer_ip: str,
|
|
|
|
|
- printer_port: int,
|
|
|
|
|
- use_tls: bool,
|
|
|
|
|
- tls_session: ssl.SSLSession | None = None,
|
|
|
|
|
- ) -> None:
|
|
|
|
|
- """Start a one-shot server for one FTP data connection.
|
|
|
|
|
-
|
|
|
|
|
- When the slicer connects, immediately connects to the printer's data
|
|
|
|
|
- port and buffers any slicer data until the printer connection is ready.
|
|
|
|
|
- This handles zero-byte uploads (verify_job) where the slicer closes
|
|
|
|
|
- the data channel before a naive proxy would finish its TLS handshake.
|
|
|
|
|
-
|
|
|
|
|
- The slicer-side listener is ALWAYS cleartext. Even when the slicer
|
|
|
|
|
- sends PROT P on the control channel, Bambu Studio does not perform
|
|
|
|
|
- a TLS handshake on the data connection — it relies on the implicit
|
|
|
|
|
- FTPS control channel for authentication and sends data unencrypted.
|
|
|
|
|
-
|
|
|
|
|
- The printer-side outbound connection follows the PROT level:
|
|
|
|
|
- - PROT P (use_tls=True): TLS to the printer's data port
|
|
|
|
|
- - PROT C (use_tls=False): cleartext to the printer's data port
|
|
|
|
|
-
|
|
|
|
|
- This mirrors the control channel's TLS-termination architecture.
|
|
|
|
|
-
|
|
|
|
|
- Raises OSError if the port is already in use.
|
|
|
|
|
- """
|
|
|
|
|
- connected = asyncio.Event()
|
|
|
|
|
- server_holder: list[asyncio.Server] = []
|
|
|
|
|
-
|
|
|
|
|
- # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
|
|
|
|
|
- # the data channel even after sending PROT P.
|
|
|
|
|
- # Printer side: TLS if PROT P, cleartext if PROT C.
|
|
|
|
|
- # For TLS data connections, wrap the SSL context to reuse the
|
|
|
|
|
- # control channel's TLS session if available. vsFTPd (X1C) requires
|
|
|
|
|
- # require_ssl_reuse — without this, data connections are rejected
|
|
|
|
|
- # with "522 SSL connection failed: session reuse required".
|
|
|
|
|
- if use_tls and tls_session:
|
|
|
|
|
- client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
|
|
|
|
|
- logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
|
|
|
|
|
- else:
|
|
|
|
|
- client_ssl = self._client_ssl_context if use_tls else None
|
|
|
|
|
-
|
|
|
|
|
- # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
|
|
|
|
|
- # the data channel even after PROT P (confirmed for both H2D and X1C).
|
|
|
|
|
- printer_mode = "TLS" if use_tls else "cleartext"
|
|
|
|
|
-
|
|
|
|
|
- async def handle_data(
|
|
|
|
|
- client_reader: asyncio.StreamReader,
|
|
|
|
|
- client_writer: asyncio.StreamWriter,
|
|
|
|
|
- ) -> None:
|
|
|
|
|
- """Handle one FTP data connection, then close the server."""
|
|
|
|
|
- peername = client_writer.get_extra_info("peername")
|
|
|
|
|
- data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
|
|
|
|
|
- port,
|
|
|
|
|
- printer_mode,
|
|
|
|
|
- data_client,
|
|
|
|
|
- printer_ip,
|
|
|
|
|
- printer_port,
|
|
|
|
|
- )
|
|
|
|
|
- connected.set()
|
|
|
|
|
- # One-shot: close server after accepting first connection
|
|
|
|
|
- if server_holder:
|
|
|
|
|
- server_holder[0].close()
|
|
|
|
|
-
|
|
|
|
|
- printer_writer = None
|
|
|
|
|
- try:
|
|
|
|
|
- # Buffer any slicer data while connecting to printer.
|
|
|
|
|
- # This handles the race where the slicer sends data (or closes
|
|
|
|
|
- # for zero-byte files) before the TLS handshake completes.
|
|
|
|
|
- slicer_buffer = bytearray()
|
|
|
|
|
- slicer_eof = False
|
|
|
|
|
-
|
|
|
|
|
- async def buffer_slicer():
|
|
|
|
|
- nonlocal slicer_eof
|
|
|
|
|
- while True:
|
|
|
|
|
- chunk = await client_reader.read(65536)
|
|
|
|
|
- if not chunk:
|
|
|
|
|
- slicer_eof = True
|
|
|
|
|
- return
|
|
|
|
|
- slicer_buffer.extend(chunk)
|
|
|
|
|
-
|
|
|
|
|
- buffer_task = asyncio.create_task(buffer_slicer())
|
|
|
|
|
-
|
|
|
|
|
- # Connect to printer's data port
|
|
|
|
|
- printer_reader, printer_writer = await asyncio.wait_for(
|
|
|
|
|
- asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
|
|
|
|
|
- timeout=10.0,
|
|
|
|
|
- )
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
|
|
|
|
|
- port,
|
|
|
|
|
- printer_mode,
|
|
|
|
|
- printer_ip,
|
|
|
|
|
- printer_port,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # Stop buffering
|
|
|
|
|
- buffer_task.cancel()
|
|
|
|
|
- try:
|
|
|
|
|
- await buffer_task
|
|
|
|
|
- except asyncio.CancelledError:
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
- # Flush buffered slicer data to printer
|
|
|
|
|
- logger.info(
|
|
|
|
|
- "FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
|
|
|
|
|
- port,
|
|
|
|
|
- len(slicer_buffer),
|
|
|
|
|
- slicer_eof,
|
|
|
|
|
- )
|
|
|
|
|
- if slicer_buffer:
|
|
|
|
|
- printer_writer.write(bytes(slicer_buffer))
|
|
|
|
|
- await printer_writer.drain()
|
|
|
|
|
-
|
|
|
|
|
- # Forward remaining slicer data to printer, then close the
|
|
|
|
|
- # printer side to signal upload complete.
|
|
|
|
|
- #
|
|
|
|
|
- # Bambu Studio does NOT close the FTP data channel after sending
|
|
|
|
|
- # STOR data — it keeps the connection open and waits for the
|
|
|
|
|
- # printer to close its side + send 226 on the control channel.
|
|
|
|
|
- # A naive bidirectional proxy deadlocks here because the proxy
|
|
|
|
|
- # waits for the slicer EOF that never comes.
|
|
|
|
|
- #
|
|
|
|
|
- # Fix: read slicer data with an idle timeout. Once data has been
|
|
|
|
|
- # received and the slicer goes quiet, close the printer side so
|
|
|
|
|
- # the printer can send 226. For RETR (download), the printer
|
|
|
|
|
- # sends data and closes — the slicer reads until EOF — so this
|
|
|
|
|
- # unidirectional approach works for both directions.
|
|
|
|
|
- total_c2p = len(slicer_buffer)
|
|
|
|
|
- if not slicer_eof:
|
|
|
|
|
- # Read remaining slicer data with idle detection.
|
|
|
|
|
- # Must be short — Bambu Studio expects 226 almost instantly
|
|
|
|
|
- # after sending data. Too long and the slicer times out.
|
|
|
|
|
- idle_timeout = 0.3
|
|
|
|
|
- while True:
|
|
|
|
|
- try:
|
|
|
|
|
- chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
|
|
|
|
|
- except TimeoutError:
|
|
|
|
|
- if total_c2p > 0:
|
|
|
|
|
- # Slicer sent data then went idle — upload done
|
|
|
|
|
- logger.debug(
|
|
|
|
|
- "FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
|
|
|
|
|
- port,
|
|
|
|
|
- total_c2p,
|
|
|
|
|
- )
|
|
|
|
|
- break
|
|
|
|
|
- continue # No data yet, keep waiting
|
|
|
|
|
- if not chunk:
|
|
|
|
|
- break # Slicer closed
|
|
|
|
|
- printer_writer.write(chunk)
|
|
|
|
|
- await printer_writer.drain()
|
|
|
|
|
- total_c2p += len(chunk)
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
|
|
|
|
|
-
|
|
|
|
|
- # Close printer side to signal upload complete.
|
|
|
|
|
- # For TLS, close() sends close_notify which the printer treats
|
|
|
|
|
- # as end-of-data. The printer then sends 226 on the control
|
|
|
|
|
- # channel. For RETR, this is a no-op since the printer closes
|
|
|
|
|
- # first and we'd have exited the loop above via EOF.
|
|
|
|
|
- try:
|
|
|
|
|
- printer_writer.close()
|
|
|
|
|
- await printer_writer.wait_closed()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
- # Wait for 226 response to propagate through the FTP control
|
|
|
|
|
- # channel before closing the slicer's data channel.
|
|
|
|
|
- #
|
|
|
|
|
- # Without this delay, the data channel FIN arrives at the
|
|
|
|
|
- # slicer before the 226 response on the control channel.
|
|
|
|
|
- # BambuStudio reacts to the data channel FIN within <1ms
|
|
|
|
|
- # by sending QUIT + closing the control channel — before
|
|
|
|
|
- # 226 arrives (~2-3ms network RTT). This causes verify_job
|
|
|
|
|
- # to be treated as failed and shows the login modal.
|
|
|
|
|
- #
|
|
|
|
|
- # In a direct connection, the printer sends 226 AND closes
|
|
|
|
|
- # the data channel simultaneously, so the slicer gets both
|
|
|
|
|
- # at once. The delay here emulates that timing.
|
|
|
|
|
- if total_c2p > 0:
|
|
|
|
|
- await asyncio.sleep(0.5)
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.error("FTP data proxy port %s: error: %s", port, e)
|
|
|
|
|
- finally:
|
|
|
|
|
- for w in [client_writer, printer_writer]:
|
|
|
|
|
- if w:
|
|
|
|
|
- try:
|
|
|
|
|
- w.close()
|
|
|
|
|
- await w.wait_closed()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort data connection cleanup
|
|
|
|
|
- logger.info("FTP data proxy port %s: connection closed", port)
|
|
|
|
|
-
|
|
|
|
|
- server = await asyncio.start_server(
|
|
|
|
|
- handle_data,
|
|
|
|
|
- "0.0.0.0", # nosec B104
|
|
|
|
|
- port,
|
|
|
|
|
- # No TLS on slicer side — Bambu Studio doesn't do TLS on data
|
|
|
|
|
- # channel even after PROT P (confirmed by connection hang test).
|
|
|
|
|
- )
|
|
|
|
|
- server_holder.append(server)
|
|
|
|
|
- self._data_servers.append(server)
|
|
|
|
|
-
|
|
|
|
|
- # Auto-close after 60s if no connection arrives
|
|
|
|
|
- async def auto_close() -> None:
|
|
|
|
|
- try:
|
|
|
|
|
- await asyncio.wait_for(connected.wait(), timeout=60.0)
|
|
|
|
|
- except TimeoutError:
|
|
|
|
|
- logger.debug("FTP data proxy on port %s timed out, closing", port)
|
|
|
|
|
- try:
|
|
|
|
|
- server.close()
|
|
|
|
|
- await server.wait_closed()
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass # Best-effort timeout cleanup
|
|
|
|
|
- finally:
|
|
|
|
|
- if server in self._data_servers:
|
|
|
|
|
- self._data_servers.remove(server)
|
|
|
|
|
-
|
|
|
|
|
- # Track the auto_close task so stop() can cancel it; otherwise the
|
|
|
|
|
- # 60 s timeout would hold a server reference past proxy teardown.
|
|
|
|
|
- ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
|
|
|
|
|
- self._auto_close_tasks.append(ac_task)
|
|
|
|
|
- ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
|
|
|
|
|
-
|
|
|
|
|
- logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
class SlicerProxyManager:
|
|
class SlicerProxyManager:
|
|
|
"""Manages FTP and MQTT TLS proxies for a single printer target."""
|
|
"""Manages FTP and MQTT TLS proxies for a single printer target."""
|
|
|
|
|
|