Browse Source

Add proxy mode for virtual printer (cross-LAN slicer support)

Enable Bambu Studio on a remote network to print through BamBuddy
acting as a TLS-terminating proxy for both MQTT and FTP connections.

- Add TLSProxy base class and FTPTLSProxy with PASV response rewriting,
  EPSV→PASV translation, PROT P/C tracking, and one-shot data proxies
- Add SlicerProxyManager to coordinate per-slicer MQTT + FTP proxy pairs
- Support additional SAN IPs in certificate generation for proxy mode
- Broadcast SSDP on LAN B so slicers discover the proxy as a printer
- Narrow FTP passive port range to 50000-50100 with retry logic
- Expose proxy ports (8883, 9990, 50000-50100) in Dockerfile
- Document passive port range in docker-compose.yml
maziggy 3 tháng trước cách đây
mục cha
commit
70622e6e53

+ 3 - 0
Dockerfile

@@ -47,6 +47,9 @@ ENV LOG_DIR=/app/logs
 ENV PORT=8000
 ENV PORT=8000
 
 
 EXPOSE 8000
 EXPOSE 8000
+EXPOSE 8883
+EXPOSE 9990
+EXPOSE 50000-50100
 
 
 # Health check (uses PORT env var via shell)
 # Health check (uses PORT env var via shell)
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \

+ 28 - 10
backend/app/services/virtual_printer/certificate.py

@@ -193,13 +193,39 @@ class CertificateService:
 
 
         return ca_key, ca_cert
         return ca_key, ca_cert
 
 
-    def generate_certificates(self) -> tuple[Path, Path]:
+    def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]:
+        """Build Subject Alternative Name entries for the printer certificate."""
+        entries: list[x509.GeneralName] = [
+            x509.DNSName("localhost"),
+            x509.DNSName("bambuddy"),
+            x509.DNSName(self.serial),
+            x509.IPAddress(IPv4Address(local_ip)),
+            x509.IPAddress(IPv4Address("127.0.0.1")),
+        ]
+        seen_ips = {local_ip, "127.0.0.1"}
+        if additional_ips:
+            for ip in additional_ips:
+                if ip and ip not in seen_ips:
+                    try:
+                        entries.append(x509.IPAddress(IPv4Address(ip)))
+                        seen_ips.add(ip)
+                        logger.info("Added additional SAN IP: %s", ip)
+                    except ValueError:
+                        logger.warning("Skipping invalid additional SAN IP: %s", ip)
+        return entries
+
+    def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]:
         """Generate printer certificate (reusing existing CA if available).
         """Generate printer certificate (reusing existing CA if available).
 
 
         Creates a certificate chain mimicking real Bambu printers:
         Creates a certificate chain mimicking real Bambu printers:
         - CA certificate (reused if exists and valid, otherwise generated)
         - CA certificate (reused if exists and valid, otherwise generated)
         - Printer certificate (CN=serial, signed by CA)
         - Printer certificate (CN=serial, signed by CA)
 
 
+        Args:
+            additional_ips: Extra IP addresses to include in certificate SAN.
+                Used in proxy mode to include the remote interface IP so the
+                slicer's TLS handshake succeeds when connecting to the proxy.
+
         Returns:
         Returns:
             Tuple of (cert_path, key_path)
             Tuple of (cert_path, key_path)
         """
         """
@@ -245,15 +271,7 @@ class CertificateService:
                 critical=True,
                 critical=True,
             )
             )
             .add_extension(
             .add_extension(
-                x509.SubjectAlternativeName(
-                    [
-                        x509.DNSName("localhost"),
-                        x509.DNSName("bambuddy"),
-                        x509.DNSName(self.serial),
-                        x509.IPAddress(IPv4Address(local_ip)),
-                        x509.IPAddress(IPv4Address("127.0.0.1")),
-                    ]
-                ),
+                x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)),
                 critical=False,
                 critical=False,
             )
             )
             .add_extension(
             .add_extension(

+ 60 - 35
backend/app/services/virtual_printer/ftp_server.py

@@ -9,6 +9,7 @@ immediately upon connection, before any FTP commands are exchanged.
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import os
 import random
 import random
 import ssl
 import ssl
 from collections.abc import Callable
 from collections.abc import Callable
@@ -31,6 +32,8 @@ class FTPSession:
         access_code: str,
         access_code: str,
         ssl_context: ssl.SSLContext,
         ssl_context: ssl.SSLContext,
         on_file_received: Callable[[Path, str], None] | None,
         on_file_received: Callable[[Path, str], None] | None,
+        passive_port_range: tuple[int, int] = (50000, 50100),
+        pasv_address: str = "",
     ):
     ):
         self.reader = reader
         self.reader = reader
         self.writer = writer
         self.writer = writer
@@ -38,6 +41,8 @@ class FTPSession:
         self.access_code = access_code
         self.access_code = access_code
         self.ssl_context = ssl_context
         self.ssl_context = ssl_context
         self.on_file_received = on_file_received
         self.on_file_received = on_file_received
+        self.passive_port_range = passive_port_range
+        self.pasv_address = pasv_address
 
 
         self.authenticated = False
         self.authenticated = False
         self.username: str | None = None
         self.username: str | None = None
@@ -159,6 +164,7 @@ class FTPSession:
         features = [
         features = [
             "211-Features:",
             "211-Features:",
             " PASV",
             " PASV",
+            " EPSV",
             " UTF8",
             " UTF8",
             " SIZE",
             " SIZE",
             "211 End",
             "211 End",
@@ -196,6 +202,28 @@ class FTPSession:
         else:
         else:
             await self.send(504, "Type not supported")
             await self.send(504, "Type not supported")
 
 
+    async def _bind_passive_port(self) -> bool:
+        """Try to bind a passive data port with retries.
+
+        Returns True if a port was successfully bound, False otherwise.
+        Sets self.data_server and self.data_port on success.
+        """
+        port_min, port_max = self.passive_port_range
+        for attempt in range(10):
+            port = random.randint(port_min, port_max)
+            try:
+                self.data_server = await asyncio.start_server(
+                    self._handle_data_connection,
+                    "0.0.0.0",  # nosec B104
+                    port,
+                    ssl=self.ssl_context,
+                )
+                self.data_port = port
+                return True
+            except OSError:
+                logger.debug("FTP passive port %s in use, retrying (%s/10)", port, attempt + 1)
+        return False
+
     async def cmd_EPSV(self, arg: str) -> None:
     async def cmd_EPSV(self, arg: str) -> None:
         """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
         """Handle EPSV command - Extended Passive Mode (IPv6 compatible)."""
         if not self.authenticated:
         if not self.authenticated:
@@ -210,24 +238,12 @@ class FTPSession:
         self._data_reader = None
         self._data_reader = None
         self._data_writer = None
         self._data_writer = None
 
 
-        # Find a free port for passive data connection
-        self.data_port = random.randint(50000, 60000)
-
-        try:
-            # Create data server with TLS - use same context for session reuse
-            self.data_server = await asyncio.start_server(
-                self._handle_data_connection,
-                "0.0.0.0",  # nosec B104
-                self.data_port,
-                ssl=self.ssl_context,
-            )
-
+        if await self._bind_passive_port():
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             # EPSV response format: 229 Entering Extended Passive Mode (|||port|)
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
             await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)")
             logger.info("FTP EPSV listening on port %s", self.data_port)
             logger.info("FTP EPSV listening on port %s", self.data_port)
-
-        except Exception as e:
-            logger.error("Failed to create EPSV data connection: %s", e)
+        else:
+            logger.error("Failed to bind any passive port for EPSV")
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def cmd_PASV(self, arg: str) -> None:
     async def cmd_PASV(self, arg: str) -> None:
@@ -244,22 +260,18 @@ class FTPSession:
         self._data_reader = None
         self._data_reader = None
         self._data_writer = None
         self._data_writer = None
 
 
-        # Find a free port for passive data connection
-        self.data_port = random.randint(50000, 60000)
-
-        try:
-            # Create data server with TLS
-            self.data_server = await asyncio.start_server(
-                self._handle_data_connection,
-                "0.0.0.0",  # nosec B104
-                self.data_port,
-                ssl=self.ssl_context,
-            )
-
-            # Get server's IP for response
-            # Use the IP the client connected to
-            sockname = self.writer.get_extra_info("sockname")
-            ip = sockname[0] if sockname else "127.0.0.1"
+        if await self._bind_passive_port():
+            # Determine the IP to advertise in PASV response
+            if self.pasv_address:
+                # Explicit override (e.g., for Docker bridge mode behind NAT)
+                ip = self.pasv_address
+            else:
+                # Use the local IP of the control connection
+                sockname = self.writer.get_extra_info("sockname")
+                ip = sockname[0] if sockname else "127.0.0.1"
+                # 0.0.0.0 is not routable — fall back to control connection IP
+                if ip == "0.0.0.0":
+                    ip = "127.0.0.1"
 
 
             # Format IP and port for PASV response
             # Format IP and port for PASV response
             ip_parts = ip.split(".")
             ip_parts = ip.split(".")
@@ -270,10 +282,9 @@ class FTPSession:
                 227,
                 227,
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
                 f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})",
             )
             )
-            logger.info("FTP PASV listening on port %s", self.data_port)
-
-        except Exception as e:
-            logger.error("Failed to create passive data connection: %s", e)
+            logger.info("FTP PASV listening on %s:%s", ip, self.data_port)
+        else:
+            logger.error("Failed to bind any passive port for PASV")
             await self.send(425, "Cannot open data connection")
             await self.send(425, "Cannot open data connection")
 
 
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
     async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -458,6 +469,9 @@ class FTPSession:
 class VirtualPrinterFTPServer:
 class VirtualPrinterFTPServer:
     """Implicit FTPS server that accepts uploads from slicers."""
     """Implicit FTPS server that accepts uploads from slicers."""
 
 
+    PASSIVE_PORT_MIN = 50000
+    PASSIVE_PORT_MAX = 50100
+
     def __init__(
     def __init__(
         self,
         self,
         upload_dir: Path,
         upload_dir: Path,
@@ -487,6 +501,8 @@ class VirtualPrinterFTPServer:
         self._running = False
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
         self._ssl_context: ssl.SSLContext | None = None
         self._active_sessions: list[asyncio.Task] = []
         self._active_sessions: list[asyncio.Task] = []
+        # Override PASV response IP for Docker bridge mode / NAT environments
+        self._pasv_address = os.environ.get("VIRTUAL_PRINTER_PASV_ADDRESS", "")
 
 
     async def start(self) -> None:
     async def start(self) -> None:
         """Start the implicit FTPS server."""
         """Start the implicit FTPS server."""
@@ -521,6 +537,13 @@ class VirtualPrinterFTPServer:
             self._running = True
             self._running = True
 
 
             logger.info("Implicit FTPS server started on port %s", self.port)
             logger.info("Implicit FTPS server started on port %s", self.port)
+            logger.info(
+                "FTP passive data port range: %s-%s",
+                self.PASSIVE_PORT_MIN,
+                self.PASSIVE_PORT_MAX,
+            )
+            if self._pasv_address:
+                logger.info("FTP PASV address override: %s", self._pasv_address)
 
 
             async with self._server:
             async with self._server:
                 await self._server.serve_forever()
                 await self._server.serve_forever()
@@ -549,6 +572,8 @@ class VirtualPrinterFTPServer:
             access_code=self.access_code,
             access_code=self.access_code,
             ssl_context=self._ssl_context,
             ssl_context=self._ssl_context,
             on_file_received=self.on_file_received,
             on_file_received=self.on_file_received,
+            passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
+            pasv_address=self._pasv_address,
         )
         )
 
 
         # Track the session task so we can cancel it on stop
         # Track the session task so we can cancel it on stop

+ 21 - 5
backend/app/services/virtual_printer/manager.py

@@ -296,8 +296,12 @@ class VirtualPrinterManager:
         self._cert_service.serial = proxy_serial
         self._cert_service.serial = proxy_serial
 
 
         # Regenerate printer cert if needed (CA is preserved)
         # Regenerate printer cert if needed (CA is preserved)
+        # Include remote interface IP in SAN so slicer TLS succeeds
+        additional_ips = []
+        if self._remote_interface_ip:
+            additional_ips.append(self._remote_interface_ip)
         self._cert_service.delete_printer_certificate()
         self._cert_service.delete_printer_certificate()
-        cert_path, key_path = self._cert_service.generate_certificates()
+        cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None)
         logger.info("Generated certificate for proxy serial: %s", proxy_serial)
         logger.info("Generated certificate for proxy serial: %s", proxy_serial)
 
 
         # Initialize TLS proxy with our certificates
         # Initialize TLS proxy with our certificates
@@ -359,9 +363,11 @@ class VirtualPrinterManager:
         )
         )
 
 
         logger.info(
         logger.info(
-            f"Virtual printer proxy started: "
-            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
-            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+            "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d",
+            self._target_printer_ip,
+            SlicerProxyManager.PRINTER_FTP_PORT,
+            self._target_printer_ip,
+            SlicerProxyManager.PRINTER_MQTT_PORT,
         )
         )
 
 
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
     def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None:
@@ -500,6 +506,11 @@ class VirtualPrinterManager:
             # "review" mode (or legacy "queue" mode)
             # "review" mode (or legacy "queue" mode)
             await self._queue_file(file_path, source_ip)
             await self._queue_file(file_path, source_ip)
 
 
+        # Reset MQTT status back to IDLE after file processing
+        # This tells the slicer the printer is done with the file
+        if self._mqtt and file_path.suffix.lower() == ".3mf":
+            self._mqtt.set_gcode_state("IDLE")
+
     async def _on_print_command(self, filename: str, data: dict) -> None:
     async def _on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
         """Handle print command from MQTT.
 
 
@@ -584,7 +595,12 @@ class VirtualPrinterManager:
 
 
         # Only queue 3MF files
         # Only queue 3MF files
         if file_path.suffix.lower() != ".3mf":
         if file_path.suffix.lower() != ".3mf":
-            logger.warning("Skipping non-3MF file: %s", file_path.name)
+            logger.debug("Skipping non-3MF file: %s", file_path.name)
+            self._pending_files.pop(file_path.name, None)
+            try:
+                file_path.unlink()
+            except OSError:
+                pass  # Best-effort removal of non-3MF file; may already be gone
             return
             return
 
 
         try:
         try:

+ 81 - 68
backend/app/services/virtual_printer/mqtt_server.py

@@ -181,6 +181,11 @@ class SimpleMQTTServer:
         self._status_push_task: asyncio.Task | None = None
         self._status_push_task: asyncio.Task | None = None
         self._sequence_id = 0
         self._sequence_id = 0
 
 
+        # Dynamic state for status reports
+        self._gcode_state = "IDLE"
+        self._current_file = ""
+        self._prepare_percent = "0"
+
     async def start(self) -> None:
     async def start(self) -> None:
         """Start the MQTT server."""
         """Start the MQTT server."""
         if self._running:
         if self._running:
@@ -521,10 +526,10 @@ class SimpleMQTTServer:
                     "sequence_id": str(self._sequence_id),
                     "sequence_id": str(self._sequence_id),
                     "command": "push_status",
                     "command": "push_status",
                     "msg": 0,
                     "msg": 0,
-                    "gcode_state": "IDLE",
-                    "gcode_file": "",
-                    "gcode_file_prepare_percent": "0",
-                    "subtask_name": "",
+                    "gcode_state": self._gcode_state,
+                    "gcode_file": self._current_file,
+                    "gcode_file_prepare_percent": self._prepare_percent,
+                    "subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "",
                     "mc_print_stage": "",
                     "mc_print_stage": "",
                     "mc_percent": 0,
                     "mc_percent": 0,
                     "mc_remaining_time": 0,
                     "mc_remaining_time": 0,
@@ -589,38 +594,7 @@ class SimpleMQTTServer:
                 }
                 }
             }
             }
 
 
