Browse Source

[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
maziggy 2 months ago
parent
commit
82d329d85c

+ 2 - 1
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.
 - **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
 ### 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.
 - **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.
 - **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.
 - **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**:
 - **Virtual Printer Proxy Mode**:
   - New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
   - 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
   - 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
   - Slicer connects to Bambuddy using the real printer's access code
   - Real-time status display showing active FTP/MQTT connections
   - Real-time status display showing active FTP/MQTT connections
   - Target printer selector with validation (must be configured in Bambuddy)
   - Target printer selector with validation (must be configured in Bambuddy)

+ 1 - 1
Dockerfile

@@ -57,7 +57,7 @@ EXPOSE 3000
 EXPOSE 3002
 EXPOSE 3002
 EXPOSE 8000
 EXPOSE 8000
 EXPOSE 8883
 EXPOSE 8883
-EXPOSE 9990
+EXPOSE 990
 EXPOSE 50000-50100
 EXPOSE 50000-50100
 
 
 # Health check (uses PORT env var via shell)
 # Health check (uses PORT env var via shell)

+ 7 - 3
backend/app/services/virtual_printer/ftp_server.py

@@ -17,8 +17,12 @@ from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 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:
 class FTPSession:
@@ -568,7 +572,7 @@ class VirtualPrinterFTPServer:
         if self._running:
         if self._running:
             return
             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
         # Ensure upload directory exists
         self.upload_dir.mkdir(parents=True, exist_ok=True)
         self.upload_dir.mkdir(parents=True, exist_ok=True)

+ 1 - 1
backend/app/services/virtual_printer/manager.py

@@ -689,7 +689,7 @@ class VirtualPrinterManager:
                 )
                 )
                 self._instances[vp.id] = instance
                 self._instances[vp.id] = instance
                 await instance.start_proxy()
                 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:
             else:
                 instance = VirtualPrinterInstance(
                 instance = VirtualPrinterInstance(
                     vp_id=vp.id,
                     vp_id=vp.id,

+ 0 - 14
backend/app/services/virtual_printer/mqtt_server.py

@@ -262,20 +262,6 @@ class SimpleMQTTServer:
                 except Exception as e:
                 except Exception as e:
                     logger.error("MQTT connection handler error: %s", 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(
             self._server = await asyncio.start_server(
                 connection_handler,
                 connection_handler,
                 self.bind_address,
                 self.bind_address,

+ 14 - 2
backend/app/services/virtual_printer/ssdp_server.py

@@ -232,7 +232,13 @@ class VirtualPrinterSSDPServer:
         try:
         try:
             msg = self._build_notify_message()
             msg = self._build_notify_message()
             self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
             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:
         except OSError as e:
             logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
             logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
 
 
@@ -278,7 +284,13 @@ class VirtualPrinterSSDPServer:
             try:
             try:
                 response = self._build_response_message()
                 response = self._build_response_message()
                 self._socket.sendto(response, addr)
                 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:
             except OSError as e:
                 logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
                 logger.debug("Failed to send SSDP response for %s: %s", self.name, e)
 
 

+ 4 - 4
backend/app/services/virtual_printer/tcp_proxy.py

@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
 def detect_port_redirect(port: int) -> int | None:
 def detect_port_redirect(port: int) -> int | None:
     """Detect if iptables redirects a port to another port.
     """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
     to the original port never reach our socket because iptables intercepts
     them in PREROUTING. We must listen on the redirect target instead.
     them in PREROUTING. We must listen on the redirect target instead.
 
 
@@ -1087,9 +1087,9 @@ class SlicerProxyManager:
         """Start FTP and MQTT TLS proxies."""
         """Start FTP and MQTT TLS proxies."""
         logger.info("Starting slicer TLS proxy to %s", self.target_host)
         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
         ftp_listen_port = self.LOCAL_FTP_PORT
         redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
         redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
         if redirect_target:
         if redirect_target:

+ 20 - 0
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."
+        )

+ 28 - 0
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."
+                )

+ 1 - 1
docker-compose.yml

@@ -26,7 +26,7 @@ services:
     #  - "3000:3000"                  # Virtual printer bind/detect
     #  - "3000:3000"                  # Virtual printer bind/detect
     #  - "3002:3002"                  # Virtual printer bind/detect
     #  - "3002:3002"                  # Virtual printer bind/detect
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "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
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data

BIN
docs/images/proxy-mode-diagram.png


+ 83 - 0
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 <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
frontend/docs/create_proxy_diagram.py

@@ -284,7 +284,7 @@ def create_diagram():
 
 
     # Ports on remote side
     # 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 + 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 ===
     # === INTERNET CLOUD ===
     draw_cloud_icon(draw, internet_x, section_y, 80, INTERNET_COLOR)
     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],
     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)
                       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 + 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],
     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)
                       6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)

BIN
frontend/docs/proxy-mode-diagram.png


+ 9 - 46
install/docker-install.sh

@@ -14,7 +14,7 @@
 #   --tz TIMEZONE      Timezone (default: system timezone or UTC)
 #   --tz TIMEZONE      Timezone (default: system timezone or UTC)
 #   --build            Build from source instead of using pre-built image
 #   --build            Build from source instead of using pre-built image
 #   --yes, -y          Non-interactive mode, accept defaults
 #   --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
 #   --help, -h         Show this help message
 #
 #
 
 
@@ -142,7 +142,7 @@ show_help() {
     echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
     echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
     echo "  --build            Build from source instead of using pre-built image"
     echo "  --build            Build from source instead of using pre-built image"
     echo "  --yes, -y          Non-interactive mode, accept defaults"
     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 "  --help, -h         Show this help message"
     echo ""
     echo ""
     echo "Examples:"
     echo "Examples:"
@@ -411,38 +411,13 @@ check_sudo() {
 }
 }
 
 
 configure_iptables_redirect() {
 configure_iptables_redirect() {
-    if [[ "$OS_TYPE" != "linux" ]]; then
-        log_warn "iptables redirect only supported on Linux. Skipping."
-        return
-    fi
-
-    if [[ "$REDIRECT_990" != "true" ]]; then
-        return
-    fi
-
-    if ! check_sudo; then
-        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
     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() {
 gather_config() {
@@ -466,19 +441,7 @@ gather_config() {
         prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
         prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
     fi
     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
     # Timezone
     detect_timezone
     detect_timezone