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, file transfer and camera ports were 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 proxies for port 6000 (file transfer) and 322 (RTSP camera)
  - 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
1b1f58fc88

+ 1 - 1
CHANGELOG.md

@@ -20,7 +20,7 @@ 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.
+- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding TLS proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
 - **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

+ 25 - 1
backend/app/services/virtual_printer/tcp_proxy.py

@@ -540,7 +540,6 @@ class TLSProxy:
                 await writer.drain()
 
                 total_bytes += len(data)
-                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
 
         except asyncio.CancelledError:
             pass  # Expected when the other forwarding direction closes first
@@ -1289,6 +1288,7 @@ class SlicerProxyManager:
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
     PRINTER_FILE_TRANSFER_PORT = 6000
+    PRINTER_RTSP_PORT = 322  # X1/H2/P2 series camera (A1/P1 use port 6000)
     PRINTER_BIND_PORTS = [3000, 3002]
 
     # Local listen ports - must match what Bambu Studio expects
@@ -1328,6 +1328,7 @@ class SlicerProxyManager:
         self._ftp_proxy: TLSProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
         self._file_transfer_proxy: TLSProxy | None = None
+        self._rtsp_proxy: TLSProxy | None = None
         self._bind_proxies: list[TCPProxy] = []
         self._bind_server = None
         self._tasks: list[asyncio.Task] = []
@@ -1390,6 +1391,21 @@ class SlicerProxyManager:
             bind_address=self.bind_address,
         )
 
+        # RTSP camera proxy — port 322 (TLS)
+        # X1/H2/P2 series use RTSP on port 322 for camera streaming.
+        # A1/P1 series use port 6000 (already proxied via file transfer proxy).
+        self._rtsp_proxy = TLSProxy(
+            name="RTSP",
+            listen_port=self.PRINTER_RTSP_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_RTSP_PORT,
+            server_cert_path=self.cert_path,
+            server_key_path=self.key_path,
+            on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
+        )
+
         # 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
@@ -1453,6 +1469,10 @@ class SlicerProxyManager:
                 run_with_logging(self._file_transfer_proxy),
                 name="slicer_proxy_file_transfer",
             ),
+            asyncio.create_task(
+                run_with_logging(self._rtsp_proxy),
+                name="slicer_proxy_rtsp",
+            ),
         ]
         if self._bind_server:
             self._tasks.append(
@@ -1495,6 +1515,10 @@ class SlicerProxyManager:
             await self._file_transfer_proxy.stop()
             self._file_transfer_proxy = None
 
+        if self._rtsp_proxy:
+            await self._rtsp_proxy.stop()
+            self._rtsp_proxy = None
+
         if self._bind_server:
             await self._bind_server.stop()
             self._bind_server = None

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

@@ -1089,6 +1089,8 @@ class TestSlicerProxyManager:
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
+        assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
+        assert proxy_manager.PRINTER_RTSP_PORT == 322
         # Bind ports: both 3000 and 3002 for slicer compatibility
         assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]