-            topic = f"device/{self.serial}/report"
-            message = json.dumps(status)
-
-            # Build MQTT PUBLISH packet
-            topic_bytes = topic.encode("utf-8")
-            message_bytes = message.encode("utf-8")
-
-            # Calculate remaining length
-            remaining = 2 + len(topic_bytes) + len(message_bytes)
-
-            # Build packet
-            packet = bytes([0x30])  # PUBLISH, QoS 0
-
-            # Encode remaining length
-            while remaining > 0:
-                byte = remaining % 128
-                remaining //= 128
-                if remaining > 0:
-                    byte |= 0x80
-                packet += bytes([byte])
-
-            # Topic length and topic
-            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
-            packet += topic_bytes
-
-            # Message payload
-            packet += message_bytes
-
-            writer.write(packet)
-            await writer.drain()
-
-            logger.info("Sent initial status report on %s", topic)
+            await self._publish_to_report(writer, status)
 
 
         except OSError as e:
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
             logger.error("Failed to send status report: %s", e)
@@ -684,41 +658,74 @@ class SimpleMQTTServer:
                 }
                 }
             }
             }
 
 
-            topic = f"device/{self.serial}/report"
-            message = json.dumps(version_info)
-
-            # Build MQTT PUBLISH packet
-            topic_bytes = topic.encode("utf-8")
-            message_bytes = message.encode("utf-8")
-
-            # Calculate remaining length
-            remaining = 2 + len(topic_bytes) + len(message_bytes)
-
-            # Build packet
-            packet = bytes([0x30])  # PUBLISH, QoS 0
-
-            # Encode remaining length
-            while remaining > 0:
-                byte = remaining % 128
-                remaining //= 128
-                if remaining > 0:
-                    byte |= 0x80
-                packet += bytes([byte])
+            await self._publish_to_report(writer, version_info)
+            logger.info("Sent version response")
 
 
-            # Topic length and topic
-            packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
-            packet += topic_bytes
-
-            # Message payload
-            packet += message_bytes
+        except OSError as e:
+            logger.error("Failed to send version response: %s", e)
 
 
-            writer.write(packet)
-            await writer.drain()
+    def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None:
+        """Update the gcode state reported to connected slicers.
 
 
-            logger.info("Sent version response on %s", topic)
+        Called by the manager to reflect FTP upload progress/completion.
+        """
+        self._gcode_state = state
+        self._current_file = filename
+        self._prepare_percent = prepare_percent
+
+    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None:
+        """Publish a message on the device report topic."""
+        topic = f"device/{self.serial}/report"
+        message = json.dumps(payload)
+
+        topic_bytes = topic.encode("utf-8")
+        message_bytes = message.encode("utf-8")
+
+        remaining = 2 + len(topic_bytes) + len(message_bytes)
+        packet = bytes([0x30])  # PUBLISH, QoS 0
+
+        while remaining > 0:
+            byte = remaining % 128
+            remaining //= 128
+            if remaining > 0:
+                byte |= 0x80
+            packet += bytes([byte])
+
+        packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF])
+        packet += topic_bytes
+        packet += message_bytes
+
+        writer.write(packet)
+        await writer.drain()
+
+    async def _send_print_response(self, writer: asyncio.StreamWriter, sequence_id: str, filename: str) -> None:
+        """Send project_file acknowledgment matching real Bambu printer behavior."""
+        # Update state so periodic status pushes reflect preparation
+        self._gcode_state = "PREPARE"
+        self._current_file = filename
+        self._prepare_percent = "0"
 
 
+        try:
+            # Send command acknowledgment — slicer expects to see
+            # command: "project_file" echoed back before starting FTP upload
+            subtask_name = filename.replace(".3mf", "") if filename else ""
+            response = {
+                "print": {
+                    "command": "project_file",
+                    "sequence_id": sequence_id,
+                    "param": "Metadata/plate_1.gcode",
+                    "subtask_name": subtask_name,
+                    "gcode_state": "PREPARE",
+                    "gcode_file": filename,
+                    "gcode_file_prepare_percent": "0",
+                    "result": "SUCCESS",
+                    "msg": 0,
+                }
+            }
+            await self._publish_to_report(writer, response)
+            logger.info("Sent project_file acknowledgment for %s", filename)
         except OSError as e:
         except OSError as e:
-            logger.error("Failed to send version response: %s", e)
+            logger.error("Failed to send print response: %s", e)
 
 
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
     async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None:
         """Handle MQTT PUBLISH packet."""
         """Handle MQTT PUBLISH packet."""
@@ -776,11 +783,17 @@ class SimpleMQTTServer:
                         print_data = data["print"]
                         print_data = data["print"]
                         command = print_data.get("command", "")
                         command = print_data.get("command", "")
                         filename = print_data.get("subtask_name", "")
                         filename = print_data.get("subtask_name", "")
+                        sequence_id = print_data.get("sequence_id", "0")
 
 
                         logger.info("MQTT print command: %s for %s", command, filename)
                         logger.info("MQTT print command: %s for %s", command, filename)
 
 
-                        if self.on_print_command and command == "project_file":
-                            await self._notify_print_command(filename, print_data)
+                        if command == "project_file":
+                            # Respond with PREPARE status so slicer proceeds with FTP upload
+                            file_3mf = print_data.get("file", filename)
+                            await self._send_print_response(writer, sequence_id, file_3mf)
+
+                            if self.on_print_command:
+                                await self._notify_print_command(filename, print_data)
 
 
                 except json.JSONDecodeError:
                 except json.JSONDecodeError:
                     pass  # Non-JSON payloads on request topic are safely ignored
                     pass  # Non-JSON payloads on request topic are safely ignored

+ 78 - 8
backend/app/services/virtual_printer/ssdp_server.py

@@ -328,8 +328,13 @@ class SSDPProxy:
             pass  # Return partial headers if parsing fails; malformed packets are common
             pass  # Return partial headers if parsing fails; malformed packets are common
         return headers
         return headers
 
 
-    def _rewrite_ssdp_location(self, data: bytes) -> bytes:
-        """Rewrite SSDP message with Bambuddy's remote IP as Location."""
+    def _rewrite_ssdp(self, data: bytes) -> bytes:
+        """Rewrite SSDP message for proxy re-broadcast.
+
+        - Location: changed to Bambuddy's remote interface IP
+        - DevBind: forced to 'free' so the slicer treats the proxy as a
+          LAN-only printer (avoids cloud auth requirement for sending prints)
+        """
         try:
         try:
             text = data.decode("utf-8", errors="ignore")
             text = data.decode("utf-8", errors="ignore")
             original = text
             original = text
@@ -340,11 +345,25 @@ class SSDPProxy:
                 text,
                 text,
                 flags=re.IGNORECASE,
                 flags=re.IGNORECASE,
             )
             )
+            # Force DevBind to 'free' - ensures slicer uses LAN mode for
+            # both monitoring AND sending prints through the proxy
+            text = re.sub(
+                r"(DevBind\.bambu\.com:\s*)\S+",
+                r"\g<1>free",
+                text,
+                flags=re.IGNORECASE,
+            )
+            # Append " - Proxy" to printer name so it's distinguishable
+            text = re.sub(
+                r"(DevName\.bambu\.com:\s*)(.+)",
+                r"\g<1>\g<2> - Proxy",
+                text,
+                flags=re.IGNORECASE,
+            )
             if text != original:
             if text != original:
-                logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip)
-                logger.debug("Rewritten SSDP packet:\n%s", text)
+                logger.debug("Rewrote SSDP for proxy:\n%s", text)
             else:
             else:
-                logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original)
+                logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
             return text.encode("utf-8")
             return text.encode("utf-8")
         except Exception as e:
         except Exception as e:
             logger.error("Failed to rewrite SSDP: %s", e)
             logger.error("Failed to rewrite SSDP: %s", e)
@@ -453,13 +472,26 @@ class SSDPProxy:
         self._remote_socket = None
         self._remote_socket = None
 
 
     async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
     async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
-        """Handle SSDP packet received on local interface (LAN A)."""
+        """Handle SSDP packet received on local interface (LAN A).
+
+        Processes two types of traffic:
+        - NOTIFY from the real printer → cache and re-broadcast on LAN B
+        - M-SEARCH from slicers on LAN B → respond with cached printer info
+        """
         sender_ip = addr[0]
         sender_ip = addr[0]
 
 
-        # Only process packets from the target printer
+        # Ignore packets from our own interfaces (prevent loops)
+        if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
+            return
+
+        # Handle M-SEARCH from slicers (any IP that's not the target printer)
         if sender_ip != self.target_printer_ip:
         if sender_ip != self.target_printer_ip:
+            if b"M-SEARCH" in data:
+                await self._respond_to_msearch(data, addr)
             return
             return
 
 
+        # Below: NOTIFY handling from the real printer
+
         # Check if it's a NOTIFY message
         # Check if it's a NOTIFY message
         if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
         if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
             return
             return
@@ -478,6 +510,44 @@ class SSDPProxy:
         self._last_printer_ssdp = data
         self._last_printer_ssdp = data
         await self._broadcast_to_remote()
         await self._broadcast_to_remote()
 
 
+    async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
+        """Respond to M-SEARCH from a slicer with cached, rewritten printer info.
+
+        When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
+        we respond with the cached printer info, rewritten to point to the
+        proxy's LAN B IP. Without this, the slicer thinks the printer is
+        offline and shows a 'connect to printer' modal.
+        """
+        # Check if it's a relevant M-SEARCH
+        if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
+            return
+
+        if not self._last_printer_ssdp:
+            logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
+            return
+
+        logger.debug("Received M-SEARCH from slicer %s", addr[0])
+
+        # Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
+        rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
+        text = rewritten.decode("utf-8", errors="ignore")
+
+        # Convert NOTIFY format to M-SEARCH response format:
+        #   "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
+        #   NT: → ST: (Notification Type → Search Target)
+        #   Remove NTS: header (only in NOTIFY)
+        text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
+        text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
+        text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
+
+        # Send unicast response directly to the slicer via remote socket
+        if self._remote_socket:
+            try:
+                self._remote_socket.sendto(text.encode("utf-8"), addr)
+                logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
+            except OSError as e:
+                logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
+
     async def _broadcast_to_remote(self) -> None:
     async def _broadcast_to_remote(self) -> None:
         """Broadcast cached printer SSDP on remote interface (LAN B)."""
         """Broadcast cached printer SSDP on remote interface (LAN B)."""
         if not self._remote_socket or not self._last_printer_ssdp:
         if not self._remote_socket or not self._last_printer_ssdp:
@@ -485,7 +555,7 @@ class SSDPProxy:
 
 
         try:
         try:
             # Rewrite Location to point to Bambuddy's remote interface
             # Rewrite Location to point to Bambuddy's remote interface
-            rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp)
+            rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
 
 
             # Calculate broadcast address for remote network
             # Calculate broadcast address for remote network
             # Use 255.255.255.255 for simplicity (works across subnets)
             # Use 255.255.255.255 for simplicity (works across subnets)

+ 582 - 3
backend/app/services/virtual_printer/tcp_proxy.py

@@ -12,13 +12,58 @@ Unlike a transparent TCP proxy, this terminates TLS on both ends:
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import random
+import re
 import ssl
 import ssl
+import subprocess
 from collections.abc import Callable
 from collections.abc import Callable
 from pathlib import Path
 from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def detect_port_redirect(port: int) -> int | None:
+    """Detect if iptables redirects a port to another port.
+
+    When iptables NAT REDIRECT rules exist (e.g. 990→9990), connections
+    to the original port never reach our socket because iptables intercepts
+    them in PREROUTING. We must listen on the redirect target instead.
+
+    Returns the redirect target port, or None if no redirect is active.
+    """
+    # Method 1: Read persistent rules file (doesn't require root)
+    for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"):
+        try:
+            with open(rules_path) as f:
+                content = f.read()
+            match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content)
+            if match:
+                target = int(match.group(1))
+                if target != port:
+                    return target
+        except (FileNotFoundError, PermissionError, OSError):
+            continue
+
+    # Method 2: Query live iptables rules (may require root)
+    try:
+        result = subprocess.run(  # noqa: S603, S607
+            ["iptables-save", "-t", "nat"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if result.returncode == 0:
+            match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout)
+            if match:
+                target = int(match.group(1))
+                if target != port:
+                    return target
+    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
+        pass
+
+    return None
+
+
 class TLSProxy:
 class TLSProxy:
     """TLS terminating proxy that forwards data between client and target.
     """TLS terminating proxy that forwards data between client and target.
 
 
@@ -115,6 +160,17 @@ class TLSProxy:
         except OSError as e:
         except OSError as e:
             if e.errno == 98:  # Address already in use
             if e.errno == 98:  # Address already in use
                 logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
                 logger.error("%s proxy port %s is already in use", self.name, self.listen_port)
+            elif e.errno == 13:  # Permission denied
+                logger.error(
+                    "%s proxy: cannot bind to port %s (permission denied). "
+                    "Port %s requires root or CAP_NET_BIND_SERVICE. "
+                    "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. "
+                    "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' "
+                    "or redirect with iptables.",
+                    self.name,
+                    self.listen_port,
+                    self.listen_port,
+                )
             else:
             else:
                 logger.error("%s proxy error: %s", self.name, e)
                 logger.error("%s proxy error: %s", self.name, e)
         except asyncio.CancelledError:
         except asyncio.CancelledError:
@@ -284,6 +340,515 @@ class TLSProxy:
         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."""
+        # Close all data servers first
+        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] = []
+        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"
+        if local_ip in ("0.0.0.0", "::"):
+            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
+
+        # 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"}
+
+        # 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],
+    ) -> 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()
+
+                    # Log all FTP commands from slicer
+                    try:
+                        logger.info("FTP cmd  >>> %s", line.decode("utf-8", errors="replace"))
+                    except Exception:
+                        pass
+
+                    # 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":
+                        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],
+    ) -> 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 :]
+
+                    # Log all FTP responses from printer
+                    try:
+                        logger.info("FTP resp <<< %s", line.decode("utf-8", errors="replace"))
+                    except Exception:
+                        pass
+
+                    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]) -> 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]) -> 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",
+        )
+
+        # 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)
+            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)
+                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) -> None:
+        """Start a one-shot server for one FTP data connection.
+
+        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.
+        client_ssl = self._client_ssl_context if use_tls else None
+        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:
+                # 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,
+                )
+
+                # Bidirectional data 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()
+                    try:
+                        await task
+                    except asyncio.CancelledError:
+                        pass  # Expected when other data direction closes
+            except TimeoutError:
+                logger.error("FTP data proxy port %s: timeout connecting to printer", port)
+            except ssl.SSLError as e:
+                logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
+            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. The proxy terminates TLS only on
+            # the printer side (inside handle_data).
+        )
+        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)
+
+        asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
+
+        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."""
 
 
@@ -324,10 +889,24 @@ class SlicerProxyManager:
         """Start FTP and MQTT TLS proxies."""
         """Start FTP and MQTT TLS proxies."""
         logger.info("Starting slicer TLS proxy to %s", self.target_host)
         logger.info("Starting slicer TLS proxy to %s", self.target_host)
 
 
-        # Create proxies with TLS
-        self._ftp_proxy = TLSProxy(
+        # Detect iptables port redirect (e.g. 990→9990 for non-root installs).
+        # If active, connections to port 990 get intercepted by iptables PREROUTING
+        # and sent to the redirect target — our socket on 990 never sees them.
+        ftp_listen_port = self.LOCAL_FTP_PORT
+        redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
+        if redirect_target:
+            logger.info(
+                "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.",
+                self.LOCAL_FTP_PORT,
+                redirect_target,
+                redirect_target,
+            )
+            ftp_listen_port = redirect_target
+
+        # Create FTP proxy with PASV/EPSV awareness for data connections
+        self._ftp_proxy = FTPTLSProxy(
             name="FTP",
             name="FTP",
-            listen_port=self.LOCAL_FTP_PORT,
+            listen_port=ftp_listen_port,
             target_host=self.target_host,
             target_host=self.target_host,
             target_port=self.PRINTER_FTP_PORT,
             target_port=self.PRINTER_FTP_PORT,
             server_cert_path=self.cert_path,
             server_cert_path=self.cert_path,

+ 5 - 5
backend/tests/unit/services/test_virtual_printer.py

@@ -507,7 +507,7 @@ class TestSSDPProxy:
         """Verify SSDP Location header is rewritten to remote interface IP."""
         """Verify SSDP Location header is rewritten to remote interface IP."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
         # Location should be changed to remote interface IP
         # Location should be changed to remote interface IP
         assert b"Location: 10.0.0.100" in rewritten
         assert b"Location: 10.0.0.100" in rewritten
