mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
264 lines
10 KiB
Python
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
|