Browse Source

[Fix] Virtual Printer proxy: transparent TCP for X1C/X1 compatibility (#757)

  The closed-source bambu_networking DLL validates TLS connection parameters
  and rejects connections where the certificate doesn't match the printer's
  real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's
  own certificate, causing X1C/X1 prints to silently fail after verify_job.

  Switch to transparent TCP proxying for FTP, FileTransfer, Camera, and FTP
  data — only MQTT remains TLS-terminated (required for IP rewriting). The
  slicer now gets end-to-end TLS directly with the printer's real certificate.

  Changes:
  - SlicerProxyManager uses TCPProxy for FTP (990), FileTransfer (6000),
    Camera (322), and pre-listens on FTP data ports (50000-50100)
  - Only MQTT (8883) uses TLSProxy for IP rewriting
  - Remove debug logging from MQTT and FTP proxy code
  - Fix install.sh missing AmbientCapabilities=CAP_NET_BIND_SERVICE
  - Update module docstring, migration docs, README proxy description
  - Add tests verifying transparent proxy architecture
maziggy 2 months ago
parent
commit
332a7c6ac8

+ 3 - 1
CHANGELOG.md

@@ -20,8 +20,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.
 - **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH.
 - **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
-- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding TLS proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
+- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
+- **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.
 - **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.
+- **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.
 
 ### Added
 - **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.

+ 2 - 2
README.md

@@ -39,11 +39,11 @@
 
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
-- 🔒 **TLS-encrypted control channels** — MQTT and FTP control fully encrypted
+- 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
 - 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
-- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
+- ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
 
 Perfect for remote print farms, traveling makers, or accessing your home printer from work.
 

+ 243 - 74
backend/app/services/virtual_printer/tcp_proxy.py

@@ -1,15 +1,17 @@
-"""TLS proxy for slicer-to-printer communication.
+"""Proxy for slicer-to-printer communication.
 
-This module provides a TLS terminating proxy that forwards data between
-a slicer and a real Bambu printer, enabling remote printing over
-any network connection.
+This module provides both transparent TCP proxying and TLS-terminating
+proxying for forwarding data between a slicer and a real Bambu printer,
+enabling remote printing over any network connection.
 
-Unlike a transparent TCP proxy, this terminates TLS on both ends:
-- Slicer connects to Bambuddy using Bambuddy's certificate
-- Bambuddy connects to printer using printer's certificate
-- Data is decrypted, forwarded, and re-encrypted
+Most protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —
+raw bytes are forwarded without decryption, preserving end-to-end TLS
+between slicer and printer. Only MQTT is TLS-terminated so Bambuddy can
+rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
 """
 
+# ruff: noqa: N801
+
 import asyncio
 import logging
 import random
@@ -22,6 +24,44 @@ from pathlib import Path
 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:
     """Detect if iptables redirects a port to another port.
 
@@ -515,7 +555,6 @@ class TLSProxy:
                 # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
                 # to prevent the slicer from bypassing the proxy.
                 if do_rewrite:
-                    original_len = len(data)
                     extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
                     data, rewrite_buffer = self._rewrite_mqtt_ip(
                         data,
@@ -524,13 +563,20 @@ class TLSProxy:
                         rewrite_buffer,
                         extra_replacements=extra,
                     )
-                    if not rewrite_logged and data and len(data) != original_len:
-                        logger.info(
-                            "%s proxy IP rewrite active: %s → %s",
-                            self.name,
-                            self._rewrite_old.decode(),
-                            self._rewrite_new.decode(),
-                        )
+                    if not rewrite_logged and data:
+                        if self._rewrite_old in data:
+                            logger.warning(
+                                "%s proxy IP rewrite FAILED — %s still present after rewrite!",
+                                self.name,
+                                self._rewrite_old.decode(),
+                            )
+                        else:
+                            logger.info(
+                                "%s proxy IP rewrite active: %s → %s",
+                                self.name,
+                                self._rewrite_old.decode(),
+                                self._rewrite_new.decode(),
+                            )
                         rewrite_logged = True
                     if not data:
                         continue  # All data buffered, waiting for more
@@ -835,11 +881,21 @@ class FTPTLSProxy(TLSProxy):
             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] = {"prot": "C"}
+        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
@@ -895,7 +951,7 @@ class FTPTLSProxy(TLSProxy):
         reader: asyncio.StreamReader,
         writer: asyncio.StreamWriter,
         direction: str,
