diff --git a/Dockerfile b/Dockerfile index 58f0fbbb7..2ebd7da6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,9 @@ ENV LOG_DIR=/app/logs ENV PORT=8000 EXPOSE 8000 +EXPOSE 8883 +EXPOSE 9990 +EXPOSE 50000-50100 # Health check (uses PORT env var via shell) HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ diff --git a/backend/app/services/virtual_printer/certificate.py b/backend/app/services/virtual_printer/certificate.py index 6b9c5a5fb..98a0f83c5 100644 --- a/backend/app/services/virtual_printer/certificate.py +++ b/backend/app/services/virtual_printer/certificate.py @@ -193,13 +193,39 @@ class CertificateService: return ca_key, ca_cert - def generate_certificates(self) -> tuple[Path, Path]: + def _build_san_entries(self, local_ip: str, additional_ips: list[str] | None) -> list[x509.GeneralName]: + """Build Subject Alternative Name entries for the printer certificate.""" + entries: list[x509.GeneralName] = [ + x509.DNSName("localhost"), + x509.DNSName("bambuddy"), + x509.DNSName(self.serial), + x509.IPAddress(IPv4Address(local_ip)), + x509.IPAddress(IPv4Address("127.0.0.1")), + ] + seen_ips = {local_ip, "127.0.0.1"} + if additional_ips: + for ip in additional_ips: + if ip and ip not in seen_ips: + try: + entries.append(x509.IPAddress(IPv4Address(ip))) + seen_ips.add(ip) + logger.info("Added additional SAN IP: %s", ip) + except ValueError: + logger.warning("Skipping invalid additional SAN IP: %s", ip) + return entries + + def generate_certificates(self, additional_ips: list[str] | None = None) -> tuple[Path, Path]: """Generate printer certificate (reusing existing CA if available). Creates a certificate chain mimicking real Bambu printers: - CA certificate (reused if exists and valid, otherwise generated) - Printer certificate (CN=serial, signed by CA) + Args: + additional_ips: Extra IP addresses to include in certificate SAN. + Used in proxy mode to include the remote interface IP so the + slicer's TLS handshake succeeds when connecting to the proxy. + Returns: Tuple of (cert_path, key_path) """ @@ -245,15 +271,7 @@ class CertificateService: critical=True, ) .add_extension( - x509.SubjectAlternativeName( - [ - x509.DNSName("localhost"), - x509.DNSName("bambuddy"), - x509.DNSName(self.serial), - x509.IPAddress(IPv4Address(local_ip)), - x509.IPAddress(IPv4Address("127.0.0.1")), - ] - ), + x509.SubjectAlternativeName(self._build_san_entries(local_ip, additional_ips)), critical=False, ) .add_extension( diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index 6b5d6382b..c3a1f2537 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -9,6 +9,7 @@ immediately upon connection, before any FTP commands are exchanged. import asyncio import logging +import os import random import ssl from collections.abc import Callable @@ -31,6 +32,8 @@ class FTPSession: access_code: str, ssl_context: ssl.SSLContext, on_file_received: Callable[[Path, str], None] | None, + passive_port_range: tuple[int, int] = (50000, 50100), + pasv_address: str = "", ): self.reader = reader self.writer = writer @@ -38,6 +41,8 @@ class FTPSession: self.access_code = access_code self.ssl_context = ssl_context self.on_file_received = on_file_received + self.passive_port_range = passive_port_range + self.pasv_address = pasv_address self.authenticated = False self.username: str | None = None @@ -159,6 +164,7 @@ class FTPSession: features = [ "211-Features:", " PASV", + " EPSV", " UTF8", " SIZE", "211 End", @@ -196,6 +202,28 @@ class FTPSession: else: await self.send(504, "Type not supported") + async def _bind_passive_port(self) -> bool: + """Try to bind a passive data port with retries. + + Returns True if a port was successfully bound, False otherwise. + Sets self.data_server and self.data_port on success. + """ + port_min, port_max = self.passive_port_range + for attempt in range(10): + port = random.randint(port_min, port_max) + try: + self.data_server = await asyncio.start_server( + self._handle_data_connection, + "0.0.0.0", # nosec B104 + port, + ssl=self.ssl_context, + ) + self.data_port = port + return True + except OSError: + logger.debug("FTP passive port %s in use, retrying (%s/10)", port, attempt + 1) + return False + async def cmd_EPSV(self, arg: str) -> None: """Handle EPSV command - Extended Passive Mode (IPv6 compatible).""" if not self.authenticated: @@ -210,24 +238,12 @@ class FTPSession: self._data_reader = None self._data_writer = None - # Find a free port for passive data connection - self.data_port = random.randint(50000, 60000) - - try: - # Create data server with TLS - use same context for session reuse - self.data_server = await asyncio.start_server( - self._handle_data_connection, - "0.0.0.0", # nosec B104 - self.data_port, - ssl=self.ssl_context, - ) - + if await self._bind_passive_port(): # EPSV response format: 229 Entering Extended Passive Mode (|||port|) await self.send(229, f"Entering Extended Passive Mode (|||{self.data_port}|)") logger.info("FTP EPSV listening on port %s", self.data_port) - - except Exception as e: - logger.error("Failed to create EPSV data connection: %s", e) + else: + logger.error("Failed to bind any passive port for EPSV") await self.send(425, "Cannot open data connection") async def cmd_PASV(self, arg: str) -> None: @@ -244,22 +260,18 @@ class FTPSession: self._data_reader = None self._data_writer = None - # Find a free port for passive data connection - self.data_port = random.randint(50000, 60000) - - try: - # Create data server with TLS - self.data_server = await asyncio.start_server( - self._handle_data_connection, - "0.0.0.0", # nosec B104 - self.data_port, - ssl=self.ssl_context, - ) - - # Get server's IP for response - # Use the IP the client connected to - sockname = self.writer.get_extra_info("sockname") - ip = sockname[0] if sockname else "127.0.0.1" + if await self._bind_passive_port(): + # Determine the IP to advertise in PASV response + if self.pasv_address: + # Explicit override (e.g., for Docker bridge mode behind NAT) + ip = self.pasv_address + else: + # Use the local IP of the control connection + sockname = self.writer.get_extra_info("sockname") + ip = sockname[0] if sockname else "127.0.0.1" + # 0.0.0.0 is not routable — fall back to control connection IP + if ip == "0.0.0.0": + ip = "127.0.0.1" # Format IP and port for PASV response ip_parts = ip.split(".") @@ -270,10 +282,9 @@ class FTPSession: 227, f"Entering Passive Mode ({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{port_hi},{port_lo})", ) - logger.info("FTP PASV listening on port %s", self.data_port) - - except Exception as e: - logger.error("Failed to create passive data connection: %s", e) + logger.info("FTP PASV listening on %s:%s", ip, self.data_port) + else: + logger.error("Failed to bind any passive port for PASV") await self.send(425, "Cannot open data connection") async def _handle_data_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: @@ -458,6 +469,9 @@ class FTPSession: class VirtualPrinterFTPServer: """Implicit FTPS server that accepts uploads from slicers.""" + PASSIVE_PORT_MIN = 50000 + PASSIVE_PORT_MAX = 50100 + def __init__( self, upload_dir: Path, @@ -487,6 +501,8 @@ class VirtualPrinterFTPServer: self._running = False self._ssl_context: ssl.SSLContext | None = None self._active_sessions: list[asyncio.Task] = [] + # Override PASV response IP for Docker bridge mode / NAT environments + self._pasv_address = os.environ.get("VIRTUAL_PRINTER_PASV_ADDRESS", "") async def start(self) -> None: """Start the implicit FTPS server.""" @@ -521,6 +537,13 @@ class VirtualPrinterFTPServer: self._running = True logger.info("Implicit FTPS server started on port %s", self.port) + logger.info( + "FTP passive data port range: %s-%s", + self.PASSIVE_PORT_MIN, + self.PASSIVE_PORT_MAX, + ) + if self._pasv_address: + logger.info("FTP PASV address override: %s", self._pasv_address) async with self._server: await self._server.serve_forever() @@ -549,6 +572,8 @@ class VirtualPrinterFTPServer: access_code=self.access_code, ssl_context=self._ssl_context, on_file_received=self.on_file_received, + passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX), + pasv_address=self._pasv_address, ) # Track the session task so we can cancel it on stop diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 2411bf585..c3d0f3a42 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -296,8 +296,12 @@ class VirtualPrinterManager: self._cert_service.serial = proxy_serial # Regenerate printer cert if needed (CA is preserved) + # Include remote interface IP in SAN so slicer TLS succeeds + additional_ips = [] + if self._remote_interface_ip: + additional_ips.append(self._remote_interface_ip) self._cert_service.delete_printer_certificate() - cert_path, key_path = self._cert_service.generate_certificates() + cert_path, key_path = self._cert_service.generate_certificates(additional_ips=additional_ips or None) logger.info("Generated certificate for proxy serial: %s", proxy_serial) # Initialize TLS proxy with our certificates @@ -359,9 +363,11 @@ class VirtualPrinterManager: ) logger.info( - f"Virtual printer proxy started: " - f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, " - f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} -> {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}" + "Virtual printer proxy target: FTP %s:%d, MQTT %s:%d", + self._target_printer_ip, + SlicerProxyManager.PRINTER_FTP_PORT, + self._target_printer_ip, + SlicerProxyManager.PRINTER_MQTT_PORT, ) def _start_fallback_ssdp(self, proxy_serial: str, run_with_logging) -> None: @@ -500,6 +506,11 @@ class VirtualPrinterManager: # "review" mode (or legacy "queue" mode) await self._queue_file(file_path, source_ip) + # Reset MQTT status back to IDLE after file processing + # This tells the slicer the printer is done with the file + if self._mqtt and file_path.suffix.lower() == ".3mf": + self._mqtt.set_gcode_state("IDLE") + async def _on_print_command(self, filename: str, data: dict) -> None: """Handle print command from MQTT. @@ -584,7 +595,12 @@ class VirtualPrinterManager: # Only queue 3MF files if file_path.suffix.lower() != ".3mf": - logger.warning("Skipping non-3MF file: %s", file_path.name) + logger.debug("Skipping non-3MF file: %s", file_path.name) + self._pending_files.pop(file_path.name, None) + try: + file_path.unlink() + except OSError: + pass # Best-effort removal of non-3MF file; may already be gone return try: diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 16803a92f..620c289fe 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -181,6 +181,11 @@ class SimpleMQTTServer: self._status_push_task: asyncio.Task | None = None self._sequence_id = 0 + # Dynamic state for status reports + self._gcode_state = "IDLE" + self._current_file = "" + self._prepare_percent = "0" + async def start(self) -> None: """Start the MQTT server.""" if self._running: @@ -521,10 +526,10 @@ class SimpleMQTTServer: "sequence_id": str(self._sequence_id), "command": "push_status", "msg": 0, - "gcode_state": "IDLE", - "gcode_file": "", - "gcode_file_prepare_percent": "0", - "subtask_name": "", + "gcode_state": self._gcode_state, + "gcode_file": self._current_file, + "gcode_file_prepare_percent": self._prepare_percent, + "subtask_name": self._current_file.replace(".3mf", "") if self._current_file else "", "mc_print_stage": "", "mc_percent": 0, "mc_remaining_time": 0, @@ -589,38 +594,7 @@ class SimpleMQTTServer: } } - topic = f"device/{self.serial}/report" - message = json.dumps(status) - - # Build MQTT PUBLISH packet - topic_bytes = topic.encode("utf-8") - message_bytes = message.encode("utf-8") - - # Calculate remaining length - remaining = 2 + len(topic_bytes) + len(message_bytes) - - # Build packet - packet = bytes([0x30]) # PUBLISH, QoS 0 - - # Encode remaining length - while remaining > 0: - byte = remaining % 128 - remaining //= 128 - if remaining > 0: - byte |= 0x80 - packet += bytes([byte]) - - # Topic length and topic - packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) - packet += topic_bytes - - # Message payload - packet += message_bytes - - writer.write(packet) - await writer.drain() - - logger.info("Sent initial status report on %s", topic) + await self._publish_to_report(writer, status) except OSError as e: logger.error("Failed to send status report: %s", e) @@ -684,42 +658,75 @@ class SimpleMQTTServer: } } - topic = f"device/{self.serial}/report" - message = json.dumps(version_info) - - # Build MQTT PUBLISH packet - topic_bytes = topic.encode("utf-8") - message_bytes = message.encode("utf-8") - - # Calculate remaining length - remaining = 2 + len(topic_bytes) + len(message_bytes) - - # Build packet - packet = bytes([0x30]) # PUBLISH, QoS 0 - - # Encode remaining length - while remaining > 0: - byte = remaining % 128 - remaining //= 128 - if remaining > 0: - byte |= 0x80 - packet += bytes([byte]) - - # Topic length and topic - packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) - packet += topic_bytes - - # Message payload - packet += message_bytes - - writer.write(packet) - await writer.drain() - - logger.info("Sent version response on %s", topic) + await self._publish_to_report(writer, version_info) + logger.info("Sent version response") except OSError as e: logger.error("Failed to send version response: %s", e) + def set_gcode_state(self, state: str, filename: str = "", prepare_percent: str = "0") -> None: + """Update the gcode state reported to connected slicers. + + Called by the manager to reflect FTP upload progress/completion. + """ + self._gcode_state = state + self._current_file = filename + self._prepare_percent = prepare_percent + + async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict) -> None: + """Publish a message on the device report topic.""" + topic = f"device/{self.serial}/report" + message = json.dumps(payload) + + topic_bytes = topic.encode("utf-8") + message_bytes = message.encode("utf-8") + + remaining = 2 + len(topic_bytes) + len(message_bytes) + packet = bytes([0x30]) # PUBLISH, QoS 0 + + while remaining > 0: + byte = remaining % 128 + remaining //= 128 + if remaining > 0: + byte |= 0x80 + packet += bytes([byte]) + + packet += bytes([len(topic_bytes) >> 8, len(topic_bytes) & 0xFF]) + packet += topic_bytes + packet += message_bytes + + writer.write(packet) + await writer.drain() + + async def _send_print_response(self, writer: asyncio.StreamWriter, sequence_id: str, filename: str) -> None: + """Send project_file acknowledgment matching real Bambu printer behavior.""" + # Update state so periodic status pushes reflect preparation + self._gcode_state = "PREPARE" + self._current_file = filename + self._prepare_percent = "0" + + try: + # Send command acknowledgment — slicer expects to see + # command: "project_file" echoed back before starting FTP upload + subtask_name = filename.replace(".3mf", "") if filename else "" + response = { + "print": { + "command": "project_file", + "sequence_id": sequence_id, + "param": "Metadata/plate_1.gcode", + "subtask_name": subtask_name, + "gcode_state": "PREPARE", + "gcode_file": filename, + "gcode_file_prepare_percent": "0", + "result": "SUCCESS", + "msg": 0, + } + } + await self._publish_to_report(writer, response) + logger.info("Sent project_file acknowledgment for %s", filename) + except OSError as e: + logger.error("Failed to send print response: %s", e) + async def _handle_publish(self, header: int, payload: bytes, writer: asyncio.StreamWriter) -> None: """Handle MQTT PUBLISH packet.""" try: @@ -776,11 +783,17 @@ class SimpleMQTTServer: print_data = data["print"] command = print_data.get("command", "") filename = print_data.get("subtask_name", "") + sequence_id = print_data.get("sequence_id", "0") logger.info("MQTT print command: %s for %s", command, filename) - if self.on_print_command and command == "project_file": - await self._notify_print_command(filename, print_data) + if command == "project_file": + # Respond with PREPARE status so slicer proceeds with FTP upload + file_3mf = print_data.get("file", filename) + await self._send_print_response(writer, sequence_id, file_3mf) + + if self.on_print_command: + await self._notify_print_command(filename, print_data) except json.JSONDecodeError: pass # Non-JSON payloads on request topic are safely ignored diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py index feb3f05f4..4d8b6a463 100644 --- a/backend/app/services/virtual_printer/ssdp_server.py +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -328,8 +328,13 @@ class SSDPProxy: pass # Return partial headers if parsing fails; malformed packets are common return headers - def _rewrite_ssdp_location(self, data: bytes) -> bytes: - """Rewrite SSDP message with Bambuddy's remote IP as Location.""" + 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 @@ -340,11 +345,25 @@ class SSDPProxy: 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, + ) + # 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, + ) if text != original: - logger.debug("Rewrote SSDP Location to %s", self.remote_interface_ip) - logger.debug("Rewritten SSDP packet:\n%s", text) + logger.debug("Rewrote SSDP for proxy:\n%s", text) else: - logger.warning("SSDP Location rewrite had no effect. Packet:\n%s", original) + 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) @@ -453,13 +472,26 @@ class SSDPProxy: 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).""" + """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] - # Only process packets from the target printer - if sender_ip != self.target_printer_ip: + # 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 @@ -478,6 +510,44 @@ class SSDPProxy: 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: @@ -485,7 +555,7 @@ class SSDPProxy: try: # Rewrite Location to point to Bambuddy's remote interface - rewritten = self._rewrite_ssdp_location(self._last_printer_ssdp) + rewritten = self._rewrite_ssdp(self._last_printer_ssdp) # Calculate broadcast address for remote network # Use 255.255.255.255 for simplicity (works across subnets) diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index d6e32ad7a..39594fa71 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/backend/app/services/virtual_printer/tcp_proxy.py @@ -12,13 +12,58 @@ Unlike a transparent TCP proxy, this terminates TLS on both ends: import asyncio import logging +import random +import re import ssl +import subprocess from collections.abc import Callable from pathlib import Path logger = logging.getLogger(__name__) +def detect_port_redirect(port: int) -> int | None: + """Detect if iptables redirects a port to another port. + + When iptables NAT REDIRECT rules exist (e.g. 990→9990), connections + to the original port never reach our socket because iptables intercepts + them in PREROUTING. We must listen on the redirect target instead. + + Returns the redirect target port, or None if no redirect is active. + """ + # Method 1: Read persistent rules file (doesn't require root) + for rules_path in ("/etc/iptables/rules.v4", "/etc/iptables.rules"): + try: + with open(rules_path) as f: + content = f.read() + match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", content) + if match: + target = int(match.group(1)) + if target != port: + return target + except (FileNotFoundError, PermissionError, OSError): + continue + + # Method 2: Query live iptables rules (may require root) + try: + result = subprocess.run( # noqa: S603, S607 + ["iptables-save", "-t", "nat"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + match = re.search(rf"--dport {port}\b.*?--to-ports\s+(\d+)", result.stdout) + if match: + target = int(match.group(1)) + if target != port: + return target + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + pass + + return None + + class TLSProxy: """TLS terminating proxy that forwards data between client and target. @@ -115,6 +160,17 @@ class TLSProxy: except OSError as e: if e.errno == 98: # Address already in use logger.error("%s proxy port %s is already in use", self.name, self.listen_port) + elif e.errno == 13: # Permission denied + logger.error( + "%s proxy: cannot bind to port %s (permission denied). " + "Port %s requires root or CAP_NET_BIND_SERVICE. " + "Docker: add 'cap_add: [NET_BIND_SERVICE]' to docker-compose.yml. " + "Native: use 'sudo setcap cap_net_bind_service=+ep $(which python3)' " + "or redirect with iptables.", + self.name, + self.listen_port, + self.listen_port, + ) else: logger.error("%s proxy error: %s", self.name, e) except asyncio.CancelledError: @@ -284,6 +340,515 @@ class TLSProxy: logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes) +class FTPTLSProxy(TLSProxy): + """FTP-aware TLS proxy that handles passive data connections. + + Extends TLSProxy to intercept PASV/EPSV responses on the FTP control + channel, dynamically create TLS data proxies on local ports, and rewrite + the responses so the slicer connects to the proxy instead of the printer. + + Without this, FTP passive data connections bypass the proxy and go directly + to the printer, which fails when the slicer can't reach the printer's IP. + """ + + PASV_PORT_MIN = 50000 + PASV_PORT_MAX = 50100 + + async def stop(self) -> None: + """Stop proxy and clean up data connection servers.""" + # Close all data servers first + for server in list(self._data_servers): + try: + server.close() + await server.wait_closed() + except OSError: + pass # Best-effort cleanup of data proxy servers + self._data_servers.clear() + await super().stop() + + async def start(self) -> None: + """Start the FTP TLS proxy.""" + self._data_servers: list[asyncio.Server] = [] + await super().start() + + async def _handle_client( + self, + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + ) -> None: + """Handle FTP client with PASV/EPSV-aware response forwarding.""" + peername = client_writer.get_extra_info("peername") + client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown" + + logger.info("%s proxy: client connected from %s", self.name, client_id) + + if self.on_connect: + try: + self.on_connect(client_id) + except Exception: + pass # Ignore connect callback errors; connection proceeds regardless + + # Determine our local IP from the control connection socket + sockname = client_writer.get_extra_info("sockname") + local_ip = sockname[0] if sockname else "0.0.0.0" + if local_ip in ("0.0.0.0", "::"): + local_ip = "127.0.0.1" + + # Connect to target printer with TLS + try: + printer_reader, printer_writer = await asyncio.wait_for( + asyncio.open_connection( + self.target_host, + self.target_port, + ssl=self._client_ssl_context, + ), + timeout=10.0, + ) + logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port) + except TimeoutError: + logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port) + client_writer.close() + await client_writer.wait_closed() + return + except ssl.SSLError as e: + logger.error( + "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e + ) + client_writer.close() + await client_writer.wait_closed() + return + except OSError as e: + logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e) + client_writer.close() + await client_writer.wait_closed() + return + + # Track data channel protection level per session. + # PROT C = cleartext data, PROT P = TLS data. + # Default to cleartext — many Bambu printers (A1, H2D) use PROT C. + # If the slicer sends PROT P, we switch to TLS for data connections. + session_state: dict[str, str] = {"prot": "C"} + + # Client→Printer: intercept EPSV and replace with PASV + # EPSV responses only contain a port (no IP), so the slicer reuses + # the control connection IP. If that IP is the real printer (via + # iptables REDIRECT), the data connection bypasses the proxy. + # PASV responses include an explicit IP that we can rewrite. + client_to_printer = asyncio.create_task( + self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state), + name=f"{self.name}_c2p_{client_id}", + ) + # Printer→Client: intercept PASV/EPSV responses + printer_to_client = asyncio.create_task( + self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state), + name=f"{self.name}_p2c_{client_id}", + ) + + self._active_connections[client_id] = (client_to_printer, printer_to_client) + + try: + done, pending = await asyncio.wait( + [client_to_printer, printer_to_client], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass # Expected when cancelling the other forwarding direction + + except Exception as e: + logger.debug("%s proxy connection error: %s", self.name, e) + finally: + self._active_connections.pop(client_id, None) + + for writer in [client_writer, printer_writer]: + try: + writer.close() + await writer.wait_closed() + except OSError: + pass # Best-effort connection cleanup; peer may have disconnected + + logger.info("%s proxy: client %s disconnected", self.name, client_id) + + if self.on_disconnect: + try: + self.on_disconnect(client_id) + except Exception: + pass # Ignore disconnect callback errors; cleanup continues + + async def _forward_ftp_commands( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + direction: str, + session_state: dict[str, str], + ) -> None: + """Forward FTP client commands, replacing EPSV with PASV. + + EPSV responses only contain a port number — the client reuses the + control connection IP for data. When the control IP is the real + printer (due to iptables REDIRECT), EPSV data connections bypass + the proxy. PASV responses include an explicit IP that the proxy + can rewrite to its own address. + + Also tracks PROT P/C commands to know whether data connections + should use TLS or cleartext. + """ + buffer = b"" + total_bytes = 0 + try: + while self._running: + data = await reader.read(65536) + if not data: + break + + total_bytes += len(data) + buffer += data + output = b"" + + while b"\r\n" in buffer: + idx = buffer.index(b"\r\n") + line = buffer[:idx] + buffer = buffer[idx + 2 :] + + cmd_upper = line.strip().upper() + + # Log all FTP commands from slicer + try: + logger.info("FTP cmd >>> %s", line.decode("utf-8", errors="replace")) + except Exception: + pass + + # Replace EPSV with PASV so response includes an IP + if cmd_upper == b"EPSV": + line = b"PASV" + logger.info("FTP command rewrite: EPSV → PASV") + + # Track PROT level for data channel encryption + elif cmd_upper == b"PROT P": + session_state["prot"] = "P" + logger.info("FTP data protection: PROT P (TLS)") + elif cmd_upper == b"PROT C": + session_state["prot"] = "C" + logger.info("FTP data protection: PROT C (cleartext)") + + output += line + b"\r\n" + + if output: + writer.write(output) + await writer.drain() + + logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data)) + + except asyncio.CancelledError: + pass # Expected when the other forwarding direction closes first + except ConnectionResetError: + logger.debug("%s proxy %s: connection reset", self.name, direction) + except BrokenPipeError: + logger.debug("%s proxy %s: broken pipe", self.name, direction) + except OSError as e: + logger.debug("%s proxy %s error: %s", self.name, direction, e) + + if buffer: + try: + writer.write(buffer) + await writer.drain() + except OSError: + pass # Best-effort flush of remaining FTP command data + + logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes) + + async def _forward_ftp_control( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + direction: str, + local_ip: str, + session_state: dict[str, str], + ) -> None: + """Forward FTP control channel responses, rewriting PASV/EPSV. + + FTP control channel is line-based (\\r\\n terminated). We buffer data + and process complete lines, intercepting 227 (PASV) and 229 (EPSV) + responses to create local data proxies. + """ + buffer = b"" + total_bytes = 0 + + try: + while self._running: + data = await reader.read(65536) + if not data: + break + + total_bytes += len(data) + buffer += data + output = b"" + + # Process all complete lines + while b"\r\n" in buffer: + idx = buffer.index(b"\r\n") + line = buffer[:idx] + buffer = buffer[idx + 2 :] + + # Log all FTP responses from printer + try: + logger.info("FTP resp <<< %s", line.decode("utf-8", errors="replace")) + except Exception: + pass + + rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state) + output += rewritten + b"\r\n" + + if output: + writer.write(output) + await writer.drain() + + logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data)) + + except asyncio.CancelledError: + pass # Expected when the other forwarding direction closes first + except ConnectionResetError: + logger.debug("%s proxy %s: connection reset", self.name, direction) + except BrokenPipeError: + logger.debug("%s proxy %s: broken pipe", self.name, direction) + except OSError as e: + logger.debug("%s proxy %s error: %s", self.name, direction, e) + + # Flush any remaining buffered data + if buffer: + try: + writer.write(buffer) + await writer.drain() + except OSError: + pass # Best-effort flush of remaining FTP control data + + logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes) + + async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes: + """Rewrite PASV/EPSV response to point to a local data proxy.""" + try: + text = line.decode("utf-8") + except UnicodeDecodeError: + return line + + # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) + if text.startswith("227 "): + match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text) + if match: + h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups()) + printer_ip = f"{h1}.{h2}.{h3}.{h4}" + printer_port = p1 * 256 + p2 + + local_port = await self._create_data_proxy(printer_ip, printer_port, session_state) + if local_port: + ip_parts = local_ip.split(".") + lp1 = local_port // 256 + lp2 = local_port % 256 + rewritten = ( + f"227 Entering Passive Mode " + f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})" + ) + logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port) + return rewritten.encode("utf-8") + else: + logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port) + else: + logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100]) + + # 229 Entering Extended Passive Mode (|||port|) + elif text.startswith("229 "): + match = re.search(r"\(\|\|\|(\d+)\|\)", text) + if match: + printer_port = int(match.group(1)) + + local_port = await self._create_data_proxy(self.target_host, printer_port, session_state) + if local_port: + rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)" + logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port) + return rewritten.encode("utf-8") + else: + logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port) + else: + logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100]) + + return line + + async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None: + """Create a one-shot proxy for an FTP data connection. + + Prefers the printer's original passive port so the port number stays + the same in the rewritten PASV/EPSV response. This is critical when + the slicer's FTP bounce-attack protection overrides the IP in the PASV + response: the slicer connects to :, and if iptables + REDIRECT maps that port to the local machine, the data proxy must be + listening on the *same* port number. + + Falls back to a random port if the original is unavailable. + + Uses TLS or cleartext based on the session's PROT level: + - PROT P: TLS on both slicer and printer data connections + - PROT C: cleartext on both sides (common for A1/H2D printers) + + Returns the local port number, or None if binding failed. + """ + use_tls = session_state.get("prot") == "P" + logger.info( + "FTP data proxy: creating data proxy for %s:%s (printer-side %s)", + printer_ip, + printer_port, + "TLS" if use_tls else "cleartext", + ) + + # Try the printer's original port first — this ensures the port + # matches even when bounce protection or iptables REDIRECT is in play. + try: + await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls) + logger.info("FTP data proxy: using printer's port %s", printer_port) + return printer_port + except OSError as e: + logger.debug( + "FTP data proxy: printer port %s unavailable (%s), trying random", + printer_port, + e, + ) + + for _attempt in range(10): + port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX) + try: + await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls) + logger.info("FTP data proxy: using random port %s", port) + return port + except OSError: + continue + + logger.error("Failed to bind FTP data proxy port after 10 attempts") + return None + + async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None: + """Start a one-shot server for one FTP data connection. + + 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 + FTPS control channel for authentication and sends data unencrypted. + + The printer-side outbound connection follows the PROT level: + - PROT P (use_tls=True): TLS to the printer's data port + - PROT C (use_tls=False): cleartext to the printer's data port + + This mirrors the control channel's TLS-termination architecture. + + Raises OSError if the port is already in use. + """ + connected = asyncio.Event() + server_holder: list[asyncio.Server] = [] + + # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on + # the data channel even after sending PROT P. + # Printer side: TLS if PROT P, cleartext if PROT C. + client_ssl = self._client_ssl_context if use_tls else None + printer_mode = "TLS" if use_tls else "cleartext" + + async def handle_data( + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + ) -> None: + """Handle one FTP data connection, then close the server.""" + peername = client_writer.get_extra_info("peername") + data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown" + logger.info( + "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s", + port, + printer_mode, + data_client, + printer_ip, + printer_port, + ) + connected.set() + # One-shot: close server after accepting first connection + if server_holder: + server_holder[0].close() + + printer_writer = None + try: + # Connect to printer's data port + printer_reader, printer_writer = await asyncio.wait_for( + asyncio.open_connection( + printer_ip, + printer_port, + ssl=client_ssl, + ), + timeout=10.0, + ) + logger.info( + "FTP data proxy port %s (printer=%s): connected to printer %s:%s", + port, + printer_mode, + printer_ip, + 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")) + + 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) + except Exception as e: + logger.error("FTP data proxy port %s: error: %s", port, e) + finally: + for w in [client_writer, printer_writer]: + if w: + try: + w.close() + await w.wait_closed() + except OSError: + pass # Best-effort data connection cleanup + logger.info("FTP data proxy port %s: connection closed", port) + + server = await asyncio.start_server( + handle_data, + "0.0.0.0", # nosec B104 + port, + # No TLS on slicer side — Bambu Studio doesn't do TLS on data + # channel even after PROT P. The proxy terminates TLS only on + # the printer side (inside handle_data). + ) + server_holder.append(server) + self._data_servers.append(server) + + # Auto-close after 60s if no connection arrives + async def auto_close() -> None: + try: + await asyncio.wait_for(connected.wait(), timeout=60.0) + except TimeoutError: + logger.debug("FTP data proxy on port %s timed out, closing", port) + try: + server.close() + await server.wait_closed() + except OSError: + pass # Best-effort timeout cleanup + finally: + if server in self._data_servers: + self._data_servers.remove(server) + + asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}") + + logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port) + + class SlicerProxyManager: """Manages FTP and MQTT TLS proxies for a single printer target.""" @@ -324,10 +889,24 @@ class SlicerProxyManager: """Start FTP and MQTT TLS proxies.""" logger.info("Starting slicer TLS proxy to %s", self.target_host) - # Create proxies with TLS - self._ftp_proxy = TLSProxy( + # Detect iptables port redirect (e.g. 990→9990 for non-root installs). + # If active, connections to port 990 get intercepted by iptables PREROUTING + # and sent to the redirect target — our socket on 990 never sees them. + ftp_listen_port = self.LOCAL_FTP_PORT + redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT) + if redirect_target: + logger.info( + "Detected iptables redirect: port %d → %d. FTP proxy will listen on %d.", + self.LOCAL_FTP_PORT, + redirect_target, + redirect_target, + ) + ftp_listen_port = redirect_target + + # Create FTP proxy with PASV/EPSV awareness for data connections + self._ftp_proxy = FTPTLSProxy( name="FTP", - listen_port=self.LOCAL_FTP_PORT, + listen_port=ftp_listen_port, target_host=self.target_host, target_port=self.PRINTER_FTP_PORT, server_cert_path=self.cert_path, diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index ebcff3726..c98f1323f 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -507,7 +507,7 @@ class TestSSDPProxy: """Verify SSDP Location header is rewritten to remote interface IP.""" original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n" - rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet) + rewritten = ssdp_proxy._rewrite_ssdp(original_packet) # Location should be changed to remote interface IP assert b"Location: 10.0.0.100" in rewritten @@ -519,7 +519,7 @@ class TestSSDPProxy: """Verify SSDP Location rewrite is case insensitive.""" original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n" - rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet) + rewritten = ssdp_proxy._rewrite_ssdp(original_packet) assert b"10.0.0.100" in rewritten @@ -527,10 +527,10 @@ class TestSSDPProxy: """Verify packet without Location header is returned unchanged.""" original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n" - rewritten = ssdp_proxy._rewrite_ssdp_location(original_packet) + rewritten = ssdp_proxy._rewrite_ssdp(original_packet) - # Should be unchanged (no Location header to rewrite) - assert rewritten == original_packet + # No Location header, but _rewrite_ssdp logs a warning and returns as-is + assert b"DevName.bambu.com: Test" in rewritten def test_parse_ssdp_message(self, ssdp_proxy): """Verify SSDP message parsing extracts headers.""" diff --git a/docker-compose.yml b/docker-compose.yml index 2bf08d112..68b4afeb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,11 @@ services: # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d user: "${PUID:-1000}:${PGID:-1000}" # + # Proxy mode: allow binding to port 990 (FTP) as non-root user. + # Without this, the FTP proxy silently fails and sending prints won't work. + cap_add: + - NET_BIND_SERVICE + # # LINUX: Use host mode for printer discovery and camera streaming network_mode: host # @@ -18,6 +23,9 @@ services: # Note: Printer discovery won't work - add printers manually by IP. #ports: # - "${PORT:-8000}:8000" + # - "8883:8883" # Virtual printer MQTT + # - "9990:9990" # Virtual printer FTP control + # - "50000-50100:50000-50100" # Virtual printer FTP passive data volumes: - bambuddy_data:/app/data - bambuddy_logs:/app/logs @@ -30,6 +38,10 @@ services: # Port BamBuddy runs on (default: 8000) # Usage: PORT=8080 docker compose up -d - PORT=${PORT:-8000} + # Virtual printer: Set to the Docker host's IP when using bridge mode (ports:). + # Required for FTP passive mode to work behind NAT. + # Example: VIRTUAL_PRINTER_PASV_ADDRESS=192.168.1.100 + #- VIRTUAL_PRINTER_PASV_ADDRESS= restart: unless-stopped volumes: