[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:28:33 +01:00
parent d78a9b81dc
commit fc746ae218
15 changed files with 172 additions and 75 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
### Fixed
- **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.
- **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.
- **White Filament Color Swatches Invisible in Light Theme** ([#726](https://github.com/maziggy/bambuddy/issues/726)) — Filament color circles used a white border that was invisible against light theme backgrounds, making white spools indistinguishable. Changed to a dark border (`border-black/20`) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user.
- **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.
@@ -753,7 +754,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Virtual Printer Proxy Mode**:
- New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
- Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer
- Supports both FTP (port 9990) and MQTT (port 8883) protocols with full TLS encryption
- Supports both FTP (port 990) and MQTT (port 8883) protocols with full TLS encryption
- Slicer connects to Bambuddy using the real printer's access code
- Real-time status display showing active FTP/MQTT connections
- Target printer selector with validation (must be configured in Bambuddy)
+1 -1
View File
@@ -57,7 +57,7 @@ EXPOSE 3000
EXPOSE 3002
EXPOSE 8000
EXPOSE 8883
EXPOSE 9990
EXPOSE 990
EXPOSE 50000-50100
# Health check (uses PORT env var via shell)
@@ -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:
@@ -558,7 +562,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)
@@ -687,7 +687,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,
@@ -259,20 +259,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:
+20
View File
@@ -0,0 +1,20 @@
"""Tests for Virtual Printer FTP server port configuration."""
from backend.app.services.virtual_printer.ftp_server import FTP_PORT
class TestFTPPort:
"""Verify FTP server uses the standard FTPS port."""
def test_ftp_port_is_990(self):
"""FTP must bind to port 990 (standard implicit FTPS).
Port 9990 required an iptables REDIRECT rule which rewrites
the destination IP to the interface's primary address, breaking
multi-VP setups with different bind IPs and access codes.
"""
assert FTP_PORT == 990, (
f"FTP_PORT must be 990 (standard FTPS), not {FTP_PORT}. "
"Using a non-standard port requires iptables REDIRECT which "
"breaks multi-VP setups."
)
+28
View File
@@ -0,0 +1,28 @@
"""Tests for Virtual Printer MQTT server."""
import ast
import inspect
from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
class TestMQTTServerNoGlobalState:
"""Ensure MQTT server doesn't set global asyncio state."""
def test_no_global_exception_handler(self):
"""MQTT server must not call set_exception_handler().
set_exception_handler() is global to the event loop. When multiple
VP instances run, each would overwrite the previous handler,
causing lost error context and spurious 'Unhandled exception in
client_connected_cb' messages.
"""
source = inspect.getsource(SimpleMQTTServer)
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == "set_exception_handler":
raise AssertionError(
"SimpleMQTTServer must not call set_exception_handler(). "
"It overwrites the global asyncio exception handler, "
"breaking multi-VP setups."
)
+1 -1
View File
@@ -26,7 +26,7 @@ services:
# - "3000:3000" # Virtual printer bind/detect
# - "3002:3002" # Virtual printer bind/detect
# - "8883:8883" # Virtual printer MQTT
# - "9990:9990" # Virtual printer FTP control
# - "990:990" # Virtual printer FTP control
# - "50000-50100:50000-50100" # Virtual printer FTP passive data
volumes:
- bambuddy_data:/app/data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+83
View File
@@ -0,0 +1,83 @@
# Migration: Virtual Printer FTP Port Change (9990 -> 990)
## What Changed
The Virtual Printer FTP server now binds **directly to port 990** instead of port 9990.
Previously, an iptables `REDIRECT` rule was required to forward port 990 to 9990.
## Why
The iptables `REDIRECT` target rewrites the destination IP to the **primary address
of the incoming network interface**. When running multiple virtual printers on
different bind IPs (e.g. secondary interfaces or IP aliases), this caused FTP
connections to be routed to the wrong virtual printer — breaking authentication
when VPs have different access codes.
By binding directly to port 990, iptables is no longer involved and each VP's
FTP server correctly receives only its own traffic.
## Migration Steps
### Linux (Native / systemd)
1. **Remove old iptables rules:**
```bash
sudo iptables -t nat -D PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
sudo iptables -t nat -D OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
```
Repeat each command until it says "No chain/target/match by that name".
2. **Remove persistent rules** (if saved):
- **Debian/Ubuntu:** `sudo netfilter-persistent save`
- **Fedora/RHEL:** `sudo service iptables save`
- **Arch:** `sudo iptables-save > /etc/iptables/iptables.rules`
3. **Verify systemd service** has `AmbientCapabilities=CAP_NET_BIND_SERVICE`:
```bash
systemctl cat bambuddy | grep AmbientCapabilities
```
If missing, add it to the `[Service]` section.
4. **Restart Bambuddy.** Verify FTP binds to port 990:
```bash
grep "FTPS on" logs/bambuddy.log
# Should show: Starting virtual printer implicit FTPS on <IP>:990
```
### Docker (Host Network)
1. **Remove old iptables rules** on the Docker host (same as above).
2. **Update and restart** the container. No other changes needed —
the container binds directly to port 990 via `CAP_NET_BIND_SERVICE`.
### Docker (Bridge Network)
1. **Update port mapping** in `docker-compose.yml`:
```yaml
# Old:
- "990:9990"
# New:
- "990:990"
```
2. **Recreate the container:** `docker compose up -d`
### Unraid / Synology / TrueNAS / Proxmox LXC
1. **Remove any iptables redirect rules** you added for `990 -> 9990`.
- **Unraid:** Remove the lines from `/boot/config/go`
- **Synology:** Remove the scheduled task that added the iptables rule
2. **Update and restart** the container.
## Verification
After migration, confirm no redirect rules remain:
```bash
sudo iptables -t nat -L PREROUTING -n | grep 9990
# Should return nothing
```
Check the FTP server is binding correctly:
```bash
grep "FTPS on" logs/bambuddy.log
# Should show port 990, not 9990
```
+2 -2
View File
@@ -284,7 +284,7 @@ def create_diagram():
# Ports on remote side
draw.text((remote_x, section_y + 100), "Connects to Bambuddy", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
draw.text((remote_x, section_y + 120), "FTP :9990 MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
draw.text((remote_x, section_y + 120), "FTP :990 MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
# === INTERNET CLOUD ===
draw_cloud_icon(draw, internet_x, section_y, 80, INTERNET_COLOR)
@@ -303,7 +303,7 @@ def create_diagram():
draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 85, bambuddy_x + 55, section_y + 130],
6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
draw.text((bambuddy_x, section_y + 98), "FTP", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
draw.text((bambuddy_x, section_y + 115), "9990", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
draw.text((bambuddy_x, section_y + 115), "990", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 140, bambuddy_x + 55, section_y + 185],
6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+9 -46
View File
@@ -14,7 +14,7 @@
# --tz TIMEZONE Timezone (default: system timezone or UTC)
# --build Build from source instead of using pre-built image
# --yes, -y Non-interactive mode, accept defaults
# --redirect-990 Add iptables redirect from 990 -> 9990 (Linux only)
# --redirect-990 (Deprecated, no longer needed — FTP binds to port 990 directly)
# --help, -h Show this help message
#
@@ -142,7 +142,7 @@ show_help() {
echo " --tz TIMEZONE Timezone (default: system timezone or UTC)"
echo " --build Build from source instead of using pre-built image"
echo " --yes, -y Non-interactive mode, accept defaults"
echo " --redirect-990 Add iptables redirect from 990 -> 9990 (Linux only)"
echo " --redirect-990 (Deprecated, no longer needed)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
@@ -411,38 +411,13 @@ check_sudo() {
}
configure_iptables_redirect() {
if [[ "$OS_TYPE" != "linux" ]]; then
log_warn "iptables redirect only supported on Linux. Skipping."
return
# Deprecated: FTP now binds directly to port 990 (requires CAP_NET_BIND_SERVICE).
# The iptables 990→9990 redirect is no longer needed and caused issues with
# multi-VP setups (REDIRECT rewrites dest IP to the interface's primary address).
if [[ "$REDIRECT_990" == "true" ]]; then
log_warn "The --redirect-990 flag is deprecated. FTP now binds directly to port 990."
log_warn "No iptables redirect is needed. Skipping."
fi
if [[ "$REDIRECT_990" != "true" ]]; then
return
fi
if ! check_sudo; then
return
fi
log_info "Configuring iptables redirect: 990 -> 9990"
# Check if rule already exists
if sudo iptables -t nat -C PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990 2>/dev/null; then
log_warn "PREROUTING rule already exists. Skipping."
else
sudo iptables -t nat -A PREROUTING -p tcp --dport 990 -j REDIRECT --to-port 9990
log_success "Added PREROUTING redirect rule"
fi
if sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990 2>/dev/null; then
log_warn "OUTPUT rule already exists. Skipping."
else
sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 990 -j REDIRECT --to-port 9990
log_success "Added OUTPUT redirect rule"
fi
log_warn "Note: iptables rules are NOT persistent after reboot."
log_warn "To persist them, install iptables-persistent or use a firewall manager."
}
gather_config() {
@@ -466,19 +441,7 @@ gather_config() {
prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
fi
# Redirect port 990 -> 9990 (Linux only)
if [[ "$OS_TYPE" == "linux" ]]; then
if [[ "$NON_INTERACTIVE" == "true" ]]; then
# In non-interactive mode, only set REDIRECT_990 if explicitly passed
: # Do nothing, REDIRECT_990 is set only if --redirect-990 is passed
elif [[ "$REDIRECT_990" != "true" ]]; then
echo ""
echo "Optional network configuration:"
if prompt_yes_no "Add iptables redirect (990 -> 9990)?" "n"; then
REDIRECT_990="true"
fi
fi
fi
# Note: iptables redirect is no longer needed — FTP binds to port 990 directly.
# Timezone
detect_timezone