Browse Source

Fix VP bind server rejecting TLS connections on port 3002 (#559)

  BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1
  Mini / N1). The bind server only spoke plain TCP on both ports, so the
  TLS ClientHello was rejected as "invalid frame" and the slicer could
  never discover or connect to the virtual printer.

  Port 3002 now uses TLS (reusing the VP's existing certificate), port
  3000 remains plain TCP. Also updated proxy-mode to use TLSProxy for
  the port 3002 bind proxy instead of raw TCPProxy.
maziggy 2 months ago
parent
commit
e5b2cee2f1

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 
 ### Fixed
+- **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
 - **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
 - **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
 - **Virtual Printer Config Changes Ignored Until Toggle Off/On** — Changing a virtual printer's mode (e.g. proxy → archive), model, access code, bind IP, remote interface IP, or target printer via the UI updated the database but the running VP instance was never restarted. `sync_from_db()` skipped any VP whose ID was already in the running instances dict without checking if config had changed. Now compares critical fields between the running instance and DB record and restarts the VP when a difference is detected.

+ 42 - 8
backend/app/services/virtual_printer/bind_server.py

@@ -2,9 +2,12 @@
 
 Bambu slicers (BambuStudio, OrcaSlicer) connect to a printer on port 3000
 or 3002 to perform the "bind with access code" handshake before using
-MQTT/FTP. The port varies by slicer version, so we listen on both.
+MQTT/FTP.
 
-Protocol:
+Port 3000: plain TCP (legacy / some printer models).
+Port 3002: TLS (newer firmware, e.g. A1 Mini 01.07.x).
+
+Protocol (same on both ports, only transport differs):
   - Framing: 0xA5A5 + uint16_le(total_msg_size) + JSON payload + 0xA7A7
   - Slicer sends: {"login":{"command":"detect","sequence_id":"20000"}}
   - Printer replies: {"login":{"bind":"free","command":"detect","connect":"lan",
@@ -16,11 +19,15 @@ Protocol:
 import asyncio
 import json
 import logging
+import ssl
 import struct
+from pathlib import Path
 
 logger = logging.getLogger(__name__)
 
-BIND_PORTS = [3000, 3002]
+BIND_PORT_PLAIN = 3000
+BIND_PORT_TLS = 3002
+BIND_PORTS = [BIND_PORT_PLAIN, BIND_PORT_TLS]
 FRAME_HEADER = b"\xa5\xa5"
 FRAME_TRAILER = b"\xa7\xa7"
 HEADER_SIZE = 4  # 2 bytes magic + 2 bytes length
@@ -33,8 +40,8 @@ class BindServer:
     In server mode, Bambuddy IS the printer — it responds with its own
     identity so the slicer can discover and bind to it.
 
-    Different BambuStudio versions connect on different ports (3000 or 3002),
-    so we listen on both to ensure compatibility.
+    Port 3000 is plain TCP, port 3002 is TLS.  BambuStudio chooses which
+    port to use based on the printer model discovered via SSDP.
     """
 
     def __init__(
@@ -44,39 +51,66 @@ class BindServer:
         name: str,
         version: str = "01.00.00.00",
         bind_address: str = "0.0.0.0",  # nosec B104
+        cert_path: Path | None = None,
+        key_path: Path | None = None,
     ):
         self.serial = serial
         self.model = model
         self.name = name
         self.version = version
         self.bind_address = bind_address
+        self.cert_path = cert_path
+        self.key_path = key_path
 
         self._servers: list[asyncio.Server] = []
         self._running = False
 
+    def _create_tls_context(self) -> ssl.SSLContext | None:
+        """Create SSL context for the TLS bind port (3002)."""
+        if not self.cert_path or not self.key_path:
+            return None
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(str(self.cert_path), str(self.key_path))
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+
     async def start(self) -> None:
-        """Start the bind server on ports 3000 and 3002."""
+        """Start the bind server on ports 3000 (plain) and 3002 (TLS)."""
         if self._running:
             return
 
         self._running = True
+
+        tls_ctx = self._create_tls_context()
+        if not tls_ctx:
+            logger.warning("Bind server: no TLS cert provided, port %s will be plain TCP", BIND_PORT_TLS)
+
         logger.info(
-            "Starting bind server on ports %s (serial=%s, model=%s)",
+            "Starting bind server on ports %s (serial=%s, model=%s, tls=%s)",
             BIND_PORTS,
             self.serial,
             self.model,
+            tls_ctx is not None,
         )
 
         try:
             for port in BIND_PORTS:
+                use_tls = port == BIND_PORT_TLS and tls_ctx is not None
                 try:
                     server = await asyncio.start_server(
                         self._handle_client,
                         self.bind_address,
                         port,
+                        ssl=tls_ctx if use_tls else None,
                     )
                     self._servers.append(server)
-                    logger.info("Bind server listening on %s:%s", self.bind_address, port)
+                    logger.info(
+                        "Bind server listening on %s:%s (%s)",
+                        self.bind_address,
+                        port,
+                        "TLS" if use_tls else "plain",
+                    )
                 except OSError as e:
                     if e.errno == 98:
                         logger.warning("Bind server port %s already in use, skipping", port)

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

@@ -410,6 +410,8 @@ class VirtualPrinterInstance:
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             name=self.name,
             bind_address=bind_addr,
+            cert_path=cert_path,
+            key_path=key_path,
         )
         self._tasks.append(
             asyncio.create_task(

+ 24 - 12
backend/app/services/virtual_printer/tcp_proxy.py

@@ -346,7 +346,7 @@ class TLSProxy:
 class TCPProxy:
     """Raw TCP proxy that forwards data without TLS termination.
 
-    Used for protocols where the printer doesn't use TLS (e.g., port 3002
+    Used for protocols where the printer doesn't use TLS (e.g., port 3000
     binding/authentication protocol).
     """
 
@@ -1123,18 +1123,30 @@ class SlicerProxyManager:
             bind_address=self.bind_address,
         )
 
-        # Bind/auth proxy (ports 3000 + 3002) - raw TCP, no TLS
-        # Different BambuStudio versions use different ports
+        # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
         for bind_port in self.PRINTER_BIND_PORTS:
-            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,
-            )
+            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

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

@@ -1405,4 +1405,6 @@ class TestBindServer:
                 model="3DPrinter-X1-Carbon",
                 name="Bambuddy",
                 bind_address="192.168.1.50",
+                cert_path=Path("/tmp/cert.pem"),  # nosec B108
+                key_path=Path("/tmp/key.pem"),  # nosec B108
             )