-        session_state: dict[str, str],
+        session_state: dict[str, str | ssl.SSLSession],
     ) -> None:
         """Forward FTP client commands, replacing EPSV with PASV.
 
@@ -927,13 +983,8 @@ class FTPTLSProxy(TLSProxy):
 
                     cmd_upper = line.strip().upper()
 
-                    # Replace EPSV with PASV so response includes an IP
-                    if cmd_upper == b"EPSV":
-                        line = b"PASV"
-                        logger.info("FTP command rewrite: EPSV → PASV")
-
                     # Track PROT level for data channel encryption
-                    elif cmd_upper == b"PROT P":
+                    if cmd_upper == b"PROT P":
                         session_state["prot"] = "P"
                         logger.info("FTP data protection: PROT P (TLS)")
                     elif cmd_upper == b"PROT C":
@@ -972,7 +1023,7 @@ class FTPTLSProxy(TLSProxy):
         writer: asyncio.StreamWriter,
         direction: str,
         local_ip: str,
-        session_state: dict[str, str],
+        session_state: dict[str, str | ssl.SSLSession],
     ) -> None:
         """Forward FTP control channel responses, rewriting PASV/EPSV.
 
@@ -1027,7 +1078,9 @@ class FTPTLSProxy(TLSProxy):
 
         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]) -> 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")
@@ -1076,7 +1129,9 @@ class FTPTLSProxy(TLSProxy):
 
         return line
 
-    async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
+    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
@@ -1102,10 +1157,13 @@ class FTPTLSProxy(TLSProxy):
             "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)
+            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:
@@ -1118,7 +1176,7 @@ class FTPTLSProxy(TLSProxy):
         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)
+                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:
@@ -1127,7 +1185,14 @@ class FTPTLSProxy(TLSProxy):
         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) -> 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
@@ -1154,7 +1219,18 @@ class FTPTLSProxy(TLSProxy):
         # 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.
-        client_ssl = self._client_ssl_context if use_tls else None
+        # 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(
@@ -1217,27 +1293,84 @@ class FTPTLSProxy(TLSProxy):
                     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()
 
-                if slicer_eof:
-                    # Slicer already closed (zero-byte upload like verify_job).
-                    # Close the printer write side to signal upload complete.
-                    if printer_writer.can_write_eof():
-                        printer_writer.write_eof()
-                else:
-                    # Continue bidirectional forwarding
-                    c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
-                    p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
-
-                    done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
-                    for task in pending:
-                        task.cancel()
+                # 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:
-                            await task
-                        except asyncio.CancelledError:
-                            pass  # Expected when other data direction closes
+                            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:
@@ -1255,8 +1388,7 @@ class FTPTLSProxy(TLSProxy):
             "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. The proxy terminates TLS only on
-            # the printer side (inside handle_data).
+            # channel even after PROT P (confirmed by connection hang test).
         )
         server_holder.append(server)
         self._data_servers.append(server)
@@ -1325,21 +1457,33 @@ class SlicerProxyManager:
         self.bind_address = bind_address
         self.bind_identity = bind_identity
 
-        self._ftp_proxy: TLSProxy | None = None
+        self._ftp_proxy: TCPProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
-        self._file_transfer_proxy: TLSProxy | None = None
-        self._rtsp_proxy: TLSProxy | None = None
+        self._file_transfer_proxy: TCPProxy | None = None
+        self._rtsp_proxy: TCPProxy | None = None
         self._bind_proxies: list[TCPProxy] = []
         self._bind_server = None
         self._tasks: list[asyncio.Task] = []
 
+    # FTP passive data port range — Bambu printers typically use ports in
+    # this range for EPSV/PASV data connections. We pre-listen on all of
+    # them so EPSV works transparently without decrypting FTP control.
+    FTP_DATA_PORT_MIN = 50000
+    FTP_DATA_PORT_MAX = 50100
+
     async def start(self) -> None:
-        """Start FTP and MQTT TLS proxies."""
-        logger.info("Starting slicer TLS proxy to %s", self.target_host)
+        """Start proxy services.
+
+        Uses transparent TCP proxying for most protocols (FTP, FileTransfer,
+        Camera) — raw bytes are forwarded without TLS termination, so the
+        slicer gets the printer's real TLS certificate end-to-end.
 
-        # Detect iptables port redirect (e.g. if an external redirect exists).
-        # If active, connections get intercepted by iptables PREROUTING
-        # and sent to the redirect target — our socket never sees them.
+        Only MQTT is TLS-terminated because we must decrypt the payload to
+        rewrite the printer's real IP with the proxy's bind IP.
+        """
+        logger.info("Starting slicer proxy to %s (transparent mode)", self.target_host)
+
+        # Detect iptables port redirect for FTP
         ftp_listen_port = self.LOCAL_FTP_PORT
         redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
         if redirect_target:
@@ -1351,19 +1495,35 @@ class SlicerProxyManager:
             )
             ftp_listen_port = redirect_target
 
-        # Create FTP proxy with PASV/EPSV awareness for data connections
-        self._ftp_proxy = FTPTLSProxy(
+        # FTP control — raw TCP pass-through (end-to-end TLS with printer)
+        self._ftp_proxy = TCPProxy(
             name="FTP",
             listen_port=ftp_listen_port,
             target_host=self.target_host,
             target_port=self.PRINTER_FTP_PORT,
-            server_cert_path=self.cert_path,
-            server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
             bind_address=self.bind_address,
         )
 
+        # FTP data ports — pre-listen on the entire passive port range.
+        # Since FTP control is encrypted end-to-end, we can't read EPSV
+        # responses to know which port the printer chose. Instead, we
+        # listen on every port in the range and forward to the same port
+        # on the printer. The slicer connects to bind_ip:PORT (from EPSV)
+        # and we transparently relay to printer_ip:PORT.
+        self._ftp_data_proxies: list[TCPProxy] = []
+        for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):
+            dp = TCPProxy(
+                name=f"FTP-Data-{port}",
+                listen_port=port,
+                target_host=self.target_host,
+                target_port=port,
+                bind_address=self.bind_address,
+            )
+            self._ftp_data_proxies.append(dp)
+
+        # MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)
         self._mqtt_proxy = TLSProxy(
             name="MQTT",
             listen_port=self.LOCAL_MQTT_PORT,
@@ -1377,30 +1537,23 @@ class SlicerProxyManager:
             rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
         )
 
-        # File transfer proxy — port 6000 (TLS)
-        # BambuStudio connects here for verify_job and actual file uploads.
-        self._file_transfer_proxy = TLSProxy(
+        # File transfer — raw TCP pass-through (port 6000)
+        self._file_transfer_proxy = TCPProxy(
             name="FileTransfer",
             listen_port=self.PRINTER_FILE_TRANSFER_PORT,
             target_host=self.target_host,
             target_port=self.PRINTER_FILE_TRANSFER_PORT,
-            server_cert_path=self.cert_path,
-            server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
             bind_address=self.bind_address,
         )
 
-        # RTSP camera proxy — port 322 (TLS)
-        # X1/H2/P2 series use RTSP on port 322 for camera streaming.
-        # A1/P1 series use port 6000 (already proxied via file transfer proxy).
-        self._rtsp_proxy = TLSProxy(
+        # RTSP camera — raw TCP pass-through (port 322)
+        self._rtsp_proxy = TCPProxy(
             name="RTSP",
             listen_port=self.PRINTER_RTSP_PORT,
             target_host=self.target_host,
             target_port=self.PRINTER_RTSP_PORT,
-            server_cert_path=self.cert_path,
-            server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
             bind_address=self.bind_address,
@@ -1450,7 +1603,7 @@ class SlicerProxyManager:
                 self._bind_proxies.append(proxy)
 
         # Start as background tasks
-        async def run_with_logging(proxy: TLSProxy) -> None:
+        async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
             try:
                 await proxy.start()
             except Exception as e:
@@ -1488,8 +1641,20 @@ class SlicerProxyManager:
                     name=f"slicer_proxy_bind_{bp.listen_port}",
                 )
             )
+        # FTP data port proxies (50000-50100)
+        for dp in self._ftp_data_proxies:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(dp),
+                    name=f"slicer_proxy_ftp_data_{dp.listen_port}",
+                )
+            )
 
-        logger.info("Slicer TLS proxy started for %s", self.target_host)
+        logger.info(
+            "Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
+            self.target_host,
+            len(self._ftp_data_proxies),
+        )
 
         # Wait for tasks to complete (they run until cancelled)
         # This keeps the start() coroutine alive so the parent task doesn't complete
@@ -1527,6 +1692,10 @@ class SlicerProxyManager:
             await bp.stop()
         self._bind_proxies = []
 
+        for dp in self._ftp_data_proxies:
+            await dp.stop()
+        self._ftp_data_proxies = []
+
         # Cancel tasks
         for task in self._tasks:
             task.cancel()

+ 86 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1093,6 +1093,9 @@ class TestSlicerProxyManager:
         assert proxy_manager.PRINTER_RTSP_PORT == 322
         # Bind ports: both 3000 and 3002 for slicer compatibility
         assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
+        # FTP data port range for transparent EPSV proxying
+        assert proxy_manager.FTP_DATA_PORT_MIN == 50000
+        assert proxy_manager.FTP_DATA_PORT_MAX == 50100
 
     def test_proxy_manager_stores_target_host(self, proxy_manager):
         """Verify proxy manager stores target host."""
@@ -1106,6 +1109,89 @@ class TestSlicerProxyManager:
         assert status["ftp_connections"] == 0
         assert status["mqtt_connections"] == 0
 
+    @pytest.mark.asyncio
+    async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
+        """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
+
+        The transparent proxy architecture preserves end-to-end TLS between
+        slicer and printer for all protocols except MQTT, which must be
+        TLS-terminated to rewrite the printer's IP in MQTT payloads.
+        """
+        from unittest.mock import AsyncMock, patch
+
+        from backend.app.services.virtual_printer.tcp_proxy import (
+            SlicerProxyManager,
+            TCPProxy,
+            TLSProxy,
+        )
+
+        cert_path = tmp_path / "cert.pem"
+        key_path = tmp_path / "key.pem"
+        cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+        key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+        mgr = SlicerProxyManager(
+            target_host="192.168.1.100",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="10.0.0.1",
+        )
+
+        # Mock asyncio.create_task and asyncio.gather to prevent actual server start
+        with (
+            patch("asyncio.create_task") as mock_create_task,
+            patch("asyncio.gather", new_callable=AsyncMock),
+            patch.object(SlicerProxyManager, "_log_activity"),
+        ):
+            mock_create_task.return_value = MagicMock()
+            # start() will create proxies then try to gather tasks — we just
+            # need to verify the proxy types after creation.
+            # Trigger start but let gather return immediately.
+            await mgr.start()
+
+        # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
+        assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
+        assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
+        assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
+
+        # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
+        assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
+
+        # FTP data ports should be pre-created as TCPProxy instances
+        assert len(mgr._ftp_data_proxies) == 101  # 50000-50100 inclusive
+        for dp in mgr._ftp_data_proxies:
+            assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
+
+        # Verify FTP data proxies target the same port on the printer
+        first_dp = mgr._ftp_data_proxies[0]
+        assert first_dp.listen_port == 50000
+        assert first_dp.target_port == 50000
+        assert first_dp.target_host == "192.168.1.100"
+
+        last_dp = mgr._ftp_data_proxies[-1]
+        assert last_dp.listen_port == 50100
+        assert last_dp.target_port == 50100
+
+    def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
+        """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
+        from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
+
+        cert_path = tmp_path / "cert.pem"
+        key_path = tmp_path / "key.pem"
+        cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+        key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+        mgr = SlicerProxyManager(
+            target_host="192.168.1.100",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="10.0.0.1",
+        )
+
+        # Before start, proxies are None — verify constructor stores rewrite config
+        assert mgr.bind_address == "10.0.0.1"
+        assert mgr.target_host == "192.168.1.100"
+
 
 class TestSSDPProxy:
     """Tests for SSDPProxy (cross-network SSDP relay)."""

+ 2 - 2
docs/migration-vp-ftp-port.md

@@ -22,8 +22,8 @@ Proxy mode now requires two additional ports:
 
 | Port | Protocol | Purpose |
 |------|----------|---------|
-| 6000 | TCP/TLS | File transfer tunnel (verify_job + print uploads) |
-| 322 | TCP/TLS | RTSP camera streaming (X1/H2/P2 series) |
+| 6000 | TCP | File transfer tunnel (transparent proxy, end-to-end TLS) |
+| 322 | TCP | RTSP camera streaming (transparent proxy, end-to-end TLS) |
 
 These ports are proxied automatically — no iptables rules needed. If you have
 a firewall, ensure these ports are open between the slicer and Bambuddy.

+ 3 - 0
install/install.sh

@@ -532,6 +532,9 @@ RestartSec=5
 StandardOutput=journal
 StandardError=journal
 
+# Allow binding to privileged ports (322, 990) for Virtual Printer proxy mode
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+
 # Security hardening
 NoNewPrivileges=true
 PrivateTmp=true