Browse Source

[Fix] Virtual Printer proxy mode fails on isolated networks (#757)

  When the slicer and printer are on different VLANs, Bambu Studio could
  not send prints through the proxy because the printer's real IP leaked
  through MQTT payloads, the bind protocol forwarded the real printer's
  identity, the port 6000 file transfer tunnel was not proxied, and FTP
  data connections raced the TLS handshake on zero-byte uploads.

  - Rewrite IP addresses in MQTT PUBLISH payloads (string + integer)
    with proper packet framing and cross-chunk buffering
  - Respond to bind/detect with VP identity via BindServer
  - Add TLS proxy for port 6000 (file transfer tunnel)
  - Buffer slicer FTP data during printer connection setup
  - Advertise configured VP name in SSDP proxy
  - Add cross-subnet SSDP wildcard listener for VPN setups
  - Register UserEmailPreference model in models/__init__.py
  - Add 11 unit tests for MQTT rewrite, IP conversion, SSDP name
maziggy 2 months ago
parent
commit
0b2b81a34d

+ 2 - 0
CHANGELOG.md

@@ -20,6 +20,8 @@ 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 a TLS proxy for port 6000, 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.
+- **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.
 
 ### 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 - 0
backend/app/models/__init__.py

@@ -25,6 +25,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
+from backend.app.models.user_email_pref import UserEmailPreference
 
 __all__ = [
     "Printer",
@@ -59,4 +60,5 @@ __all__ = [
     "SpoolUsageHistory",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
+    "UserEmailPreference",
 ]

+ 7 - 0
backend/app/services/virtual_printer/manager.py

@@ -473,6 +473,12 @@ class VirtualPrinterInstance:
             key_path=key_path,
             on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
             bind_address=self.bind_ip or "0.0.0.0",  # nosec B104
+            bind_identity={
+                "serial": self.target_printer_serial or self.serial,
+                "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                "name": self.name,
+                "version": "01.00.00.00",
+            },
         )
 
         async def run_with_logging(coro, svc_name):
@@ -494,6 +500,7 @@ class VirtualPrinterInstance:
                     local_interface_ip=local_iface["ip"],
                     remote_interface_ip=self.remote_interface_ip,
                     target_printer_ip=self.target_printer_ip,
+                    name=self.name,
                 )
                 self._tasks.append(
                     asyncio.create_task(

+ 96 - 27
backend/app/services/virtual_printer/ssdp_server.py

@@ -35,6 +35,7 @@ class VirtualPrinterSSDPServer:
         model: str = "BL-P001",  # X1C model code for best compatibility
         advertise_ip: str = "",
         bind_ip: str = "",
+        extra_interfaces: list[str] | None = None,
     ):
         """Initialize the SSDP server.
 
@@ -44,6 +45,10 @@ class VirtualPrinterSSDPServer:
             model: Model code
             advertise_ip: Override IP to advertise instead of auto-detecting
             bind_ip: IP address to bind the SSDP socket to
+            extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).
+                NOTIFY and M-SEARCH responses are sent on these interfaces too,
+                but Location always points to the bind IP so the slicer connects
+                to the correct address for MQTT/FTP.
         """
         self.name = name
         self.serial = serial
@@ -51,6 +56,8 @@ class VirtualPrinterSSDPServer:
         self._bind_ip = bind_ip
         self._running = False
         self._socket: socket.socket | None = None
+        self._extra_sockets: list[socket.socket] = []
+        self._extra_interfaces = extra_interfaces or []
         self._local_ip: str | None = advertise_ip or bind_ip or None
 
     def _get_local_ip(self) -> str:
@@ -163,6 +170,30 @@ class VirtualPrinterSSDPServer:
             logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
             logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
 
+            # Create extra sockets for additional interfaces (VPN, etc.)
+            # If no explicit extra interfaces given and we're bound to a
+            # specific IP, add a wildcard socket to catch M-SEARCH from
+            # other subnets (VPN tunnels, secondary NICs, etc.)
+            extra_ips = list(self._extra_interfaces)
+            if not extra_ips and self._bind_ip:
+                extra_ips.append("0.0.0.0")  # nosec B104
+
+            for iface_ip in extra_ips:
+                try:
+                    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+                    try:
+                        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+                    except (AttributeError, OSError):
+                        pass
+                    sock.setblocking(False)
+                    sock.bind((iface_ip, SSDP_PORT))
+                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+                    self._extra_sockets.append(sock)
+                    logger.info("SSDP server also listening on %s:%s", iface_ip, SSDP_PORT)
+                except OSError as e:
+                    logger.warning("SSDP server: failed to bind extra interface %s: %s", iface_ip, e)
+
             # Send initial NOTIFY
             await self._send_notify()
             logger.info("Sent initial SSDP NOTIFY announcement")
@@ -172,7 +203,7 @@ class VirtualPrinterSSDPServer:
             notify_interval = 30.0  # Send NOTIFY every 30 seconds
 
             while self._running:
-                # Try to receive M-SEARCH requests
+                # Try to receive M-SEARCH requests on primary socket
                 try:
                     data, addr = self._socket.recvfrom(4096)
                     message = data.decode("utf-8", errors="ignore")
@@ -183,6 +214,17 @@ class VirtualPrinterSSDPServer:
                     if self._running:
                         logger.debug("SSDP receive error: %s", e)
 
+                # Try to receive M-SEARCH requests on extra sockets
+                for sock in self._extra_sockets:
+                    try:
+                        data, addr = sock.recvfrom(4096)
+                        message = data.decode("utf-8", errors="ignore")
+                        await self._handle_message(message, addr, sock)
+                    except BlockingIOError:
+                        pass
+                    except OSError:
+                        pass
+
                 # Send periodic NOTIFY
                 now = asyncio.get_event_loop().time()
                 if now - last_notify >= notify_interval:
@@ -224,23 +266,35 @@ class VirtualPrinterSSDPServer:
                 pass  # Best-effort socket close; may already be released
             self._socket = None
 
+        for sock in self._extra_sockets:
+            try:
+                sock.close()
+            except OSError:
+                pass
+        self._extra_sockets = []
+
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message via broadcast."""
-        if not self._socket:
-            return
+        """Send SSDP NOTIFY message via broadcast on all sockets."""
+        msg = self._build_notify_message()
 
-        try:
-            msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
-            logger.debug(
-                "Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
-                self.name,
-                self._get_local_ip(),
-                self.serial,
-                self._bind_ip,
-            )
-        except OSError as e:
-            logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
+        if self._socket:
+            try:
+                self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+                logger.debug(
+                    "Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
+                    self.name,
+                    self._get_local_ip(),
+                    self.serial,
+                    self._bind_ip,
+                )
+            except OSError as e:
+                logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
+
+        for sock in self._extra_sockets:
+            try:
+                sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+            except OSError:
+                pass  # Best-effort broadcast on extra interfaces
 
     async def _send_byebye(self) -> None:
         """Send SSDP byebye message when shutting down."""
@@ -262,12 +316,15 @@ class VirtualPrinterSSDPServer:
         except OSError:
             pass  # Best-effort byebye send; network may be unavailable during shutdown
 
-    async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
+    async def _handle_message(
+        self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None
+    ) -> None:
         """Handle incoming SSDP message.
 
         Args:
             message: The SSDP message content
             addr: Tuple of (ip_address, port) of sender
+            reply_socket: Socket to send the response on (defaults to primary)
         """
         # Check if this is an M-SEARCH request for Bambu printers
         if "M-SEARCH" not in message:
@@ -279,11 +336,12 @@ class VirtualPrinterSSDPServer:
 
         logger.debug("Received M-SEARCH from %s", addr[0])
 
-        # Send response
-        if self._socket:
+        # Send response on the socket that received the request
+        sock = reply_socket or self._socket
+        if sock:
             try:
                 response = self._build_response_message()
-                self._socket.sendto(response, addr)
+                sock.sendto(response, addr)
                 logger.info(
                     "Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
                     addr[0],
@@ -310,6 +368,7 @@ class SSDPProxy:
         local_interface_ip: str,
         remote_interface_ip: str,
         target_printer_ip: str,
+        name: str | None = None,
     ):
         """Initialize the SSDP proxy.
 
@@ -317,10 +376,12 @@ class SSDPProxy:
             local_interface_ip: IP of interface on printer's network (LAN A)
             remote_interface_ip: IP of interface on slicer's network (LAN B)
             target_printer_ip: IP of the real printer to proxy SSDP for
+            name: Optional VP name to advertise (replaces printer's real name)
         """
         self.local_interface_ip = local_interface_ip
         self.remote_interface_ip = remote_interface_ip
         self.target_printer_ip = target_printer_ip
+        self.proxy_name = name
         self._running = False
         self._local_socket: socket.socket | None = None
         self._remote_socket: socket.socket | None = None
@@ -365,13 +426,21 @@ class SSDPProxy:
                 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,
-            )
+            # Replace printer name with configured VP name, or append " - Proxy"
+            if self.proxy_name:
+                text = re.sub(
+                    r"(DevName\.bambu\.com:\s*)[^\r\n]+",
+                    rf"\g<1>{self.proxy_name}",
+                    text,
+                    flags=re.IGNORECASE,
+                )
+            else:
+                text = re.sub(
+                    r"(DevName\.bambu\.com:\s*)([^\r\n]+)",
+                    r"\g<1>\g<2> - Proxy",
+                    text,
+                    flags=re.IGNORECASE,
+                )
             if text != original:
                 logger.debug("Rewrote SSDP for proxy:\n%s", text)
             else:

+ 346 - 46
backend/app/services/virtual_printer/tcp_proxy.py

@@ -82,6 +82,7 @@ class TLSProxy:
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
+        rewrite_ip: tuple[str, str] | None = None,
     ):
         """Initialize the TLS proxy.
 
@@ -95,6 +96,10 @@ class TLSProxy:
             on_connect: Optional callback when client connects (receives client_id)
             on_disconnect: Optional callback when client disconnects (receives client_id)
             bind_address: IP address to bind to (default: all interfaces)
+            rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
+                the printer's real IP with the proxy's bind IP in printer→client data.
+                This prevents the slicer from discovering the printer's real IP
+                in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
         """
         self.name = name
         self.listen_port = listen_port
@@ -106,12 +111,41 @@ class TLSProxy:
         self.on_disconnect = on_disconnect
         self.bind_address = bind_address
 
+        # IP rewriting for printer→client direction
+        if rewrite_ip:
+            self._rewrite_old = rewrite_ip[0].encode("utf-8")
+            self._rewrite_new = rewrite_ip[1].encode("utf-8")
+            # Also rewrite the integer IP in net.info[].ip fields.
+            # Bambu printers encode their IP as a little-endian uint32 integer
+            # in the JSON payload. BambuStudio reads this to set dev_ip.
+            self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
+            self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
+        else:
+            self._rewrite_old = None
+            self._rewrite_new = None
+            self._rewrite_old_int = None
+            self._rewrite_new_int = None
+
         self._server: asyncio.Server | None = None
         self._running = False
         self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
         self._server_ssl_context: ssl.SSLContext | None = None
         self._client_ssl_context: ssl.SSLContext | None = None
 
+    @staticmethod
+    def _ip_to_le_int_bytes(ip: str) -> bytes:
+        """Convert an IP address to its little-endian integer JSON representation.
+
+        E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
+        as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
+        """
+        import struct as _struct
+
+        parts = ip.split(".")
+        packed = bytes(int(p) for p in parts)
+        le_int = _struct.unpack("<I", packed)[0]
+        return str(le_int).encode("utf-8")
+
     def _create_server_ssl_context(self) -> ssl.SSLContext:
         """Create SSL context for accepting client (slicer) connections."""
         ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -263,7 +297,7 @@ class TLSProxy:
             name=f"{self.name}_c2p_{client_id}",
         )
         printer_to_client = asyncio.create_task(
-            self._forward(printer_reader, client_writer, f"printer→{client_id}"),
+            self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
             name=f"{self.name}_p2c_{client_id}",
         )
 
@@ -305,11 +339,157 @@ class TLSProxy:
                 except Exception:
                     pass  # Ignore disconnect callback errors; cleanup continues
 
+    @staticmethod
+    def _rewrite_mqtt_ip(
+        data: bytes,
+        old_ip: bytes,
+        new_ip: bytes,
+        buffer: bytearray,
+        extra_replacements: list[tuple[bytes, bytes]] | None = None,
+    ) -> tuple[bytes, bytearray]:
+        """Rewrite IP addresses inside MQTT packets, preserving packet framing.
+
+        MQTT packets have a variable-length header encoding the remaining
+        packet length.  A naive bytes.replace() would corrupt this framing
+        when old_ip and new_ip differ in length.
+
+        This method parses individual MQTT packets out of the data stream,
+        performs the replacement only on PUBLISH payloads, and re-encodes
+        the remaining-length field to match the new size.
+
+        Incomplete packets are buffered and returned for the next call.
+
+        Args:
+            extra_replacements: Additional (old, new) byte pairs to replace
+                (e.g. the integer IP representation in net.info[].ip).
+
+        Returns (output_data, remaining_buffer).
+        """
+        buffer.extend(data)
+
+        # Check if any replacement target exists in the buffer
+        has_target = old_ip in buffer
+        if not has_target and extra_replacements:
+            has_target = any(old in buffer for old, _new in extra_replacements)
+
+        if not has_target:
+            # Fast path: no IP in buffer, but we still need to check for
+            # incomplete packets at the end that might contain a partial IP.
+            # For safety, try to parse and emit only complete packets.
+            result = bytearray()
+            pos = 0
+            length = len(buffer)
+
+            while pos < length:
+                packet_start = pos
+                if pos + 1 >= length:
+                    break
+                pos += 1  # header byte
+
+                # Parse remaining length
+                remaining_length = 0
+                multiplier = 1
+                length_bytes = 0
+                while pos < length:
+                    encoded_byte = buffer[pos]
+                    pos += 1
+                    remaining_length += (encoded_byte & 0x7F) * multiplier
+                    multiplier *= 128
+                    length_bytes += 1
+                    if (encoded_byte & 0x80) == 0:
+                        break
+                    if length_bytes >= 4:
+                        break
+
+                if pos + remaining_length > length:
+                    # Incomplete — keep in buffer
+                    new_buffer = bytearray(buffer[packet_start:])
+                    return bytes(result), new_buffer
+
+                pos += remaining_length
+                result.extend(buffer[packet_start:pos])
+
+            # All complete
+            buffer.clear()
+            return bytes(result) if result else bytes(data), buffer
+
+        # Buffer contains old_ip — parse packets and rewrite
+        result = bytearray()
+        pos = 0
+        length = len(buffer)
+
+        while pos < length:
+            packet_start = pos
+
+            if pos >= length:
+                break
+            header_byte = buffer[pos]
+            pos += 1
+
+            # Remaining length: variable-length encoding (1-4 bytes)
+            remaining_length = 0
+            multiplier = 1
+            length_bytes = 0
+            while pos < length:
+                encoded_byte = buffer[pos]
+                pos += 1
+                remaining_length += (encoded_byte & 0x7F) * multiplier
+                multiplier *= 128
+                length_bytes += 1
+                if (encoded_byte & 0x80) == 0:
+                    break
+                if length_bytes >= 4:
+                    break
+
+            # Check if we have enough data for the full packet
+            if pos + remaining_length > length:
+                # Incomplete packet — keep in buffer for next call
+                new_buffer = bytearray(buffer[packet_start:])
+                return bytes(result), new_buffer
+
+            packet_type = (header_byte >> 4) & 0x0F
+            packet_body = buffer[pos : pos + remaining_length]
+            pos += remaining_length
+
+            # Only rewrite PUBLISH packets (type 3)
+            needs_rewrite = packet_type == 3 and (
+                old_ip in packet_body
+                or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
+            )
+            if needs_rewrite:
+                new_body = bytes(packet_body).replace(old_ip, new_ip)
+                if extra_replacements:
+                    for old_val, new_val in extra_replacements:
+                        new_body = new_body.replace(old_val, new_val)
+
+                # Re-encode: header byte + new remaining length + new body
+                result.append(header_byte)
+
+                # Encode remaining length (MQTT variable-length encoding)
+                new_remaining = len(new_body)
+                while True:
+                    encoded_byte = new_remaining % 128
+                    new_remaining //= 128
+                    if new_remaining > 0:
+                        encoded_byte |= 0x80
+                    result.append(encoded_byte)
+                    if new_remaining == 0:
+                        break
+
+                result.extend(new_body)
+            else:
+                # Pass through unchanged
+                result.extend(buffer[packet_start:pos])
+
+        buffer.clear()
+        return bytes(result), buffer
+
     async def _forward(
         self,
         reader: asyncio.StreamReader,
         writer: asyncio.StreamWriter,
         direction: str,
+        rewrite_ip: bool = False,
     ) -> None:
         """Forward data from reader to writer.
 
@@ -317,7 +497,12 @@ class TLSProxy:
             reader: Source stream (already TLS-decrypted)
             writer: Destination stream (will be TLS-encrypted by the stream)
             direction: Description for logging (e.g., "client→printer")
+            rewrite_ip: If True and rewrite_ip was configured, replace the
+                printer's real IP with the proxy's bind IP in the data.
         """
+        do_rewrite = rewrite_ip and self._rewrite_old is not None
+        rewrite_buffer = bytearray() if do_rewrite else None
+        rewrite_logged = False
         total_bytes = 0
         try:
             while self._running:
@@ -327,6 +512,29 @@ class TLSProxy:
                     # Connection closed
                     break
 
+                # 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,
+                        self._rewrite_old,
+                        self._rewrite_new,
+                        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(),
+                        )
+                        rewrite_logged = True
+                    if not data:
+                        continue  # All data buffered, waiting for more
+
                 # Forward to destination
                 writer.write(data)
                 await writer.drain()
@@ -923,6 +1131,11 @@ class FTPTLSProxy(TLSProxy):
     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.
 
+        When the slicer connects, immediately connects to the printer's data
+        port and buffers any slicer data until the printer connection is ready.
+        This handles zero-byte uploads (verify_job) where the slicer closes
+        the data channel before a naive proxy would finish its TLS handshake.
+
         The slicer-side listener is ALWAYS cleartext.  Even when the slicer
         sends PROT P on the control channel, Bambu Studio does not perform
         a TLS handshake on the data connection — it relies on the implicit
@@ -967,13 +1180,26 @@ class FTPTLSProxy(TLSProxy):
 
             printer_writer = None
             try:
+                # Buffer any slicer data while connecting to printer.
+                # This handles the race where the slicer sends data (or closes
+                # for zero-byte files) before the TLS handshake completes.
+                slicer_buffer = bytearray()
+                slicer_eof = False
+
+                async def buffer_slicer():
+                    nonlocal slicer_eof
+                    while True:
+                        chunk = await client_reader.read(65536)
+                        if not chunk:
+                            slicer_eof = True
+                            return
+                        slicer_buffer.extend(chunk)
+
+                buffer_task = asyncio.create_task(buffer_slicer())
+
                 # Connect to printer's data port
                 printer_reader, printer_writer = await asyncio.wait_for(
-                    asyncio.open_connection(
-                        printer_ip,
-                        printer_port,
-                        ssl=client_ssl,
-                    ),
+                    asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
                     timeout=10.0,
                 )
                 logger.info(
@@ -984,21 +1210,35 @@ class FTPTLSProxy(TLSProxy):
                     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)
+                # Stop buffering
+                buffer_task.cancel()
+                try:
+                    await buffer_task
+                except asyncio.CancelledError:
+                    pass
+
+                # Flush buffered slicer data to printer
+                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()
+                        try:
+                            await task
+                        except asyncio.CancelledError:
+                            pass  # Expected when other data direction closes
             except Exception as e:
                 logger.error("FTP data proxy port %s: error: %s", port, e)
             finally:
@@ -1048,6 +1288,7 @@ class SlicerProxyManager:
     # Bambu printer ports
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
+    PRINTER_FILE_TRANSFER_PORT = 6000
     PRINTER_BIND_PORTS = [3000, 3002]
 
     # Local listen ports - must match what Bambu Studio expects
@@ -1062,6 +1303,7 @@ class SlicerProxyManager:
         key_path: Path,
         on_activity: Callable[[str, str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
+        bind_identity: dict[str, str] | None = None,
     ):
         """Initialize the slicer proxy manager.
 
@@ -1071,16 +1313,23 @@ class SlicerProxyManager:
             key_path: Path to server private key
             on_activity: Optional callback for activity logging (name, message)
             bind_address: IP address to bind proxy listeners to
+            bind_identity: Optional dict with keys (serial, model, name, version)
+                for the bind/detect response. When provided, the proxy responds
+                to detect requests itself instead of forwarding to the printer.
+                This ensures the slicer sees the VP identity, not the real printer.
         """
         self.target_host = target_host
         self.cert_path = cert_path
         self.key_path = key_path
         self.on_activity = on_activity
         self.bind_address = bind_address
+        self.bind_identity = bind_identity
 
         self._ftp_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
+        self._file_transfer_proxy: TLSProxy | None = None
         self._bind_proxies: list[TCPProxy] = []
+        self._bind_server = None
         self._tasks: list[asyncio.Task] = []
 
     async def start(self) -> None:
@@ -1124,33 +1373,65 @@ class SlicerProxyManager:
             on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
             bind_address=self.bind_address,
+            rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
         )
 
-        # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
-        for bind_port in self.PRINTER_BIND_PORTS:
-            if bind_port == 3002:
-                proxy = TLSProxy(
-                    name="Bind-TLS",
-                    listen_port=bind_port,
-                    target_host=self.target_host,
-                    target_port=bind_port,
-                    server_cert_path=self.cert_path,
-                    server_key_path=self.key_path,
-                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                    bind_address=self.bind_address,
-                )
-            else:
-                proxy = TCPProxy(
-                    name="Bind",
-                    listen_port=bind_port,
-                    target_host=self.target_host,
-                    target_port=bind_port,
-                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                    bind_address=self.bind_address,
-                )
-            self._bind_proxies.append(proxy)
+        # File transfer proxy — port 6000 (TLS)
+        # BambuStudio connects here for verify_job and actual file uploads.
+        self._file_transfer_proxy = TLSProxy(
+            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,
+        )
+
+        # Bind/auth — respond with VP identity instead of proxying to printer.
+        # The detect response contains the printer name, serial, model, and
+        # bind status. Proxying it would leak the real printer's identity and
+        # cause the slicer to treat it as a different device.
+        if self.bind_identity:
+            from backend.app.services.virtual_printer.bind_server import BindServer
+
+            self._bind_server = BindServer(
+                serial=self.bind_identity["serial"],
+                model=self.bind_identity["model"],
+                name=self.bind_identity["name"],
+                version=self.bind_identity.get("version", "01.00.00.00"),
+                bind_address=self.bind_address,
+                cert_path=self.cert_path,
+                key_path=self.key_path,
+            )
+        else:
+            # Fallback: proxy bind requests to the real printer
+            for bind_port in self.PRINTER_BIND_PORTS:
+                if bind_port == 3002:
+                    proxy = TLSProxy(
+                        name="Bind-TLS",
+                        listen_port=bind_port,
+                        target_host=self.target_host,
+                        target_port=bind_port,
+                        server_cert_path=self.cert_path,
+                        server_key_path=self.key_path,
+                        on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                        on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                        bind_address=self.bind_address,
+                    )
+                else:
+                    proxy = TCPProxy(
+                        name="Bind",
+                        listen_port=bind_port,
+                        target_host=self.target_host,
+                        target_port=bind_port,
+                        on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                        on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                        bind_address=self.bind_address,
+                    )
+                self._bind_proxies.append(proxy)
 
         # Start as background tasks
         async def run_with_logging(proxy: TLSProxy) -> None:
@@ -1168,7 +1449,18 @@ class SlicerProxyManager:
                 run_with_logging(self._mqtt_proxy),
                 name="slicer_proxy_mqtt",
             ),
+            asyncio.create_task(
+                run_with_logging(self._file_transfer_proxy),
+                name="slicer_proxy_file_transfer",
+            ),
         ]
+        if self._bind_server:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(self._bind_server),
+                    name="slicer_proxy_bind_server",
+                )
+            )
         for bp in self._bind_proxies:
             self._tasks.append(
                 asyncio.create_task(
@@ -1199,6 +1491,14 @@ class SlicerProxyManager:
             await self._mqtt_proxy.stop()
             self._mqtt_proxy = None
 
+        if self._file_transfer_proxy:
+            await self._file_transfer_proxy.stop()
+            self._file_transfer_proxy = None
+
+        if self._bind_server:
+            await self._bind_server.stop()
+            self._bind_server = None
+
         for bp in self._bind_proxies:
             await bp.stop()
         self._bind_proxies = []

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

@@ -1567,3 +1567,218 @@ class TestResolveModelCodes:
 
         assert _resolve_printer_model(None) is None
         assert _resolve_printer_model("UnknownModel") is None
+
+
+class TestMqttIpRewrite:
+    """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
+
+    @staticmethod
+    def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
+        """Build a minimal MQTT PUBLISH packet."""
+        # PUBLISH fixed header: type 3, no flags
+        topic_bytes = topic.encode("utf-8")
+        # Variable header: topic length (2 bytes) + topic
+        var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
+        body = var_header + payload
+
+        # Encode remaining length
+        remaining = len(body)
+        header = bytearray([0x30])  # PUBLISH, QoS 0
+        while True:
+            encoded_byte = remaining % 128
+            remaining //= 128
+            if remaining > 0:
+                encoded_byte |= 0x80
+            header.append(encoded_byte)
+            if remaining == 0:
+                break
+
+        return bytes(header) + body
+
+    @staticmethod
+    def _build_mqtt_pingreq() -> bytes:
+        """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
+        return b"\xc0\x00"
+
+    def test_rewrite_ip_in_publish(self):
+        """IP string in PUBLISH payload is rewritten."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert b"10.0.0.1" in result
+        assert b"192.168.1.100" not in result
+
+    def test_no_rewrite_when_ip_absent(self):
+        """Packets without the target IP are passed through unchanged."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"status":"idle"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert result == packet
+
+    def test_non_publish_packets_unchanged(self):
+        """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        pingreq = self._build_mqtt_pingreq()
+        result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert result == pingreq
+
+    def test_rewrite_preserves_packet_framing(self):
+        """Rewritten packet has valid MQTT remaining length."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        # Use IPs of different lengths to test length re-encoding
+        old_ip = b"192.168.255.133"  # 15 bytes
+        new_ip = b"10.0.0.1"  # 8 bytes
+
+        payload = b'{"ip":"192.168.255.133"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
+
+        # Parse the result to verify framing
+        assert result[0] == 0x30  # PUBLISH header byte
+        # Decode remaining length
+        pos = 1
+        remaining = 0
+        multiplier = 1
+        while True:
+            b = result[pos]
+            pos += 1
+            remaining += (b & 0x7F) * multiplier
+            multiplier *= 128
+            if (b & 0x80) == 0:
+                break
+
+        # Remaining length should match actual data
+        assert pos + remaining == len(result)
+        assert new_ip in result
+
+    def test_incomplete_packet_buffered(self):
+        """Incomplete packet at end of chunk is buffered for next call."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"ip":"192.168.1.100"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        # Split packet in the middle
+        half = len(packet) // 2
+        chunk1 = packet[:half]
+        chunk2 = packet[half:]
+
+        result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
+        # First chunk should be buffered (incomplete packet)
+        assert len(buf) > 0
+
+        result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
+        # Second chunk completes the packet, IP should be rewritten
+        combined = result1 + result2
+        assert b"10.0.0.1" in combined
+        assert b"192.168.1.100" not in combined
+
+    def test_multiple_packets_in_one_chunk(self):
+        """Multiple MQTT packets in a single chunk are all processed."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload1 = b'{"ip":"192.168.1.100"}'
+        payload2 = b'{"other":"data"}'
+        packet1 = self._build_mqtt_publish("topic1", payload1)
+        packet2 = self._build_mqtt_publish("topic2", payload2)
+
+        combined = packet1 + packet2
+        result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert b"10.0.0.1" in result
+        assert b"192.168.1.100" not in result
+        # Second packet should still be present
+        assert b"other" in result
+
+    def test_extra_replacements(self):
+        """Extra replacement pairs (e.g. integer IP) are also applied."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"net":{"info":[{"ip":2248124608}]}}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(
+            packet,
+            b"NOMATCH",
+            b"NOREPLACE",
+            bytearray(),
+            extra_replacements=[(b"2248124608", b"285190336")],
+        )
+
+        assert b"285190336" in result
+        assert b"2248124608" not in result
+
+
+class TestIpToLeIntBytes:
+    """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
+
+    def test_converts_ip_to_le_int(self):
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
+        assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
+        assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
+
+    def test_roundtrip(self):
+        """Verify the integer converts back to the correct IP."""
+        import struct
+
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
+            le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
+            parts = ip.split(".")
+            expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
+            assert le_int == expected
+
+
+class TestSSDPProxyName:
+    """Tests for SSDPProxy VP name rewriting."""
+
+    @pytest.fixture
+    def ssdp_proxy_with_name(self):
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+            name="H2D-1 Proxy",
+        )
+
+    @pytest.fixture
+    def ssdp_proxy_without_name(self):
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+        )
+
+    def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
+        """When name is set, DevName is replaced entirely."""
+        packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
+        rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
+
+        assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
+        assert b"RealPrinter" not in rewritten
+
+    def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
+        """When no name is set, ' - Proxy' is appended to the real name."""
+        packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
+        rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
+
+        assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-HM8c6qA1.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DTIUjlhj.js"></script>
+    <script type="module" crossorigin src="/assets/index-HM8c6qA1.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DS0IE7o1.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff