diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ddde6f9..a9d870790 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/backend/app/services/virtual_printer/bind_server.py b/backend/app/services/virtual_printer/bind_server.py index 22a0bcab1..9f9faaa08 100644 --- a/backend/app/services/virtual_printer/bind_server.py +++ b/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) diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 3c62fa281..ac8e90701 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/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( diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index 3ecd653bc..b640a4c76 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/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 diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 9d2c37425..a6d0b3db4 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/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 )