[Fix] Virtual Printer proxy mode fails on isolated networks (#757)

When the slicer and printer are on different VLANs, Bambu Studio could
  not send prints through the proxy because the printer's real IP leaked
  through MQTT payloads, the bind protocol forwarded the real printer's
  identity, the port 6000 file transfer tunnel was not proxied, and FTP
  data connections raced the TLS handshake on zero-byte uploads.

  - Rewrite IP addresses in MQTT PUBLISH payloads (string + integer)
    with proper packet framing and cross-chunk buffering
  - Respond to bind/detect with VP identity via BindServer
  - Add TLS proxy for port 6000 (file transfer tunnel)
  - Buffer slicer FTP data during printer connection setup
  - Advertise configured VP name in SSDP proxy
  - Add cross-subnet SSDP wildcard listener for VPN setups
  - Register UserEmailPreference model in models/__init__.py
  - Add 11 unit tests for MQTT rewrite, IP conversion, SSDP name
This commit is contained in:
maziggy
2026-03-19 11:03:10 +01:00
parent dfb995bfe9
commit 0b2b81a34d
8 changed files with 670 additions and 75 deletions
+2
View File
@@ -20,6 +20,8 @@ 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.
- **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.
- **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 a TLS proxy for port 6000, 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.
- **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.
### 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.
+2
View File
@@ -25,6 +25,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
from backend.app.models.user import User
from backend.app.models.user_email_pref import UserEmailPreference
__all__ = [
"Printer",
@@ -59,4 +60,5 @@ __all__ = [
"SpoolUsageHistory",
"ColorCatalogEntry",
"SpoolBuddyDevice",
"UserEmailPreference",
]
@@ -473,6 +473,12 @@ class VirtualPrinterInstance:
key_path=key_path,
on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
bind_address=self.bind_ip or "0.0.0.0", # nosec B104
bind_identity={
"serial": self.target_printer_serial or self.serial,
"model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
"name": self.name,
"version": "01.00.00.00",
},
)
async def run_with_logging(coro, svc_name):
@@ -494,6 +500,7 @@ class VirtualPrinterInstance:
local_interface_ip=local_iface["ip"],
remote_interface_ip=self.remote_interface_ip,
target_printer_ip=self.target_printer_ip,
name=self.name,
)
self._tasks.append(
asyncio.create_task(
@@ -35,6 +35,7 @@ class VirtualPrinterSSDPServer:
model: str = "BL-P001", # X1C model code for best compatibility
advertise_ip: str = "",
bind_ip: str = "",
extra_interfaces: list[str] | None = None,
):
"""Initialize the SSDP server.
@@ -44,6 +45,10 @@ class VirtualPrinterSSDPServer:
model: Model code
advertise_ip: Override IP to advertise instead of auto-detecting
bind_ip: IP address to bind the SSDP socket to
extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).
NOTIFY and M-SEARCH responses are sent on these interfaces too,
but Location always points to the bind IP so the slicer connects
to the correct address for MQTT/FTP.
"""
self.name = name
self.serial = serial
@@ -51,6 +56,8 @@ class VirtualPrinterSSDPServer:
self._bind_ip = bind_ip
self._running = False
self._socket: socket.socket | None = None
self._extra_sockets: list[socket.socket] = []
self._extra_interfaces = extra_interfaces or []
self._local_ip: str | None = advertise_ip or bind_ip or None
def _get_local_ip(self) -> str:
@@ -163,6 +170,30 @@ class VirtualPrinterSSDPServer:
logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
# Create extra sockets for additional interfaces (VPN, etc.)
# If no explicit extra interfaces given and we're bound to a
# specific IP, add a wildcard socket to catch M-SEARCH from
# other subnets (VPN tunnels, secondary NICs, etc.)
extra_ips = list(self._extra_interfaces)
if not extra_ips and self._bind_ip:
extra_ips.append("0.0.0.0") # nosec B104
for iface_ip in extra_ips:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass
sock.setblocking(False)
sock.bind((iface_ip, SSDP_PORT))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
self._extra_sockets.append(sock)
logger.info("SSDP server also listening on %s:%s", iface_ip, SSDP_PORT)
except OSError as e:
logger.warning("SSDP server: failed to bind extra interface %s: %s", iface_ip, e)
# Send initial NOTIFY
await self._send_notify()
logger.info("Sent initial SSDP NOTIFY announcement")
@@ -172,7 +203,7 @@ class VirtualPrinterSSDPServer:
notify_interval = 30.0 # Send NOTIFY every 30 seconds
while self._running:
# Try to receive M-SEARCH requests
# Try to receive M-SEARCH requests on primary socket
try:
data, addr = self._socket.recvfrom(4096)
message = data.decode("utf-8", errors="ignore")
@@ -183,6 +214,17 @@ class VirtualPrinterSSDPServer:
if self._running:
logger.debug("SSDP receive error: %s", e)
# Try to receive M-SEARCH requests on extra sockets
for sock in self._extra_sockets:
try:
data, addr = sock.recvfrom(4096)
message = data.decode("utf-8", errors="ignore")
await self._handle_message(message, addr, sock)
except BlockingIOError:
pass
except OSError:
pass
# Send periodic NOTIFY
now = asyncio.get_event_loop().time()
if now - last_notify >= notify_interval:
@@ -224,23 +266,35 @@ class VirtualPrinterSSDPServer:
pass # Best-effort socket close; may already be released
self._socket = None
async def _send_notify(self) -> None:
"""Send SSDP NOTIFY message via broadcast."""
if not self._socket:
return
for sock in self._extra_sockets:
try:
sock.close()
except OSError:
pass
self._extra_sockets = []
try:
msg = self._build_notify_message()
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug(
"Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
self.name,
self._get_local_ip(),
self.serial,
self._bind_ip,
)
except OSError as e:
logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
async def _send_notify(self) -> None:
"""Send SSDP NOTIFY message via broadcast on all sockets."""
msg = self._build_notify_message()
if self._socket:
try:
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug(
"Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
self.name,
self._get_local_ip(),
self.serial,
self._bind_ip,
)
except OSError as e:
logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
for sock in self._extra_sockets:
try:
sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
except OSError:
pass # Best-effort broadcast on extra interfaces
async def _send_byebye(self) -> None:
"""Send SSDP byebye message when shutting down."""
@@ -262,12 +316,15 @@ class VirtualPrinterSSDPServer:
except OSError:
pass # Best-effort byebye send; network may be unavailable during shutdown
async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
async def _handle_message(
self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None
) -> None:
"""Handle incoming SSDP message.
Args:
message: The SSDP message content
addr: Tuple of (ip_address, port) of sender
reply_socket: Socket to send the response on (defaults to primary)
"""
# Check if this is an M-SEARCH request for Bambu printers
if "M-SEARCH" not in message:
@@ -279,11 +336,12 @@ class VirtualPrinterSSDPServer:
logger.debug("Received M-SEARCH from %s", addr[0])
# Send response
if self._socket:
# Send response on the socket that received the request
sock = reply_socket or self._socket
if sock:
try:
response = self._build_response_message()
self._socket.sendto(response, addr)
sock.sendto(response, addr)
logger.info(
"Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
addr[0],
@@ -310,6 +368,7 @@ class SSDPProxy:
local_interface_ip: str,
remote_interface_ip: str,
target_printer_ip: str,
name: str | None = None,
):
"""Initialize the SSDP proxy.
@@ -317,10 +376,12 @@ class SSDPProxy:
local_interface_ip: IP of interface on printer's network (LAN A)
remote_interface_ip: IP of interface on slicer's network (LAN B)
target_printer_ip: IP of the real printer to proxy SSDP for
name: Optional VP name to advertise (replaces printer's real name)
"""
self.local_interface_ip = local_interface_ip
self.remote_interface_ip = remote_interface_ip
self.target_printer_ip = target_printer_ip
self.proxy_name = name
self._running = False
self._local_socket: socket.socket | None = None
self._remote_socket: socket.socket | None = None
@@ -365,13 +426,21 @@ class SSDPProxy:
text,
flags=re.IGNORECASE,
)
# Append " - Proxy" to printer name so it's distinguishable
text = re.sub(
r"(DevName\.bambu\.com:\s*)(.+)",
r"\g<1>\g<2> - Proxy",
text,
flags=re.IGNORECASE,
)
# Replace printer name with configured VP name, or append " - Proxy"
if self.proxy_name:
text = re.sub(
r"(DevName\.bambu\.com:\s*)[^\r\n]+",
rf"\g<1>{self.proxy_name}",
text,
flags=re.IGNORECASE,
)
else:
text = re.sub(
r"(DevName\.bambu\.com:\s*)([^\r\n]+)",
r"\g<1>\g<2> - Proxy",
text,
flags=re.IGNORECASE,
)
if text != original:
logger.debug("Rewrote SSDP for proxy:\n%s", text)
else:
+345 -45
View File
@@ -82,6 +82,7 @@ class TLSProxy:
on_connect: Callable[[str], None] | None = None,
on_disconnect: Callable[[str], None] | None = None,
bind_address: str = "0.0.0.0", # nosec B104
rewrite_ip: tuple[str, str] | None = None,
):
"""Initialize the TLS proxy.
@@ -95,6 +96,10 @@ class TLSProxy:
on_connect: Optional callback when client connects (receives client_id)
on_disconnect: Optional callback when client disconnects (receives client_id)
bind_address: IP address to bind to (default: all interfaces)
rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
the printer's real IP with the proxy's bind IP in printer→client data.
This prevents the slicer from discovering the printer's real IP
in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
"""
self.name = name
self.listen_port = listen_port
@@ -106,12 +111,41 @@ class TLSProxy:
self.on_disconnect = on_disconnect
self.bind_address = bind_address
# IP rewriting for printer→client direction
if rewrite_ip:
self._rewrite_old = rewrite_ip[0].encode("utf-8")
self._rewrite_new = rewrite_ip[1].encode("utf-8")
# Also rewrite the integer IP in net.info[].ip fields.
# Bambu printers encode their IP as a little-endian uint32 integer
# in the JSON payload. BambuStudio reads this to set dev_ip.
self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
else:
self._rewrite_old = None
self._rewrite_new = None
self._rewrite_old_int = None
self._rewrite_new_int = None
self._server: asyncio.Server | None = None
self._running = False
self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
self._server_ssl_context: ssl.SSLContext | None = None
self._client_ssl_context: ssl.SSLContext | None = None
@staticmethod
def _ip_to_le_int_bytes(ip: str) -> bytes:
"""Convert an IP address to its little-endian integer JSON representation.
E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
"""
import struct as _struct
parts = ip.split(".")
packed = bytes(int(p) for p in parts)
le_int = _struct.unpack("<I", packed)[0]
return str(le_int).encode("utf-8")
def _create_server_ssl_context(self) -> ssl.SSLContext:
"""Create SSL context for accepting client (slicer) connections."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -263,7 +297,7 @@ class TLSProxy:
name=f"{self.name}_c2p_{client_id}",
)
printer_to_client = asyncio.create_task(
self._forward(printer_reader, client_writer, f"printer→{client_id}"),
self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
name=f"{self.name}_p2c_{client_id}",
)
@@ -305,11 +339,157 @@ class TLSProxy:
except Exception:
pass # Ignore disconnect callback errors; cleanup continues
@staticmethod
def _rewrite_mqtt_ip(
data: bytes,
old_ip: bytes,
new_ip: bytes,
buffer: bytearray,
extra_replacements: list[tuple[bytes, bytes]] | None = None,
) -> tuple[bytes, bytearray]:
"""Rewrite IP addresses inside MQTT packets, preserving packet framing.
MQTT packets have a variable-length header encoding the remaining
packet length. A naive bytes.replace() would corrupt this framing
when old_ip and new_ip differ in length.
This method parses individual MQTT packets out of the data stream,
performs the replacement only on PUBLISH payloads, and re-encodes
the remaining-length field to match the new size.
Incomplete packets are buffered and returned for the next call.
Args:
extra_replacements: Additional (old, new) byte pairs to replace
(e.g. the integer IP representation in net.info[].ip).
Returns (output_data, remaining_buffer).
"""
buffer.extend(data)
# Check if any replacement target exists in the buffer
has_target = old_ip in buffer
if not has_target and extra_replacements:
has_target = any(old in buffer for old, _new in extra_replacements)
if not has_target:
# Fast path: no IP in buffer, but we still need to check for
# incomplete packets at the end that might contain a partial IP.
# For safety, try to parse and emit only complete packets.
result = bytearray()
pos = 0
length = len(buffer)
while pos < length:
packet_start = pos
if pos + 1 >= length:
break
pos += 1 # header byte
# Parse remaining length
remaining_length = 0
multiplier = 1
length_bytes = 0
while pos < length:
encoded_byte = buffer[pos]
pos += 1
remaining_length += (encoded_byte & 0x7F) * multiplier
multiplier *= 128
length_bytes += 1
if (encoded_byte & 0x80) == 0:
break
if length_bytes >= 4:
break
if pos + remaining_length > length:
# Incomplete — keep in buffer
new_buffer = bytearray(buffer[packet_start:])
return bytes(result), new_buffer
pos += remaining_length
result.extend(buffer[packet_start:pos])
# All complete
buffer.clear()
return bytes(result) if result else bytes(data), buffer
# Buffer contains old_ip — parse packets and rewrite
result = bytearray()
pos = 0
length = len(buffer)
while pos < length:
packet_start = pos
if pos >= length:
break
header_byte = buffer[pos]
pos += 1
# Remaining length: variable-length encoding (1-4 bytes)
remaining_length = 0
multiplier = 1
length_bytes = 0
while pos < length:
encoded_byte = buffer[pos]
pos += 1
remaining_length += (encoded_byte & 0x7F) * multiplier
multiplier *= 128
length_bytes += 1
if (encoded_byte & 0x80) == 0:
break
if length_bytes >= 4:
break
# Check if we have enough data for the full packet
if pos + remaining_length > length:
# Incomplete packet — keep in buffer for next call
new_buffer = bytearray(buffer[packet_start:])
return bytes(result), new_buffer
packet_type = (header_byte >> 4) & 0x0F
packet_body = buffer[pos : pos + remaining_length]
pos += remaining_length
# Only rewrite PUBLISH packets (type 3)
needs_rewrite = packet_type == 3 and (
old_ip in packet_body
or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
)
if needs_rewrite:
new_body = bytes(packet_body).replace(old_ip, new_ip)
if extra_replacements:
for old_val, new_val in extra_replacements:
new_body = new_body.replace(old_val, new_val)
# Re-encode: header byte + new remaining length + new body
result.append(header_byte)
# Encode remaining length (MQTT variable-length encoding)
new_remaining = len(new_body)
while True:
encoded_byte = new_remaining % 128
new_remaining //= 128
if new_remaining > 0:
encoded_byte |= 0x80
result.append(encoded_byte)
if new_remaining == 0:
break
result.extend(new_body)
else:
# Pass through unchanged
result.extend(buffer[packet_start:pos])
buffer.clear()
return bytes(result), buffer
async def _forward(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
direction: str,
rewrite_ip: bool = False,
) -> None:
"""Forward data from reader to writer.
@@ -317,7 +497,12 @@ class TLSProxy:
reader: Source stream (already TLS-decrypted)
writer: Destination stream (will be TLS-encrypted by the stream)
direction: Description for logging (e.g., "client→printer")
rewrite_ip: If True and rewrite_ip was configured, replace the
printer's real IP with the proxy's bind IP in the data.
"""
do_rewrite = rewrite_ip and self._rewrite_old is not None
rewrite_buffer = bytearray() if do_rewrite else None
rewrite_logged = False
total_bytes = 0
try:
while self._running:
@@ -327,6 +512,29 @@ class TLSProxy:
# Connection closed
break
# 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,
self._rewrite_old,
self._rewrite_new,
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(),
)
rewrite_logged = True
if not data:
continue # All data buffered, waiting for more
# Forward to destination
writer.write(data)
await writer.drain()
@@ -923,6 +1131,11 @@ class FTPTLSProxy(TLSProxy):
async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
"""Start a one-shot server for one FTP data connection.
When the slicer connects, immediately connects to the printer's data
port and buffers any slicer data until the printer connection is ready.
This handles zero-byte uploads (verify_job) where the slicer closes
the data channel before a naive proxy would finish its TLS handshake.
The slicer-side listener is ALWAYS cleartext. Even when the slicer
sends PROT P on the control channel, Bambu Studio does not perform
a TLS handshake on the data connection — it relies on the implicit
@@ -967,13 +1180,26 @@ class FTPTLSProxy(TLSProxy):
printer_writer = None
try:
# Buffer any slicer data while connecting to printer.
# This handles the race where the slicer sends data (or closes
# for zero-byte files) before the TLS handshake completes.
slicer_buffer = bytearray()
slicer_eof = False
async def buffer_slicer():
nonlocal slicer_eof
while True:
chunk = await client_reader.read(65536)
if not chunk:
slicer_eof = True
return
slicer_buffer.extend(chunk)
buffer_task = asyncio.create_task(buffer_slicer())
# Connect to printer's data port
printer_reader, printer_writer = await asyncio.wait_for(
asyncio.open_connection(
printer_ip,
printer_port,
ssl=client_ssl,
),
asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
timeout=10.0,
)
logger.info(
@@ -984,21 +1210,35 @@ class FTPTLSProxy(TLSProxy):
printer_port,
)
# Bidirectional data 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"))
# Stop buffering
buffer_task.cancel()
try:
await buffer_task
except asyncio.CancelledError:
pass
done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass # Expected when other data direction closes
except TimeoutError:
logger.error("FTP data proxy port %s: timeout connecting to printer", port)
except ssl.SSLError as e:
logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
# Flush buffered slicer data to printer
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()
try:
await task
except asyncio.CancelledError:
pass # Expected when other data direction closes
except Exception as e:
logger.error("FTP data proxy port %s: error: %s", port, e)
finally:
@@ -1048,6 +1288,7 @@ class SlicerProxyManager:
# Bambu printer ports
PRINTER_FTP_PORT = 990
PRINTER_MQTT_PORT = 8883
PRINTER_FILE_TRANSFER_PORT = 6000
PRINTER_BIND_PORTS = [3000, 3002]
# Local listen ports - must match what Bambu Studio expects
@@ -1062,6 +1303,7 @@ class SlicerProxyManager:
key_path: Path,
on_activity: Callable[[str, str], None] | None = None,
bind_address: str = "0.0.0.0", # nosec B104
bind_identity: dict[str, str] | None = None,
):
"""Initialize the slicer proxy manager.
@@ -1071,16 +1313,23 @@ class SlicerProxyManager:
key_path: Path to server private key
on_activity: Optional callback for activity logging (name, message)
bind_address: IP address to bind proxy listeners to
bind_identity: Optional dict with keys (serial, model, name, version)
for the bind/detect response. When provided, the proxy responds
to detect requests itself instead of forwarding to the printer.
This ensures the slicer sees the VP identity, not the real printer.
"""
self.target_host = target_host
self.cert_path = cert_path
self.key_path = key_path
self.on_activity = on_activity
self.bind_address = bind_address
self.bind_identity = bind_identity
self._ftp_proxy: TLSProxy | None = None
self._mqtt_proxy: TLSProxy | None = None
self._file_transfer_proxy: TLSProxy | None = None
self._bind_proxies: list[TCPProxy] = []
self._bind_server = None
self._tasks: list[asyncio.Task] = []
async def start(self) -> None:
@@ -1124,33 +1373,65 @@ class SlicerProxyManager:
on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
bind_address=self.bind_address,
rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,
)
# Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
for bind_port in self.PRINTER_BIND_PORTS:
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)
# File transfer proxy — port 6000 (TLS)
# BambuStudio connects here for verify_job and actual file uploads.
self._file_transfer_proxy = TLSProxy(
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,
)
# Bind/auth — respond with VP identity instead of proxying to printer.
# The detect response contains the printer name, serial, model, and
# bind status. Proxying it would leak the real printer's identity and
# cause the slicer to treat it as a different device.
if self.bind_identity:
from backend.app.services.virtual_printer.bind_server import BindServer
self._bind_server = BindServer(
serial=self.bind_identity["serial"],
model=self.bind_identity["model"],
name=self.bind_identity["name"],
version=self.bind_identity.get("version", "01.00.00.00"),
bind_address=self.bind_address,
cert_path=self.cert_path,
key_path=self.key_path,
)
else:
# Fallback: proxy bind requests to the real printer
for bind_port in self.PRINTER_BIND_PORTS:
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
async def run_with_logging(proxy: TLSProxy) -> None:
@@ -1168,7 +1449,18 @@ class SlicerProxyManager:
run_with_logging(self._mqtt_proxy),
name="slicer_proxy_mqtt",
),
asyncio.create_task(
run_with_logging(self._file_transfer_proxy),
name="slicer_proxy_file_transfer",
),
]
if self._bind_server:
self._tasks.append(
asyncio.create_task(
run_with_logging(self._bind_server),
name="slicer_proxy_bind_server",
)
)
for bp in self._bind_proxies:
self._tasks.append(
asyncio.create_task(
@@ -1199,6 +1491,14 @@ class SlicerProxyManager:
await self._mqtt_proxy.stop()
self._mqtt_proxy = None
if self._file_transfer_proxy:
await self._file_transfer_proxy.stop()
self._file_transfer_proxy = None
if self._bind_server:
await self._bind_server.stop()
self._bind_server = None
for bp in self._bind_proxies:
await bp.stop()
self._bind_proxies = []
@@ -1567,3 +1567,218 @@ class TestResolveModelCodes:
assert _resolve_printer_model(None) is None
assert _resolve_printer_model("UnknownModel") is None
class TestMqttIpRewrite:
"""Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
@staticmethod
def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
"""Build a minimal MQTT PUBLISH packet."""
# PUBLISH fixed header: type 3, no flags
topic_bytes = topic.encode("utf-8")
# Variable header: topic length (2 bytes) + topic
var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
body = var_header + payload
# Encode remaining length
remaining = len(body)
header = bytearray([0x30]) # PUBLISH, QoS 0
while True:
encoded_byte = remaining % 128
remaining //= 128
if remaining > 0:
encoded_byte |= 0x80
header.append(encoded_byte)
if remaining == 0:
break
return bytes(header) + body
@staticmethod
def _build_mqtt_pingreq() -> bytes:
"""Build an MQTT PINGREQ packet (2 bytes, no payload)."""
return b"\xc0\x00"
def test_rewrite_ip_in_publish(self):
"""IP string in PUBLISH payload is rewritten."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
packet = self._build_mqtt_publish("device/status", payload)
result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
assert b"10.0.0.1" in result
assert b"192.168.1.100" not in result
def test_no_rewrite_when_ip_absent(self):
"""Packets without the target IP are passed through unchanged."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
payload = b'{"status":"idle"}'
packet = self._build_mqtt_publish("device/status", payload)
result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
assert result == packet
def test_non_publish_packets_unchanged(self):
"""Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
pingreq = self._build_mqtt_pingreq()
result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
assert result == pingreq
def test_rewrite_preserves_packet_framing(self):
"""Rewritten packet has valid MQTT remaining length."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
# Use IPs of different lengths to test length re-encoding
old_ip = b"192.168.255.133" # 15 bytes
new_ip = b"10.0.0.1" # 8 bytes
payload = b'{"ip":"192.168.255.133"}'
packet = self._build_mqtt_publish("device/status", payload)
result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
# Parse the result to verify framing
assert result[0] == 0x30 # PUBLISH header byte
# Decode remaining length
pos = 1
remaining = 0
multiplier = 1
while True:
b = result[pos]
pos += 1
remaining += (b & 0x7F) * multiplier
multiplier *= 128
if (b & 0x80) == 0:
break
# Remaining length should match actual data
assert pos + remaining == len(result)
assert new_ip in result
def test_incomplete_packet_buffered(self):
"""Incomplete packet at end of chunk is buffered for next call."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
payload = b'{"ip":"192.168.1.100"}'
packet = self._build_mqtt_publish("device/status", payload)
# Split packet in the middle
half = len(packet) // 2
chunk1 = packet[:half]
chunk2 = packet[half:]
result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
# First chunk should be buffered (incomplete packet)
assert len(buf) > 0
result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
# Second chunk completes the packet, IP should be rewritten
combined = result1 + result2
assert b"10.0.0.1" in combined
assert b"192.168.1.100" not in combined
def test_multiple_packets_in_one_chunk(self):
"""Multiple MQTT packets in a single chunk are all processed."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
payload1 = b'{"ip":"192.168.1.100"}'
payload2 = b'{"other":"data"}'
packet1 = self._build_mqtt_publish("topic1", payload1)
packet2 = self._build_mqtt_publish("topic2", payload2)
combined = packet1 + packet2
result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
assert b"10.0.0.1" in result
assert b"192.168.1.100" not in result
# Second packet should still be present
assert b"other" in result
def test_extra_replacements(self):
"""Extra replacement pairs (e.g. integer IP) are also applied."""
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
payload = b'{"net":{"info":[{"ip":2248124608}]}}'
packet = self._build_mqtt_publish("device/status", payload)
result, buf = TLSProxy._rewrite_mqtt_ip(
packet,
b"NOMATCH",
b"NOREPLACE",
bytearray(),
extra_replacements=[(b"2248124608", b"285190336")],
)
assert b"285190336" in result
assert b"2248124608" not in result
class TestIpToLeIntBytes:
"""Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
def test_converts_ip_to_le_int(self):
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
def test_roundtrip(self):
"""Verify the integer converts back to the correct IP."""
import struct
from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
parts = ip.split(".")
expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
assert le_int == expected
class TestSSDPProxyName:
"""Tests for SSDPProxy VP name rewriting."""
@pytest.fixture
def ssdp_proxy_with_name(self):
from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
return SSDPProxy(
local_interface_ip="192.168.1.100",
remote_interface_ip="10.0.0.100",
target_printer_ip="192.168.1.50",
name="H2D-1 Proxy",
)
@pytest.fixture
def ssdp_proxy_without_name(self):
from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
return SSDPProxy(
local_interface_ip="192.168.1.100",
remote_interface_ip="10.0.0.100",
target_printer_ip="192.168.1.50",
)
def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
"""When name is set, DevName is replaced entirely."""
packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
assert b"RealPrinter" not in rewritten
def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
"""When no name is set, ' - Proxy' is appended to the real name."""
packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DTIUjlhj.js"></script>
<script type="module" crossorigin src="/assets/index-HM8c6qA1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DS0IE7o1.css">
</head>
<body>