diff --git a/CHANGELOG.md b/CHANGELOG.md index a21061acc..422940215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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. - **Camera Window Overlapping Modals** ([#738](https://github.com/maziggy/bambuddy/issues/738)) — Floating camera viewer rendered on top of modals (e.g. Assign Spool), making them unusable. Lowered camera z-index so modals always appear above it. Reported by @maziggy. @@ -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 d9f041950..55022cdcb 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: @@ -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) diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 05f10ca10..95633486f 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -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, diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 61968b4a4..3f27f6c64 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/backend/app/services/virtual_printer/mqtt_server.py @@ -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, 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 c62197a37..ceee45475 100644 Binary files a/docs/images/proxy-mode-diagram.png and b/docs/images/proxy-mode-diagram.png differ diff --git a/docs/migration-vp-ftp-port.md b/docs/migration-vp-ftp-port.md new file mode 100644 index 000000000..7f56b9aff --- /dev/null +++ b/docs/migration-vp-ftp-port.md @@ -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 :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 c62197a37..ceee45475 100644 Binary files a/frontend/docs/proxy-mode-diagram.png and b/frontend/docs/proxy-mode-diagram.png differ diff --git a/install/docker-install.sh b/install/docker-install.sh index 11131e1c2..e43a673a5 100755 --- a/install/docker-install.sh +++ b/install/docker-install.sh @@ -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