Add proxy mode for virtual printer (cross-LAN slicer support)

Enable Bambu Studio on a remote network to print through BamBuddy
acting as a TLS-terminating proxy for both MQTT and FTP connections.

- Add TLSProxy base class and FTPTLSProxy with PASV response rewriting,
  EPSV→PASV translation, PROT P/C tracking, and one-shot data proxies
- Add SlicerProxyManager to coordinate per-slicer MQTT + FTP proxy pairs
- Support additional SAN IPs in certificate generation for proxy mode
- Broadcast SSDP on LAN B so slicers discover the proxy as a printer
- Narrow FTP passive port range to 50000-50100 with retry logic
- Expose proxy ports (8883, 9990, 50000-50100) in Dockerfile
- Document passive port range in docker-compose.yml
This commit is contained in:
maziggy 2026-02-07 15:17:19 +01:00
parent 4dc3aaea5b
commit 70622e6e53
9 changed files with 873 additions and 137 deletions

View file

@ -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 \

View file

@ -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(

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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. 9909990), 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 <control_IP>:<port>, 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,

View file

@ -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."""

View file

@ -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: