[Fix] Virtual Printer proxy: transparent TCP for X1C/X1 compatibility (#757)

The closed-source bambu_networking DLL validates 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 certificate, causing X1C/X1 prints to silently fail after verify_job.

  Switch to transparent TCP proxying for FTP, FileTransfer, Camera, and FTP
  data — only MQTT remains TLS-terminated (required for IP rewriting). The
  slicer now gets end-to-end TLS directly with the printer's real certificate.

  Changes:
  - SlicerProxyManager uses TCPProxy for FTP (990), FileTransfer (6000),
    Camera (322), and pre-listens on FTP data ports (50000-50100)
  - Only MQTT (8883) uses TLSProxy for IP rewriting
  - Remove debug logging from MQTT and FTP proxy code
  - Fix install.sh missing AmbientCapabilities=CAP_NET_BIND_SERVICE
  - Update module docstring, migration docs, README proxy description
  - Add tests verifying transparent proxy architecture
This commit is contained in:
maziggy
2026-03-19 15:43:11 +01:00
parent 94134e1861
commit 332a7c6ac8
6 changed files with 340 additions and 80 deletions
+244 -75
View File
@@ -1,15 +1,17 @@
"""TLS proxy for slicer-to-printer communication.
"""Proxy for slicer-to-printer communication.
This module provides a TLS terminating proxy that forwards data between
a slicer and a real Bambu printer, enabling remote printing over
any network connection.
This module provides both transparent TCP proxying and TLS-terminating
proxying for forwarding data between a slicer and a real Bambu printer,
enabling remote printing over any network connection.
Unlike a transparent TCP proxy, this terminates TLS on both ends:
- Slicer connects to Bambuddy using Bambuddy's certificate
- Bambuddy connects to printer using printer's certificate
- Data is decrypted, forwarded, and re-encrypted
Most protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —
raw bytes are forwarded without decryption, preserving end-to-end TLS
between slicer and printer. Only MQTT is TLS-terminated so Bambuddy can
rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
"""
# ruff: noqa: N801
import asyncio
import logging
import random
@@ -22,6 +24,44 @@ from pathlib import Path
logger = logging.getLogger(__name__)
class _SessionReuseSSLContext:
"""Proxy around SSLContext that injects a TLS session into wrap_bio().
vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
on FTP data channels — the data connection must reuse the TLS session from
the control channel. Without this, the printer rejects the data connection
with "522 SSL connection failed: session reuse required".
asyncio's open_connection() calls SSLContext.wrap_bio() internally but
doesn't expose a session parameter. This wrapper intercepts wrap_bio()
to inject the saved control-channel session, enabling session reuse.
"""
def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
object.__setattr__(self, "_ctx", ctx)
object.__setattr__(self, "_session", session)
def __getattr__(self, name: str) -> object:
return getattr(self._ctx, name)
def wrap_bio(
self,
incoming: ssl.MemoryBIO,
outgoing: ssl.MemoryBIO,
server_side: bool = False,
server_hostname: str | None = None,
**kwargs: object,
) -> ssl.SSLObject:
return self._ctx.wrap_bio(
incoming,
outgoing,
server_side=server_side,
server_hostname=server_hostname,
session=self._session,
**kwargs,
)
def detect_port_redirect(port: int) -> int | None:
"""Detect if iptables redirects a port to another port.
@@ -515,7 +555,6 @@ class TLSProxy:
# Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
# to prevent the slicer from bypassing the proxy.
if do_rewrite:
original_len = len(data)
extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
data, rewrite_buffer = self._rewrite_mqtt_ip(
data,
@@ -524,13 +563,20 @@ class TLSProxy:
rewrite_buffer,
extra_replacements=extra,
)
if not rewrite_logged and data and len(data) != original_len:
logger.info(
"%s proxy IP rewrite active: %s → %s",
self.name,
self._rewrite_old.decode(),
self._rewrite_new.decode(),
)
if not rewrite_logged and data:
if self._rewrite_old in data:
logger.warning(
"%s proxy IP rewrite FAILED — %s still present after rewrite!",
self.name,
self._rewrite_old.decode(),
)
else:
logger.info(
"%s proxy IP rewrite active: %s → %s",
self.name,
self._rewrite_old.decode(),
self._rewrite_new.decode(),
)
rewrite_logged = True
if not data:
continue # All data buffered, waiting for more
@@ -835,11 +881,21 @@ class FTPTLSProxy(TLSProxy):
await client_writer.wait_closed()
return
# Capture the TLS session from the control channel for data channel
# reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
# must present the same TLS session as the control channel.
ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
if ctrl_tls_session:
logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
# Track data channel protection level per session.
# PROT C = cleartext data, PROT P = TLS data.
# Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
# If the slicer sends PROT P, we switch to TLS for data connections.
session_state: dict[str, str] = {"prot": "C"}
session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
if ctrl_tls_session:
session_state["tls_session"] = ctrl_tls_session
# Client→Printer: intercept EPSV and replace with PASV
# EPSV responses only contain a port (no IP), so the slicer reuses
@@ -895,7 +951,7 @@ class FTPTLSProxy(TLSProxy):
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
direction: str,
session_state: dict[str, str],
session_state: dict[str, str | ssl.SSLSession],
) -> None:
"""Forward FTP client commands, replacing EPSV with PASV.
@@ -927,13 +983,8 @@ class FTPTLSProxy(TLSProxy):
cmd_upper = line.strip().upper()
# Replace EPSV with PASV so response includes an IP
if cmd_upper == b"EPSV":
line = b"PASV"
logger.info("FTP command rewrite: EPSV → PASV")
# Track PROT level for data channel encryption
elif cmd_upper == b"PROT P":
if cmd_upper == b"PROT P":
session_state["prot"] = "P"
logger.info("FTP data protection: PROT P (TLS)")
elif cmd_upper == b"PROT C":
@@ -972,7 +1023,7 @@ class FTPTLSProxy(TLSProxy):
writer: asyncio.StreamWriter,
direction: str,
local_ip: str,
session_state: dict[str, str],
session_state: dict[str, str | ssl.SSLSession],
) -> None:
"""Forward FTP control channel responses, rewriting PASV/EPSV.
@@ -1027,7 +1078,9 @@ class FTPTLSProxy(TLSProxy):
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
async def _maybe_rewrite_pasv(
self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
) -> bytes:
"""Rewrite PASV/EPSV response to point to a local data proxy."""
try:
text = line.decode("utf-8")
@@ -1076,7 +1129,9 @@ class FTPTLSProxy(TLSProxy):
return line
async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
async def _create_data_proxy(
self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
) -> int | None:
"""Create a one-shot proxy for an FTP data connection.
Prefers the printer's original passive port so the port number stays
@@ -1102,10 +1157,13 @@ class FTPTLSProxy(TLSProxy):
"TLS" if use_tls else "cleartext",
)
# Get control channel TLS session for data channel reuse
tls_session = session_state.get("tls_session") if use_tls else None
# Try the printer's original port first — this ensures the port
# matches even when bounce protection or iptables REDIRECT is in play.
try:
await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
logger.info("FTP data proxy: using printer's port %s", printer_port)
return printer_port
except OSError as e:
@@ -1118,7 +1176,7 @@ class FTPTLSProxy(TLSProxy):
for _attempt in range(10):
port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
try:
await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
logger.info("FTP data proxy: using random port %s", port)
return port
except OSError:
@@ -1127,7 +1185,14 @@ class FTPTLSProxy(TLSProxy):
logger.error("Failed to bind FTP data proxy port after 10 attempts")
return None
async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
async def _start_data_proxy_server(
self,
port: int,
printer_ip: str,
printer_port: int,
use_tls: bool,
tls_session: ssl.SSLSession | None = None,
) -> None:
"""Start a one-shot server for one FTP data connection.
When the slicer connects, immediately connects to the printer's data
@@ -1154,7 +1219,18 @@ class FTPTLSProxy(TLSProxy):
# Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
# the data channel even after sending PROT P.
# Printer side: TLS if PROT P, cleartext if PROT C.
client_ssl = self._client_ssl_context if use_tls else None
# For TLS data connections, wrap the SSL context to reuse the
# control channel's TLS session if available. vsFTPd (X1C) requires
# require_ssl_reuse — without this, data connections are rejected
# with "522 SSL connection failed: session reuse required".
if use_tls and tls_session:
client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
else:
client_ssl = self._client_ssl_context if use_tls else None
# Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
# the data channel even after PROT P (confirmed for both H2D and X1C).
printer_mode = "TLS" if use_tls else "cleartext"
async def handle_data(
@@ -1217,27 +1293,84 @@ class FTPTLSProxy(TLSProxy):
pass
# Flush buffered slicer data to printer
logger.info(
"FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
port,
len(slicer_buffer),
slicer_eof,
)
if slicer_buffer:
printer_writer.write(bytes(slicer_buffer))
await printer_writer.drain()
if slicer_eof:
# Slicer already closed (zero-byte upload like verify_job).
# Close the printer write side to signal upload complete.
if printer_writer.can_write_eof():
printer_writer.write_eof()
else:
# Continue bidirectional forwarding
c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
# Forward remaining slicer data to printer, then close the
# printer side to signal upload complete.
#
# Bambu Studio does NOT close the FTP data channel after sending
# STOR data — it keeps the connection open and waits for the
# printer to close its side + send 226 on the control channel.
# A naive bidirectional proxy deadlocks here because the proxy
# waits for the slicer EOF that never comes.
#
# Fix: read slicer data with an idle timeout. Once data has been
# received and the slicer goes quiet, close the printer side so
# the printer can send 226. For RETR (download), the printer
# sends data and closes — the slicer reads until EOF — so this
# unidirectional approach works for both directions.
total_c2p = len(slicer_buffer)
if not slicer_eof:
# Read remaining slicer data with idle detection.
# Must be short — Bambu Studio expects 226 almost instantly
# after sending data. Too long and the slicer times out.
idle_timeout = 0.3
while True:
try:
await task
except asyncio.CancelledError:
pass # Expected when other data direction closes
chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
except TimeoutError:
if total_c2p > 0:
# Slicer sent data then went idle — upload done
logger.debug(
"FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
port,
total_c2p,
)
break
continue # No data yet, keep waiting
if not chunk:
break # Slicer closed
printer_writer.write(chunk)
await printer_writer.drain()
total_c2p += len(chunk)
logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
# Close printer side to signal upload complete.
# For TLS, close() sends close_notify which the printer treats
# as end-of-data. The printer then sends 226 on the control
# channel. For RETR, this is a no-op since the printer closes
# first and we'd have exited the loop above via EOF.
try:
printer_writer.close()
await printer_writer.wait_closed()
except OSError:
pass
# Wait for 226 response to propagate through the FTP control
# channel before closing the slicer's data channel.
#
# Without this delay, the data channel FIN arrives at the
# slicer before the 226 response on the control channel.
# BambuStudio reacts to the data channel FIN within <1ms
# by sending QUIT + closing the control channel — before
# 226 arrives (~2-3ms network RTT). This causes verify_job
# to be treated as failed and shows the login modal.
#
# In a direct connection, the printer sends 226 AND closes
# the data channel simultaneously, so the slicer gets both
# at once. The delay here emulates that timing.
if total_c2p > 0:
await asyncio.sleep(0.5)
except Exception as e:
logger.error("FTP data proxy port %s: error: %s", port, e)
finally:
@@ -1255,8 +1388,7 @@ class FTPTLSProxy(TLSProxy):
"0.0.0.0", # nosec B104
port,
# No TLS on slicer side — Bambu Studio doesn't do TLS on data
# channel even after PROT P. The proxy terminates TLS only on
# the printer side (inside handle_data).
# channel even after PROT P (confirmed by connection hang test).
)
server_holder.append(server)
self._data_servers.append(server)
@@ -1325,21 +1457,33 @@ class SlicerProxyManager:
self.bind_address = bind_address
self.bind_identity = bind_identity
self._ftp_proxy: TLSProxy | None = None
self._ftp_proxy: TCPProxy | None = None
self._mqtt_proxy: TLSProxy | None = None
self._file_transfer_proxy: TLSProxy | None = None
self._rtsp_proxy: TLSProxy | None = None
self._file_transfer_proxy: TCPProxy | None = None
self._rtsp_proxy: TCPProxy | None = None
self._bind_proxies: list[TCPProxy] = []
self._bind_server = None
self._tasks: list[asyncio.Task] = []
async def start(self) -> None:
"""Start FTP and MQTT TLS proxies."""
logger.info("Starting slicer TLS proxy to %s", self.target_host)
# FTP passive data port range — Bambu printers typically use ports in
# this range for EPSV/PASV data connections. We pre-listen on all of
# them so EPSV works transparently without decrypting FTP control.
FTP_DATA_PORT_MIN = 50000
FTP_DATA_PORT_MAX = 50100
# Detect iptables port redirect (e.g. if an external redirect exists).
# If active, connections get intercepted by iptables PREROUTING
# and sent to the redirect target — our socket never sees them.
async def start(self) -> None:
"""Start proxy services.
Uses transparent TCP proxying for most protocols (FTP, FileTransfer,
Camera) — raw bytes are forwarded without TLS termination, so the
slicer gets the printer's real TLS certificate end-to-end.
Only MQTT is TLS-terminated because we must decrypt the payload to
rewrite the printer's real IP with the proxy's bind IP.
"""
logger.info("Starting slicer proxy to %s (transparent mode)", self.target_host)
# Detect iptables port redirect for FTP
ftp_listen_port = self.LOCAL_FTP_PORT
redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
if redirect_target:
@@ -1351,19 +1495,35 @@ class SlicerProxyManager:
)
ftp_listen_port = redirect_target
# Create FTP proxy with PASV/EPSV awareness for data connections
self._ftp_proxy = FTPTLSProxy(
# FTP control — raw TCP pass-through (end-to-end TLS with printer)
self._ftp_proxy = TCPProxy(
name="FTP",
listen_port=ftp_listen_port,
target_host=self.target_host,
target_port=self.PRINTER_FTP_PORT,
server_cert_path=self.cert_path,
server_key_path=self.key_path,
on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
bind_address=self.bind_address,
)
# FTP data ports — pre-listen on the entire passive port range.
# Since FTP control is encrypted end-to-end, we can't read EPSV
# responses to know which port the printer chose. Instead, we
# listen on every port in the range and forward to the same port
# on the printer. The slicer connects to bind_ip:PORT (from EPSV)
# and we transparently relay to printer_ip:PORT.
self._ftp_data_proxies: list[TCPProxy] = []
for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):
dp = TCPProxy(
name=f"FTP-Data-{port}",
listen_port=port,
target_host=self.target_host,
target_port=port,
bind_address=self.bind_address,
)
self._ftp_data_proxies.append(dp)
# MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)
self._mqtt_proxy = TLSProxy(
name="MQTT",
listen_port=self.LOCAL_MQTT_PORT,
@@ -1377,30 +1537,23 @@ class SlicerProxyManager:
rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
)
# File transfer proxy — port 6000 (TLS)
# BambuStudio connects here for verify_job and actual file uploads.
self._file_transfer_proxy = TLSProxy(
# File transfer — raw TCP pass-through (port 6000)
self._file_transfer_proxy = TCPProxy(
name="FileTransfer",
listen_port=self.PRINTER_FILE_TRANSFER_PORT,
target_host=self.target_host,
target_port=self.PRINTER_FILE_TRANSFER_PORT,
server_cert_path=self.cert_path,
server_key_path=self.key_path,
on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
bind_address=self.bind_address,
)
# RTSP camera proxy — port 322 (TLS)
# X1/H2/P2 series use RTSP on port 322 for camera streaming.
# A1/P1 series use port 6000 (already proxied via file transfer proxy).
self._rtsp_proxy = TLSProxy(
# RTSP camera — raw TCP pass-through (port 322)
self._rtsp_proxy = TCPProxy(
name="RTSP",
listen_port=self.PRINTER_RTSP_PORT,
target_host=self.target_host,
target_port=self.PRINTER_RTSP_PORT,
server_cert_path=self.cert_path,
server_key_path=self.key_path,
on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
bind_address=self.bind_address,
@@ -1450,7 +1603,7 @@ class SlicerProxyManager:
self._bind_proxies.append(proxy)
# Start as background tasks
async def run_with_logging(proxy: TLSProxy) -> None:
async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
try:
await proxy.start()
except Exception as e:
@@ -1488,8 +1641,20 @@ class SlicerProxyManager:
name=f"slicer_proxy_bind_{bp.listen_port}",
)
)
# FTP data port proxies (50000-50100)
for dp in self._ftp_data_proxies:
self._tasks.append(
asyncio.create_task(
run_with_logging(dp),
name=f"slicer_proxy_ftp_data_{dp.listen_port}",
)
)
logger.info("Slicer TLS proxy started for %s", self.target_host)
logger.info(
"Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
self.target_host,
len(self._ftp_data_proxies),
)
# Wait for tasks to complete (they run until cancelled)
# This keeps the start() coroutine alive so the parent task doesn't complete
@@ -1527,6 +1692,10 @@ class SlicerProxyManager:
await bp.stop()
self._bind_proxies = []
for dp in self._ftp_data_proxies:
await dp.stop()
self._ftp_data_proxies = []
# Cancel tasks
for task in self._tasks:
task.cancel()