[Fix] Virtual Printer FTP routed to wrong VP with different access codes (#735)

When running multiple virtual printers with different access codes on
  separate bind IPs, FTP connections were always routed to the wrong VP.

  Root cause: the iptables REDIRECT rule (990→9990) rewrites the
  destination IP to the incoming interface's primary address. With Linux's
  weak host model (arp_filter=0), packets for secondary IPs arrive on the
  primary interface, and REDIRECT sends them all to the first VP's FTP
  server. MQTT was unaffected because port 8883 had no redirect.

  Fix: FTP server now binds directly to port 990 (standard implicit FTPS),
  eliminating the iptables redirect entirely. Requires CAP_NET_BIND_SERVICE
  (already set in the systemd service file and Docker image).

  Also removed a global asyncio set_exception_handler() in the MQTT server
  that was overwritten by each VP instance, causing spurious "Unhandled
  exception in client_connected_cb" errors on startup.

  Changes:
  - FTP_PORT: 9990 → 990 (ftp_server.py)
  - Removed set_exception_handler() from MQTT server
  - Updated Dockerfile, docker-compose.yml port mappings
  - Deprecated --redirect-990 in install script
  - Updated wiki: removed iptables instructions for all platforms
  - Added migration guide (docs/migration-vp-ftp-port.md)
  - Added unit tests for port constant and no-global-state invariant
This commit is contained in:
maziggy
2026-03-18 09:04:31 +01:00
parent dcdebef9a8
commit 82d329d85c
15 changed files with 172 additions and 75 deletions
@@ -17,8 +17,12 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Default FTP port for Bambu printers (implicit FTPS)
FTP_PORT = 9990
# Default FTP port for Bambu printers (implicit FTPS).
# Must be 990 (same as real printers) to avoid iptables REDIRECT,
# which rewrites the destination IP to the incoming interface's primary
# address — breaking multi-VP setups with different bind IPs.
# Requires CAP_NET_BIND_SERVICE or root.
FTP_PORT = 990
class FTPSession:
@@ -568,7 +572,7 @@ class VirtualPrinterFTPServer:
if self._running:
return
logger.info("Starting virtual printer implicit FTPS on port %s", self.port)
logger.info("[%s] Starting virtual printer implicit FTPS on %s:%s", self.vp_name, self.bind_address, self.port)
# Ensure upload directory exists
self.upload_dir.mkdir(parents=True, exist_ok=True)
@@ -689,7 +689,7 @@ class VirtualPrinterManager:
)
self._instances[vp.id] = instance
await instance.start_proxy()
logger.info("Started proxy VP: %s → %s", instance.name, target_ip)
logger.info("Started proxy VP: %s → %s (bind=%s)", instance.name, target_ip, instance.bind_ip)
else:
instance = VirtualPrinterInstance(
vp_id=vp.id,
@@ -262,20 +262,6 @@ class SimpleMQTTServer:
except Exception as e:
logger.error("MQTT connection handler error: %s", e)
# Custom protocol factory to log raw connection attempts
logger.info("Setting up MQTT server with SSL error handling...")
# Add SSL handshake error callback
def handle_ssl_error(loop, context):
exception = context.get("exception")
message = context.get("message", "")
if "ssl" in str(exception).lower() or "ssl" in message.lower():
logger.error("SSL error: %s - %s", message, exception)
else:
logger.debug("Asyncio error: %s", message)
asyncio.get_event_loop().set_exception_handler(handle_ssl_error)
self._server = await asyncio.start_server(
connection_handler,
self.bind_address,
@@ -232,7 +232,13 @@ class VirtualPrinterSSDPServer:
try:
msg = self._build_notify_message()
self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
logger.debug("Sent SSDP NOTIFY for %s", self.name)
logger.debug(
"Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
self.name,
self._get_local_ip(),
self.serial,
self._bind_ip,
)
except OSError as e:
logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
@@ -278,7 +284,13 @@ class VirtualPrinterSSDPServer:
try:
response = self._build_response_message()
self._socket.sendto(response, addr)
logger.info("Sent SSDP response to %s for virtual printer '%s'", addr[0], self.name)
logger.info(
"Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
addr[0],
self.name,
self._get_local_ip(),
self.serial,
)
except OSError as e:
logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
@@ -25,7 +25,7 @@ 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
When iptables NAT REDIRECT rules exist (e.g. port redirects), connections
to the original port never reach our socket because iptables intercepts
them in PREROUTING. We must listen on the redirect target instead.
@@ -1087,9 +1087,9 @@ class SlicerProxyManager:
"""Start FTP and MQTT TLS proxies."""
logger.info("Starting slicer TLS proxy to %s", self.target_host)
# 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.
# Detect iptables port redirect (e.g. if an external redirect exists).
# If active, connections get intercepted by iptables PREROUTING
# and sent to the redirect target — our socket never sees them.
ftp_listen_port = self.LOCAL_FTP_PORT
redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
if redirect_target: