[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
+3 -1
View File
@@ -20,8 +20,10 @@ All notable changes to Bambuddy will be documented in this file.
- **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model. - **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.
- **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH. - **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH.
- **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user. - **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding TLS proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298. - **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
- **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the 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 "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.
- **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata. - **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.
- **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.
### Added ### Added
- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper. - **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
+2 -2
View File
@@ -39,11 +39,11 @@
**Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer: **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
- 🔒 **TLS-encrypted control channels** — MQTT and FTP control fully encrypted - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/)) - 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
- 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
- 🔑 **Uses printer's access code** — No additional credentials needed - 🔑 **Uses printer's access code** — No additional credentials needed
- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
Perfect for remote print farms, traveling makers, or accessing your home printer from work. Perfect for remote print farms, traveling makers, or accessing your home printer from work.
+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 This module provides both transparent TCP proxying and TLS-terminating
a slicer and a real Bambu printer, enabling remote printing over proxying for forwarding data between a slicer and a real Bambu printer,
any network connection. enabling remote printing over any network connection.
Unlike a transparent TCP proxy, this terminates TLS on both ends: Most protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —
- Slicer connects to Bambuddy using Bambuddy's certificate raw bytes are forwarded without decryption, preserving end-to-end TLS
- Bambuddy connects to printer using printer's certificate between slicer and printer. Only MQTT is TLS-terminated so Bambuddy can
- Data is decrypted, forwarded, and re-encrypted rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
""" """
# ruff: noqa: N801
import asyncio import asyncio
import logging import logging
import random import random
@@ -22,6 +24,44 @@ from pathlib import Path
logger = logging.getLogger(__name__) 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: def detect_port_redirect(port: int) -> int | None:
"""Detect if iptables redirects a port to another port. """Detect if iptables redirects a port to another port.
@@ -515,7 +555,6 @@ class TLSProxy:
# Rewrite printer IP → proxy IP in MQTT PUBLISH payloads # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
# to prevent the slicer from bypassing the proxy. # to prevent the slicer from bypassing the proxy.
if do_rewrite: if do_rewrite:
original_len = len(data)
extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
data, rewrite_buffer = self._rewrite_mqtt_ip( data, rewrite_buffer = self._rewrite_mqtt_ip(
data, data,
@@ -524,13 +563,20 @@ class TLSProxy:
rewrite_buffer, rewrite_buffer,
extra_replacements=extra, extra_replacements=extra,
) )
if not rewrite_logged and data and len(data) != original_len: if not rewrite_logged and data:
logger.info( if self._rewrite_old in data:
"%s proxy IP rewrite active: %s → %s", logger.warning(
self.name, "%s proxy IP rewrite FAILED — %s still present after rewrite!",
self._rewrite_old.decode(), self.name,
self._rewrite_new.decode(), 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 rewrite_logged = True
if not data: if not data:
continue # All data buffered, waiting for more continue # All data buffered, waiting for more
@@ -835,11 +881,21 @@ class FTPTLSProxy(TLSProxy):
await client_writer.wait_closed() await client_writer.wait_closed()
return 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. # Track data channel protection level per session.
# PROT C = cleartext data, PROT P = TLS data. # PROT C = cleartext data, PROT P = TLS data.
# Default to cleartext — many Bambu printers (A1, H2D) use PROT C. # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
# If the slicer sends PROT P, we switch to TLS for data connections. # 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 # Client→Printer: intercept EPSV and replace with PASV
# EPSV responses only contain a port (no IP), so the slicer reuses # EPSV responses only contain a port (no IP), so the slicer reuses
@@ -895,7 +951,7 @@ class FTPTLSProxy(TLSProxy):
reader: asyncio.StreamReader, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
direction: str, direction: str,
session_state: dict[str, str], session_state: dict[str, str | ssl.SSLSession],
) -> None: ) -> None:
"""Forward FTP client commands, replacing EPSV with PASV. """Forward FTP client commands, replacing EPSV with PASV.
@@ -927,13 +983,8 @@ class FTPTLSProxy(TLSProxy):
cmd_upper = line.strip().upper() 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 # Track PROT level for data channel encryption
elif cmd_upper == b"PROT P": if cmd_upper == b"PROT P":
session_state["prot"] = "P" session_state["prot"] = "P"
logger.info("FTP data protection: PROT P (TLS)") logger.info("FTP data protection: PROT P (TLS)")
elif cmd_upper == b"PROT C": elif cmd_upper == b"PROT C":
@@ -972,7 +1023,7 @@ class FTPTLSProxy(TLSProxy):
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
direction: str, direction: str,
local_ip: str, local_ip: str,
session_state: dict[str, str], session_state: dict[str, str | ssl.SSLSession],
) -> None: ) -> None:
"""Forward FTP control channel responses, rewriting PASV/EPSV. """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) 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.""" """Rewrite PASV/EPSV response to point to a local data proxy."""
try: try:
text = line.decode("utf-8") text = line.decode("utf-8")
@@ -1076,7 +1129,9 @@ class FTPTLSProxy(TLSProxy):
return line 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. """Create a one-shot proxy for an FTP data connection.
Prefers the printer's original passive port so the port number stays 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", "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 # Try the printer's original port first — this ensures the port
# matches even when bounce protection or iptables REDIRECT is in play. # matches even when bounce protection or iptables REDIRECT is in play.
try: 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) logger.info("FTP data proxy: using printer's port %s", printer_port)
return printer_port return printer_port
except OSError as e: except OSError as e:
@@ -1118,7 +1176,7 @@ class FTPTLSProxy(TLSProxy):
for _attempt in range(10): for _attempt in range(10):
port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX) port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
try: 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) logger.info("FTP data proxy: using random port %s", port)
return port return port
except OSError: except OSError:
@@ -1127,7 +1185,14 @@ class FTPTLSProxy(TLSProxy):
logger.error("Failed to bind FTP data proxy port after 10 attempts") logger.error("Failed to bind FTP data proxy port after 10 attempts")
return None 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. """Start a one-shot server for one FTP data connection.
When the slicer connects, immediately connects to the printer's data 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 # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
# the data channel even after sending PROT P. # the data channel even after sending PROT P.
# Printer side: TLS if PROT P, cleartext if PROT C. # 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" printer_mode = "TLS" if use_tls else "cleartext"
async def handle_data( async def handle_data(
@@ -1217,27 +1293,84 @@ class FTPTLSProxy(TLSProxy):
pass pass
# Flush buffered slicer data to printer # 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: if slicer_buffer:
printer_writer.write(bytes(slicer_buffer)) printer_writer.write(bytes(slicer_buffer))
await printer_writer.drain() await printer_writer.drain()
if slicer_eof: # Forward remaining slicer data to printer, then close the
# Slicer already closed (zero-byte upload like verify_job). # printer side to signal upload complete.
# Close the printer write side to signal upload complete. #
if printer_writer.can_write_eof(): # Bambu Studio does NOT close the FTP data channel after sending
printer_writer.write_eof() # STOR data — it keeps the connection open and waits for the
else: # printer to close its side + send 226 on the control channel.
# Continue bidirectional forwarding # A naive bidirectional proxy deadlocks here because the proxy
c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p")) # waits for the slicer EOF that never comes.
p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c")) #
# Fix: read slicer data with an idle timeout. Once data has been
done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED) # received and the slicer goes quiet, close the printer side so
for task in pending: # the printer can send 226. For RETR (download), the printer
task.cancel() # 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: try:
await task chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
except asyncio.CancelledError: except TimeoutError:
pass # Expected when other data direction closes 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: except Exception as e:
logger.error("FTP data proxy port %s: error: %s", port, e) logger.error("FTP data proxy port %s: error: %s", port, e)
finally: finally:
@@ -1255,8 +1388,7 @@ class FTPTLSProxy(TLSProxy):
"0.0.0.0", # nosec B104 "0.0.0.0", # nosec B104
port, port,
# No TLS on slicer side — Bambu Studio doesn't do TLS on data # No TLS on slicer side — Bambu Studio doesn't do TLS on data
# channel even after PROT P. The proxy terminates TLS only on # channel even after PROT P (confirmed by connection hang test).
# the printer side (inside handle_data).
) )
server_holder.append(server) server_holder.append(server)
self._data_servers.append(server) self._data_servers.append(server)
@@ -1325,21 +1457,33 @@ class SlicerProxyManager:
self.bind_address = bind_address self.bind_address = bind_address
self.bind_identity = bind_identity self.bind_identity = bind_identity
self._ftp_proxy: TLSProxy | None = None self._ftp_proxy: TCPProxy | None = None
self._mqtt_proxy: TLSProxy | None = None self._mqtt_proxy: TLSProxy | None = None
self._file_transfer_proxy: TLSProxy | None = None self._file_transfer_proxy: TCPProxy | None = None
self._rtsp_proxy: TLSProxy | None = None self._rtsp_proxy: TCPProxy | None = None
self._bind_proxies: list[TCPProxy] = [] self._bind_proxies: list[TCPProxy] = []
self._bind_server = None self._bind_server = None
self._tasks: list[asyncio.Task] = [] self._tasks: list[asyncio.Task] = []
async def start(self) -> None: # FTP passive data port range — Bambu printers typically use ports in
"""Start FTP and MQTT TLS proxies.""" # this range for EPSV/PASV data connections. We pre-listen on all of
logger.info("Starting slicer TLS proxy to %s", self.target_host) # 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). async def start(self) -> None:
# If active, connections get intercepted by iptables PREROUTING """Start proxy services.
# and sent to the redirect target — our socket never sees them.
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 ftp_listen_port = self.LOCAL_FTP_PORT
redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT) redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
if redirect_target: if redirect_target:
@@ -1351,19 +1495,35 @@ class SlicerProxyManager:
) )
ftp_listen_port = redirect_target ftp_listen_port = redirect_target
# Create FTP proxy with PASV/EPSV awareness for data connections # FTP control — raw TCP pass-through (end-to-end TLS with printer)
self._ftp_proxy = FTPTLSProxy( self._ftp_proxy = TCPProxy(
name="FTP", name="FTP",
listen_port=ftp_listen_port, listen_port=ftp_listen_port,
target_host=self.target_host, target_host=self.target_host,
target_port=self.PRINTER_FTP_PORT, 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_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"), on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
bind_address=self.bind_address, 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( self._mqtt_proxy = TLSProxy(
name="MQTT", name="MQTT",
listen_port=self.LOCAL_MQTT_PORT, 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, rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
) )
# File transfer proxy — port 6000 (TLS) # File transfer — raw TCP pass-through (port 6000)
# BambuStudio connects here for verify_job and actual file uploads. self._file_transfer_proxy = TCPProxy(
self._file_transfer_proxy = TLSProxy(
name="FileTransfer", name="FileTransfer",
listen_port=self.PRINTER_FILE_TRANSFER_PORT, listen_port=self.PRINTER_FILE_TRANSFER_PORT,
target_host=self.target_host, target_host=self.target_host,
target_port=self.PRINTER_FILE_TRANSFER_PORT, 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_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"), on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
bind_address=self.bind_address, bind_address=self.bind_address,
) )
# RTSP camera proxy — port 322 (TLS) # RTSP camera — raw TCP pass-through (port 322)
# X1/H2/P2 series use RTSP on port 322 for camera streaming. self._rtsp_proxy = TCPProxy(
# A1/P1 series use port 6000 (already proxied via file transfer proxy).
self._rtsp_proxy = TLSProxy(
name="RTSP", name="RTSP",
listen_port=self.PRINTER_RTSP_PORT, listen_port=self.PRINTER_RTSP_PORT,
target_host=self.target_host, target_host=self.target_host,
target_port=self.PRINTER_RTSP_PORT, 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_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"), on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
bind_address=self.bind_address, bind_address=self.bind_address,
@@ -1450,7 +1603,7 @@ class SlicerProxyManager:
self._bind_proxies.append(proxy) self._bind_proxies.append(proxy)
# Start as background tasks # Start as background tasks
async def run_with_logging(proxy: TLSProxy) -> None: async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
try: try:
await proxy.start() await proxy.start()
except Exception as e: except Exception as e:
@@ -1488,8 +1641,20 @@ class SlicerProxyManager:
name=f"slicer_proxy_bind_{bp.listen_port}", 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) # Wait for tasks to complete (they run until cancelled)
# This keeps the start() coroutine alive so the parent task doesn't complete # This keeps the start() coroutine alive so the parent task doesn't complete
@@ -1527,6 +1692,10 @@ class SlicerProxyManager:
await bp.stop() await bp.stop()
self._bind_proxies = [] self._bind_proxies = []
for dp in self._ftp_data_proxies:
await dp.stop()
self._ftp_data_proxies = []
# Cancel tasks # Cancel tasks
for task in self._tasks: for task in self._tasks:
task.cancel() task.cancel()
@@ -1093,6 +1093,9 @@ class TestSlicerProxyManager:
assert proxy_manager.PRINTER_RTSP_PORT == 322 assert proxy_manager.PRINTER_RTSP_PORT == 322
# Bind ports: both 3000 and 3002 for slicer compatibility # Bind ports: both 3000 and 3002 for slicer compatibility
assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002] assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
# FTP data port range for transparent EPSV proxying
assert proxy_manager.FTP_DATA_PORT_MIN == 50000
assert proxy_manager.FTP_DATA_PORT_MAX == 50100
def test_proxy_manager_stores_target_host(self, proxy_manager): def test_proxy_manager_stores_target_host(self, proxy_manager):
"""Verify proxy manager stores target host.""" """Verify proxy manager stores target host."""
@@ -1106,6 +1109,89 @@ class TestSlicerProxyManager:
assert status["ftp_connections"] == 0 assert status["ftp_connections"] == 0
assert status["mqtt_connections"] == 0 assert status["mqtt_connections"] == 0
@pytest.mark.asyncio
async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
"""Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
The transparent proxy architecture preserves end-to-end TLS between
slicer and printer for all protocols except MQTT, which must be
TLS-terminated to rewrite the printer's IP in MQTT payloads.
"""
from unittest.mock import AsyncMock, patch
from backend.app.services.virtual_printer.tcp_proxy import (
SlicerProxyManager,
TCPProxy,
TLSProxy,
)
cert_path = tmp_path / "cert.pem"
key_path = tmp_path / "key.pem"
cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
mgr = SlicerProxyManager(
target_host="192.168.1.100",
cert_path=cert_path,
key_path=key_path,
bind_address="10.0.0.1",
)
# Mock asyncio.create_task and asyncio.gather to prevent actual server start
with (
patch("asyncio.create_task") as mock_create_task,
patch("asyncio.gather", new_callable=AsyncMock),
patch.object(SlicerProxyManager, "_log_activity"),
):
mock_create_task.return_value = MagicMock()
# start() will create proxies then try to gather tasks — we just
# need to verify the proxy types after creation.
# Trigger start but let gather return immediately.
await mgr.start()
# FTP, FileTransfer, RTSP should be TCPProxy (transparent)
assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
# MQTT should be TLSProxy (TLS-terminated for IP rewriting)
assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
# FTP data ports should be pre-created as TCPProxy instances
assert len(mgr._ftp_data_proxies) == 101 # 50000-50100 inclusive
for dp in mgr._ftp_data_proxies:
assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
# Verify FTP data proxies target the same port on the printer
first_dp = mgr._ftp_data_proxies[0]
assert first_dp.listen_port == 50000
assert first_dp.target_port == 50000
assert first_dp.target_host == "192.168.1.100"
last_dp = mgr._ftp_data_proxies[-1]
assert last_dp.listen_port == 50100
assert last_dp.target_port == 50100
def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
"""Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
cert_path = tmp_path / "cert.pem"
key_path = tmp_path / "key.pem"
cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
mgr = SlicerProxyManager(
target_host="192.168.1.100",
cert_path=cert_path,
key_path=key_path,
bind_address="10.0.0.1",
)
# Before start, proxies are None — verify constructor stores rewrite config
assert mgr.bind_address == "10.0.0.1"
assert mgr.target_host == "192.168.1.100"
class TestSSDPProxy: class TestSSDPProxy:
"""Tests for SSDPProxy (cross-network SSDP relay).""" """Tests for SSDPProxy (cross-network SSDP relay)."""
+2 -2
View File
@@ -22,8 +22,8 @@ Proxy mode now requires two additional ports:
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
|------|----------|---------| |------|----------|---------|
| 6000 | TCP/TLS | File transfer tunnel (verify_job + print uploads) | | 6000 | TCP | File transfer tunnel (transparent proxy, end-to-end TLS) |
| 322 | TCP/TLS | RTSP camera streaming (X1/H2/P2 series) | | 322 | TCP | RTSP camera streaming (transparent proxy, end-to-end TLS) |
These ports are proxied automatically — no iptables rules needed. If you have These ports are proxied automatically — no iptables rules needed. If you have
a firewall, ensure these ports are open between the slicer and Bambuddy. a firewall, ensure these ports are open between the slicer and Bambuddy.
+3
View File
@@ -532,6 +532,9 @@ RestartSec=5
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
# Allow binding to privileged ports (322, 990) for Virtual Printer proxy mode
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Security hardening # Security hardening
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true