mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
4dc3aaea5b
commit
70622e6e53
9 changed files with 873 additions and 137 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue