From fc746ae21866c89d6cc9e04ff00688e36b2585dd Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 18 Mar 2026 09:04:31 +0100 Subject: [PATCH] [Fix] Virtual Printer FTP routed to wrong VP with different access codes (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 3 +- Dockerfile | 2 +- .../services/virtual_printer/ftp_server.py | 10 ++- .../app/services/virtual_printer/manager.py | 2 +- .../services/virtual_printer/mqtt_server.py | 14 --- .../services/virtual_printer/ssdp_server.py | 16 +++- .../app/services/virtual_printer/tcp_proxy.py | 8 +- backend/tests/unit/test_vp_ftp_port.py | 20 +++++ backend/tests/unit/test_vp_mqtt_server.py | 28 ++++++ docker-compose.yml | 2 +- docs/images/proxy-mode-diagram.png | Bin 38049 -> 37897 bytes docs/migration-vp-ftp-port.md | 83 ++++++++++++++++++ frontend/docs/create_proxy_diagram.py | 4 +- frontend/docs/proxy-mode-diagram.png | Bin 38049 -> 37897 bytes install/docker-install.sh | 55 ++---------- 15 files changed, 172 insertions(+), 75 deletions(-) create mode 100644 backend/tests/unit/test_vp_ftp_port.py create mode 100644 backend/tests/unit/test_vp_mqtt_server.py create mode 100644 docs/migration-vp-ftp-port.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1dd1289..4cbd34b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Dockerfile b/Dockerfile index 0b3307da3..feafe7717 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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) diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index 0d2aea62d..d959e6acd 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -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) diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index e7664fa04..9ad0723e2 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -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, diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index e51aa967b..4fdb8cdcc 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -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, diff --git a/backend/app/services/virtual_printer/ssdp_server.py b/backend/app/services/virtual_printer/ssdp_server.py index 8a8e20869..4fd1a735c 100644 --- a/backend/app/services/virtual_printer/ssdp_server.py +++ b/backend/app/services/virtual_printer/ssdp_server.py @@ -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) diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py index 6c651b939..cb7a37689 100644 --- a/backend/app/services/virtual_printer/tcp_proxy.py +++ b/backend/app/services/virtual_printer/tcp_proxy.py @@ -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: diff --git a/backend/tests/unit/test_vp_ftp_port.py b/backend/tests/unit/test_vp_ftp_port.py new file mode 100644 index 000000000..e5d00a2d3 --- /dev/null +++ b/backend/tests/unit/test_vp_ftp_port.py @@ -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." + ) diff --git a/backend/tests/unit/test_vp_mqtt_server.py b/backend/tests/unit/test_vp_mqtt_server.py new file mode 100644 index 000000000..89d735bbc --- /dev/null +++ b/backend/tests/unit/test_vp_mqtt_server.py @@ -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." + ) diff --git a/docker-compose.yml b/docker-compose.yml index 5766751ce..3e6ad2c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/images/proxy-mode-diagram.png b/docs/images/proxy-mode-diagram.png index c62197a37059c01f11904499a553ce8384b544bd..ceee454755c868417f790db478d5d2c11078fccd 100644 GIT binary patch delta 9731 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 m%P7f=UX2U^&UohB;V~r60Gi~j>$?RR4U delta 9877 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 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 :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 +``` diff --git a/frontend/docs/create_proxy_diagram.py b/frontend/docs/create_proxy_diagram.py index 492932fb0..af42f12be 100644 --- a/frontend/docs/create_proxy_diagram.py +++ b/frontend/docs/create_proxy_diagram.py @@ -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) diff --git a/frontend/docs/proxy-mode-diagram.png b/frontend/docs/proxy-mode-diagram.png index c62197a37059c01f11904499a553ce8384b544bd..ceee454755c868417f790db478d5d2c11078fccd 100644 GIT binary patch delta 9731 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 m%P7f=UX2U^&UohB;V~r60Gi~j>$?RR4U delta 9877 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 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