mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
[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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user