bambuddy/backend/app/services/virtual_printer/bind_server.py
maziggy 9cc4b6aa60 fix(virtual-printer): #1610 add Bambu cipher pin to every slicer-facing TLS context
The #620 patch fixed the OpenSSL-3.x-strips-plain-RSA-AES-GCM cipher
  mismatch on the printer-facing TLSProxy client context. The same fix
  was never applied to the four other slicer-facing TLS contexts. On
  hardened distros (Fedora / RHEL with update-crypto-policies, hardened
  Alpine builds) where the system narrows DEFAULT to forward-secrecy
  only, the slicer's ClientHello finds no overlap with what Bambuddy
  offers and the handshake aborts with the slicer reporting code=-1
  before any application data flows. The reporter pinpointed the missing
  set_ciphers call in bind_server.py against the #620 lineage; the
  audit-wide sweep here extends the same fix to mqtt_server.py,
  tcp_proxy._create_server_ssl_context (the missing other half of #620),
  and ftp_server.py.

  For the three new contexts (bind / mqtt / proxy-server) the cipher
  string is DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256 — verbatim match
  with the #620 client-side fix. For FTPS the original HIGH baseline is
  kept (HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4) so the
  cipher set stays a strict superset of what shipped before — HIGH
  offers ~58 suites DEFAULT doesn't (CCM / ARIA / CAMELLIA / DSS) that
  no Bambu slicer is known to pick, but narrowing a compat surface
  without proof would violate the existing don't-remove-compat-pinning
  rule. TLS version pins (TLSv1_2 minimum across all four, TLSv1_2 max
  on FTPS for the BambuStudio PSK-reuse compat) and verify-mode settings
  are unchanged — only the cipher list is widened.
2026-06-03 10:04:37 +02:00

264 lines
10 KiB
Python

"""Bind/detect server for virtual printer discovery (ports 3000 + 3002).
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.
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",
"dev_cap":1,"id":"<serial>","model":"<model>","name":"<name>",
"sequence_id":<int>,"version":"<firmware>"}}
- Connection closes after one exchange.
"""
import asyncio
import json
import logging
import ssl
import struct
from pathlib import Path
logger = logging.getLogger(__name__)
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
TRAILER_SIZE = 2
class BindServer:
"""Responds to slicer bind/detect requests on ports 3000 and 3002.
In server mode, Bambuddy IS the printer — it responds with its own
identity so the slicer can discover and bind to it.
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__(
self,
serial: str,
model: str,
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
# Set after at least one bind port is listening — see ftp_server.py
# for rationale. Bind server is best-effort across BIND_PORTS, so
# "ready" means "at least one port bound", matching the existing
# serve_forever path.
self.ready = asyncio.Event()
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
# Match real Bambu printer cipher behaviour: include the plain-RSA
# AES-GCM suites the slicer's bind/connect path expects. On hardened
# distros (Fedora / RHEL with `update-crypto-policies`, hardened Alpine
# builds) the OpenSSL `DEFAULT` list strips these suites, leaving no
# overlap with the slicer's ClientHello and producing `code=-1` on the
# slicer side (#1610). Same fix the #620 client-side patch applied to
# `tcp_proxy.py::_create_client_ssl_context`; the bind-server / server
# side needs it too.
ctx.set_ciphers("DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256")
ctx.verify_mode = ssl.CERT_NONE
return ctx
async def start(self) -> None:
"""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, 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 (%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)
elif e.errno == 13:
logger.warning("Bind server: cannot bind to port %s (permission denied), skipping", port)
else:
logger.warning("Bind server: failed to bind port %s: %s", port, e)
if not self._servers:
logger.error("Bind server: could not bind to any port")
return
self.ready.set()
# Serve all successfully bound ports
await asyncio.gather(*(s.serve_forever() for s in self._servers))
except asyncio.CancelledError:
logger.debug("Bind server task cancelled")
except Exception as e:
logger.error("Bind server error: %s", e)
finally:
await self.stop()
async def stop(self) -> None:
"""Stop the bind server."""
logger.info("Stopping bind server")
self._running = False
self.ready.clear()
for server in self._servers:
try:
server.close()
await server.wait_closed()
except OSError as e:
logger.debug("Error closing bind server: %s", e)
self._servers = []
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle a single bind/detect request from a slicer."""
peername = writer.get_extra_info("peername")
client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
logger.info("Bind server: client connected from %s", client_id)
try:
# Read the framed message (timeout after 10s)
data = await asyncio.wait_for(reader.read(4096), timeout=10.0)
if not data:
return
# Parse the request
request = self._parse_frame(data)
if request is None:
logger.warning("Bind server: invalid frame from %s", client_id)
return
logger.info("Bind server: received from %s: %s", client_id, request)
# Check if this is a detect command
login = request.get("login", {})
if not isinstance(login, dict) or login.get("command") != "detect":
logger.warning("Bind server: unexpected command from %s: %s", client_id, request)
return
# Build response. `sequence_id` is an INTEGER counter chosen by
# the printer side (not an echo of the slicer's string seq_id).
# The protocol docstring at the top of this file documents the
# asymmetry: slicer sends `"20000"` (string), printer replies
# with an int. The hardcoded 3021 mirrors real-firmware-captured
# value; an earlier audit suggesting we echo the slicer's seq_id
# was wrong and would have broken slicers that validate the
# type (int vs string).
response = {
"login": {
"bind": "free",
"command": "detect",
"connect": "lan",
"dev_cap": 1,
"id": self.serial,
"model": self.model,
"name": self.name,
"sequence_id": 3021,
"version": self.version,
}
}
frame = self._build_frame(response)
writer.write(frame)
await writer.drain()
logger.info("Bind server: sent detect response to %s (serial=%s)", client_id, self.serial)
except TimeoutError:
logger.debug("Bind server: timeout waiting for data from %s", client_id)
except Exception as e:
logger.error("Bind server: error handling %s: %s", client_id, e)
finally:
try:
writer.close()
await writer.wait_closed()
except OSError:
pass
logger.debug("Bind server: client %s disconnected", client_id)
def _parse_frame(self, data: bytes) -> dict | None:
"""Parse a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
if len(data) < HEADER_SIZE + TRAILER_SIZE:
return None
if data[:2] != FRAME_HEADER:
return None
if data[-2:] != FRAME_TRAILER:
return None
# Length field is total message size (header + json + trailer)
total_len = struct.unpack_from("<H", data, 2)[0]
if total_len != len(data):
logger.debug("Bind frame length mismatch: header says %d, got %d", total_len, len(data))
# JSON payload is between header and trailer
json_bytes = data[HEADER_SIZE:-TRAILER_SIZE]
try:
return json.loads(json_bytes)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning("Bind server: failed to parse JSON: %s", e)
return None
def _build_frame(self, payload: dict) -> bytes:
"""Build a framed message: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
total_len = HEADER_SIZE + len(json_bytes) + TRAILER_SIZE
header = FRAME_HEADER + struct.pack("<H", total_len)
return header + json_bytes + FRAME_TRAILER