@@ -519,7 +519,7 @@ class TestSSDPProxy:
         """Verify SSDP Location rewrite is case insensitive."""
         """Verify SSDP Location rewrite is case insensitive."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
         assert b"10.0.0.100" in rewritten
         assert b"10.0.0.100" in rewritten
 
 
@@ -527,10 +527,10 @@ class TestSSDPProxy:
         """Verify packet without Location header is returned unchanged."""
         """Verify packet without Location header is returned unchanged."""
         original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
         original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
 
 
-        rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet)
+        rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
 
 
-        # Should be unchanged (no Location header to rewrite)
-        assert rewritten == original_packet
+        # No Location header, but _rewrite_ssdp logs a warning and returns as-is
+        assert b"DevName.bambu.com: Test" in rewritten
 
 
     def test_parse_ssdp_message(self, ssdp_proxy):
     def test_parse_ssdp_message(self, ssdp_proxy):
         """Verify SSDP message parsing extracts headers."""
         """Verify SSDP message parsing extracts headers."""

+ 12 - 0
docker-compose.yml

@@ -10,6 +10,11 @@ services:
     # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
     # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
     user: "${PUID:-1000}:${PGID:-1000}"
     user: "${PUID:-1000}:${PGID:-1000}"
     #
     #
+    # Proxy mode: allow binding to port 990 (FTP) as non-root user.
+    # Without this, the FTP proxy silently fails and sending prints won't work.
+    cap_add:
+      - NET_BIND_SERVICE
+    #
     # LINUX: Use host mode for printer discovery and camera streaming
     # LINUX: Use host mode for printer discovery and camera streaming
     network_mode: host
     network_mode: host
     #
     #
@@ -18,6 +23,9 @@ services:
     # Note: Printer discovery won't work - add printers manually by IP.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
     #  - "${PORT:-8000}:8000"
     #  - "${PORT:-8000}:8000"
+    #  - "8883:8883"                  # Virtual printer MQTT
+    #  - "9990:9990"                  # Virtual printer FTP control
+    #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
@@ -30,6 +38,10 @@ services:
       # Port BamBuddy runs on (default: 8000)
       # Port BamBuddy runs on (default: 8000)
       # Usage: PORT=8080 docker compose up -d
       # Usage: PORT=8080 docker compose up -d
       - PORT=${PORT:-8000}
       - PORT=${PORT:-8000}
+      # Virtual printer: Set to the Docker host's IP when using bridge mode (ports:).
+      # Required for FTP passive mode to work behind NAT.
+      # Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100
+      #- VIRTUAL_PRINTER_PASV_ADDRESS=
     restart: unless-stopped
     restart: unless-stopped
 
 
 volumes:
 volumes: