Browse Source

[Debug] Virtual Printer proxy: diagnostic port probing for A1 (#757)

    A1/A1 Mini printers fail to connect through VP proxy mode while
    X1C/P2S/H2C work fine with identical transparent TCP proxy code.
    Root cause unknown — the proxy is model-agnostic, so the failure
    suggests BambuStudio uses a different connection flow for A1 models
    that hits ports we don't proxy.

    Add diagnostic probe listeners on ports 21, 80, and 443 on each
    proxy VP's dedicated bind IP. If the slicer connects to any of
    these un-proxied ports, a WARNING is logged so debug logs and
    tcpdump can reveal what's missing.
maziggy 2 months ago
parent
commit
f6a85031e6
2 changed files with 38 additions and 0 deletions
  1. 1 0
      CHANGELOG.md
  2. 37 0
      backend/app/services/virtual_printer/tcp_proxy.py

+ 1 - 0
CHANGELOG.md

@@ -24,6 +24,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.
 - **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.
 - **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.
+- **Virtual Printer Proxy A1 Diagnostics** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.
 
 ### 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.

+ 37 - 0
backend/app/services/virtual_printer/tcp_proxy.py

@@ -1463,6 +1463,7 @@ class SlicerProxyManager:
         self._rtsp_proxy: TCPProxy | None = None
         self._bind_proxies: list[TCPProxy] = []
         self._bind_server = None
+        self._probe_servers: list[asyncio.Server] = []
         self._tasks: list[asyncio.Task] = []
 
     # FTP passive data port range — Bambu printers typically use ports in
@@ -1650,6 +1651,23 @@ class SlicerProxyManager:
                 )
             )
 
+        # Diagnostic probe: listen on common un-proxied ports to detect
+        # if the slicer tries to reach a service we don't handle.
+        if self.bind_address and self.bind_address != "0.0.0.0":
+            for probe_port in (21, 80, 443):
+                try:
+                    srv = await asyncio.start_server(
+                        lambda r, w, p=probe_port: self._probe_handler(r, w, p),
+                        self.bind_address,
+                        probe_port,
+                    )
+                    self._probe_servers.append(srv)
+                except OSError:
+                    pass  # Port in use or no permission — skip
+            if self._probe_servers:
+                probed = [s.sockets[0].getsockname()[1] for s in self._probe_servers if s.sockets]
+                logger.info("Proxy diagnostic: probing un-proxied ports %s on %s", probed, self.bind_address)
+
         logger.info(
             "Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
             self.target_host,
@@ -1696,6 +1714,10 @@ class SlicerProxyManager:
             await dp.stop()
         self._ftp_data_proxies = []
 
+        for srv in self._probe_servers:
+            srv.close()
+        self._probe_servers = []
+
         # Cancel tasks
         for task in self._tasks:
             task.cancel()
@@ -1712,6 +1734,21 @@ class SlicerProxyManager:
         self._tasks = []
         logger.info("Slicer proxy stopped")
 
+    async def _probe_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int) -> None:
+        """Log unexpected connections on un-proxied ports for diagnostics."""
+        peername = writer.get_extra_info("peername")
+        client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+        logger.warning(
+            "PROBE: slicer connected to un-proxied port %d from %s — this port may need proxying",
+            port,
+            client,
+        )
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except OSError:
+            pass
+
     def _log_activity(self, name: str, message: str) -> None:
         """Log activity via callback if configured."""
         if self.on_activity: