Files
bambuddy/backend/app/services/virtual_printer/ssdp_server.py
T
maziggy 0b2b81a34d [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
2026-03-19 11:03:10 +01:00

649 lines
26 KiB
Python

"""SSDP discovery responder for virtual printer.
Responds to M-SEARCH requests from slicers and sends periodic NOTIFY
announcements so the virtual printer appears as a discoverable Bambu printer.
Also provides SSDP proxy functionality for proxy mode, where Bambuddy sits
between two networks and re-broadcasts printer SSDP from LAN A to LAN B.
"""
import asyncio
import logging
import re
import socket
import struct
logger = logging.getLogger(__name__)
# SSDP addresses - Bambu uses port 2021
# Real Bambu printers broadcast to 255.255.255.255, not multicast to 239.255.255.250
SSDP_MULTICAST_ADDR = "239.255.255.250"
SSDP_BROADCAST_ADDR = "255.255.255.255"
SSDP_PORT = 2021
# Bambu service target
BAMBU_SEARCH_TARGET = "urn:bambulab-com:device:3dprinter:1"
class VirtualPrinterSSDPServer:
"""SSDP server that responds to discovery requests as a virtual Bambu printer."""
def __init__(
self,
name: str = "Bambuddy",
serial: str = "00M09A391800001", # X1C serial format for compatibility
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.
Args:
name: Display name shown in slicer discovery
serial: Unique serial number
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
self.model = model
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:
"""Get the local IP address to advertise."""
if self._local_ip:
return self._local_ip
# Try to determine local IP by connecting to a public address
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
self._local_ip = ip
return ip
except OSError:
return "127.0.0.1"
def _build_notify_message(self) -> bytes:
"""Build SSDP NOTIFY message for periodic announcements."""
ip = self._get_local_ip()
message = (
"NOTIFY * HTTP/1.1\r\n"
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
"Server: UPnP/1.0\r\n"
f"Location: {ip}\r\n"
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
"NTS: ssdp:alive\r\n"
f"USN: {self.serial}\r\n"
"Cache-Control: max-age=1800\r\n"
f"DevModel.bambu.com: {self.model}\r\n"
f"DevName.bambu.com: {self.name}\r\n"
"DevSignal.bambu.com: -44\r\n"
"DevConnect.bambu.com: lan\r\n"
"DevBind.bambu.com: free\r\n"
"Devseclink.bambu.com: secure\r\n"
"DevInf.bambu.com: eth0\r\n"
"DevVersion.bambu.com: 01.07.00.00\r\n"
"DevCap.bambu.com: 1\r\n"
"\r\n"
)
return message.encode()
def _build_response_message(self) -> bytes:
"""Build SSDP response message for M-SEARCH requests."""
ip = self._get_local_ip()
message = (
"HTTP/1.1 200 OK\r\n"
"Server: UPnP/1.0\r\n"
f"Location: {ip}\r\n"
f"ST: {BAMBU_SEARCH_TARGET}\r\n"
f"USN: {self.serial}\r\n"
"Cache-Control: max-age=1800\r\n"
f"DevModel.bambu.com: {self.model}\r\n"
f"DevName.bambu.com: {self.name}\r\n"
"DevSignal.bambu.com: -44\r\n"
"DevConnect.bambu.com: lan\r\n"
"DevBind.bambu.com: free\r\n"
"Devseclink.bambu.com: secure\r\n"
"DevInf.bambu.com: eth0\r\n"
"DevVersion.bambu.com: 01.07.00.00\r\n"
"DevCap.bambu.com: 1\r\n"
"\r\n"
)
return message.encode()
async def start(self) -> None:
"""Start the SSDP server."""
if self._running:
return
logger.info("Starting virtual printer SSDP server: %s (%s)", self.name, self.serial)
self._running = True
try:
# Create UDP socket
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Try to set SO_REUSEPORT if available
try:
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass # SO_REUSEPORT not available on all platforms; non-critical
# Set non-blocking mode
self._socket.setblocking(False)
# Bind to SSDP port on specific interface (or all interfaces)
self._socket.bind((self._bind_ip or "", SSDP_PORT))
# Join multicast group (on specific interface if bind_ip is set)
if self._bind_ip:
mreq = struct.pack(
"4s4s",
socket.inet_aton(SSDP_MULTICAST_ADDR),
socket.inet_aton(self._bind_ip),
)
else:
mreq = struct.pack("4sl", socket.inet_aton(SSDP_MULTICAST_ADDR), socket.INADDR_ANY)
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# Enable broadcast
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# Set multicast TTL
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
local_ip = self._get_local_ip()
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")
# Run receive and announce loops
last_notify = asyncio.get_event_loop().time()
notify_interval = 30.0 # Send NOTIFY every 30 seconds
while self._running:
# Try to receive M-SEARCH requests on primary socket
try:
data, addr = self._socket.recvfrom(4096)
message = data.decode("utf-8", errors="ignore")
await self._handle_message(message, addr)
except BlockingIOError:
pass # No data available on non-blocking socket; will retry
except OSError as e:
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:
await self._send_notify()
last_notify = now
await asyncio.sleep(0.1)
except OSError as e:
if e.errno == 98: # Address already in use
logger.warning("SSDP port %s in use - real printers may be running", SSDP_PORT)
else:
logger.error("SSDP server error: %s", e)
except asyncio.CancelledError:
logger.debug("SSDP server cancelled")
except Exception as e:
logger.error("SSDP server error: %s", e)
finally:
await self._cleanup()
async def stop(self) -> None:
"""Stop the SSDP server."""
logger.info("Stopping SSDP server")
self._running = False
await self._cleanup()
async def _cleanup(self) -> None:
"""Clean up resources."""
if self._socket:
try:
# Send byebye message
await self._send_byebye()
except OSError:
pass # Best-effort byebye broadcast; socket may already be closed
try:
self._socket.close()
except OSError:
pass # Best-effort socket close; may already be released
self._socket = None
for sock in self._extra_sockets:
try:
sock.close()
except OSError:
pass
self._extra_sockets = []
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."""
if not self._socket:
return
message = (
"NOTIFY * HTTP/1.1\r\n"
f"Host: {SSDP_MULTICAST_ADDR}:1990\r\n"
f"NT: {BAMBU_SEARCH_TARGET}\r\n"
"NTS: ssdp:byebye\r\n"
f"USN: {self.serial}\r\n"
"\r\n"
)
try:
self._socket.sendto(message.encode(), (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug("Sent SSDP byebye")
except OSError:
pass # Best-effort byebye send; network may be unavailable during shutdown
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:
return
# Check search target
if BAMBU_SEARCH_TARGET not in message and "ssdp:all" not in message.lower():
return
logger.debug("Received M-SEARCH from %s", addr[0])
# Send response on the socket that received the request
sock = reply_socket or self._socket
if sock:
try:
response = self._build_response_message()
sock.sendto(response, addr)
logger.info(
"Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
addr[0],
self.name,
self._get_local_ip(),
self.serial,
)
except OSError as e:
logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
class SSDPProxy:
"""SSDP proxy that re-broadcasts printer discovery from one network to another.
Listens for SSDP broadcasts from a real printer on the local interface (LAN A),
then re-broadcasts them on the remote interface (LAN B) with the Location
header changed to point to Bambuddy's IP on LAN B.
This allows Bambu Studio on LAN B to discover the printer via Bambuddy.
"""
def __init__(
self,
local_interface_ip: str,
remote_interface_ip: str,
target_printer_ip: str,
name: str | None = None,
):
"""Initialize the SSDP proxy.
Args:
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
self._last_printer_ssdp: bytes | None = None
self._printer_info: dict[str, str] = {}
def _parse_ssdp_message(self, data: bytes) -> dict[str, str]:
"""Parse SSDP message into header dict."""
headers = {}
try:
text = data.decode("utf-8", errors="ignore")
for line in text.split("\r\n"):
if ":" in line:
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
except Exception:
pass # Return partial headers if parsing fails; malformed packets are common
return headers
def _rewrite_ssdp(self, data: bytes) -> bytes:
"""Rewrite SSDP message for proxy re-broadcast.
- Location: changed to Bambuddy's remote interface IP
- DevBind: forced to 'free' so the slicer treats the proxy as a
LAN-only printer (avoids cloud auth requirement for sending prints)
"""
try:
text = data.decode("utf-8", errors="ignore")
original = text
# Replace Location header with our remote interface IP
text = re.sub(
r"(Location:\s*)[\d.]+",
f"\\g<1>{self.remote_interface_ip}",
text,
flags=re.IGNORECASE,
)
# Force DevBind to 'free' - ensures slicer uses LAN mode for
# both monitoring AND sending prints through the proxy
text = re.sub(
r"(DevBind\.bambu\.com:\s*)\S+",
r"\g<1>free",
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:
logger.warning("SSDP rewrite had no effect. Packet:\n%s", original)
return text.encode("utf-8")
except Exception as e:
logger.error("Failed to rewrite SSDP: %s", e)
return data
async def start(self) -> None:
"""Start the SSDP proxy."""
if self._running:
return
logger.info(
f"Starting SSDP proxy: listening on {self.local_interface_ip} (LAN A), "
f"broadcasting on {self.remote_interface_ip} (LAN B), "
f"proxying printer {self.target_printer_ip}"
)
self._running = True
try:
# Create socket for listening on LAN A (printer network)
# Bind to 0.0.0.0 to receive broadcast packets (255.255.255.255)
# We filter by source IP in the handler
self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass # SO_REUSEPORT not available on all platforms; non-critical
self._local_socket.setblocking(False)
# Bind to all interfaces to receive broadcasts
self._local_socket.bind(("", SSDP_PORT))
# Join multicast group on local interface (for multicast SSDP if used)
mreq = struct.pack(
"4s4s",
socket.inet_aton(SSDP_MULTICAST_ADDR),
socket.inet_aton(self.local_interface_ip),
)
self._local_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
# Create socket for broadcasting on LAN B (slicer network)
self._remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except (AttributeError, OSError):
pass # SO_REUSEPORT not available on all platforms; non-critical
self._remote_socket.setblocking(False)
# Bind to remote interface
self._remote_socket.bind((self.remote_interface_ip, 0))
self._remote_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
logger.info(
"SSDP proxy listening on 0.0.0.0:%s (filtering for printer %s)", SSDP_PORT, self.target_printer_ip
)
logger.info("SSDP proxy will broadcast on %s", self.remote_interface_ip)
# Main loop
last_broadcast = 0.0
broadcast_interval = 30.0 # Re-broadcast every 30 seconds
while self._running:
# Listen for SSDP from printer on LAN A
try:
data, addr = self._local_socket.recvfrom(4096)
await self._handle_local_packet(data, addr)
except BlockingIOError:
pass # No data available on non-blocking socket; will retry
except OSError as e:
if self._running:
logger.debug("SSDP proxy receive error: %s", e)
# Listen for M-SEARCH from slicer on LAN B (via remote socket would need separate bind)
# For now, we periodically re-broadcast cached printer SSDP
now = asyncio.get_event_loop().time()
if self._last_printer_ssdp and now - last_broadcast >= broadcast_interval:
await self._broadcast_to_remote()
last_broadcast = now
await asyncio.sleep(0.1)
except OSError as e:
logger.error("SSDP proxy error: %s", e)
except asyncio.CancelledError:
logger.debug("SSDP proxy cancelled")
except Exception as e:
logger.error("SSDP proxy error: %s", e)
finally:
await self._cleanup()
async def stop(self) -> None:
"""Stop the SSDP proxy."""
logger.info("Stopping SSDP proxy")
self._running = False
await self._cleanup()
async def _cleanup(self) -> None:
"""Clean up resources."""
for sock in [self._local_socket, self._remote_socket]:
if sock:
try:
sock.close()
except OSError:
pass # Best-effort socket close; may already be released
self._local_socket = None
self._remote_socket = None
async def _handle_local_packet(self, data: bytes, addr: tuple[str, int]) -> None:
"""Handle SSDP packet received on local interface (LAN A).
Processes two types of traffic:
- NOTIFY from the real printer → cache and re-broadcast on LAN B
- M-SEARCH from slicers on LAN B → respond with cached printer info
"""
sender_ip = addr[0]
# Ignore packets from our own interfaces (prevent loops)
if sender_ip in (self.local_interface_ip, self.remote_interface_ip):
return
# Handle M-SEARCH from slicers (any IP that's not the target printer)
if sender_ip != self.target_printer_ip:
if b"M-SEARCH" in data:
await self._respond_to_msearch(data, addr)
return
# Below: NOTIFY handling from the real printer
# Check if it's a NOTIFY message
if b"NOTIFY" not in data and b"HTTP/1.1 200" not in data:
return
# Check if it's a Bambu printer SSDP
if b"bambulab-com:device:3dprinter" not in data:
return
# Parse and store printer info
headers = self._parse_ssdp_message(data)
if headers:
self._printer_info = headers
logger.debug("Received SSDP from printer %s: %s", sender_ip, headers.get("devname.bambu.com", "unknown"))
# Store and immediately broadcast
self._last_printer_ssdp = data
await self._broadcast_to_remote()
async def _respond_to_msearch(self, data: bytes, addr: tuple[str, int]) -> None:
"""Respond to M-SEARCH from a slicer with cached, rewritten printer info.
When Bambu Studio sends an M-SEARCH (e.g., before sending a print),
we respond with the cached printer info, rewritten to point to the
proxy's LAN B IP. Without this, the slicer thinks the printer is
offline and shows a 'connect to printer' modal.
"""
# Check if it's a relevant M-SEARCH
if b"bambulab-com:device:3dprinter" not in data and b"ssdp:all" not in data.lower():
return
if not self._last_printer_ssdp:
logger.debug("M-SEARCH from %s but no cached printer SSDP yet", addr[0])
return
logger.debug("Received M-SEARCH from slicer %s", addr[0])
# Rewrite the cached printer SSDP (Location → proxy IP, DevBind → free)
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
text = rewritten.decode("utf-8", errors="ignore")
# Convert NOTIFY format to M-SEARCH response format:
# "NOTIFY * HTTP/1.1" → "HTTP/1.1 200 OK"
# NT: → ST: (Notification Type → Search Target)
# Remove NTS: header (only in NOTIFY)
text = re.sub(r"^NOTIFY \* HTTP/1\.1", "HTTP/1.1 200 OK", text)
text = re.sub(r"^NT:", "ST:", text, flags=re.MULTILINE)
text = re.sub(r"^NTS:.*\r\n", "", text, flags=re.MULTILINE)
# Send unicast response directly to the slicer via remote socket
if self._remote_socket:
try:
self._remote_socket.sendto(text.encode("utf-8"), addr)
logger.info("Sent SSDP M-SEARCH response to %s", addr[0])
except OSError as e:
logger.debug("Failed to send M-SEARCH response to %s: %s", addr[0], e)
async def _broadcast_to_remote(self) -> None:
"""Broadcast cached printer SSDP on remote interface (LAN B)."""
if not self._remote_socket or not self._last_printer_ssdp:
return
try:
# Rewrite Location to point to Bambuddy's remote interface
rewritten = self._rewrite_ssdp(self._last_printer_ssdp)
# Calculate broadcast address for remote network
# Use 255.255.255.255 for simplicity (works across subnets)
self._remote_socket.sendto(rewritten, (SSDP_BROADCAST_ADDR, SSDP_PORT))
printer_name = self._printer_info.get("devname.bambu.com", "unknown")
logger.debug("Broadcast SSDP for '%s' on LAN B (%s)", printer_name, self.remote_interface_ip)
except OSError as e:
logger.debug("Failed to broadcast SSDP on remote: %s", e)