mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
[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:
+2
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
@@ -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 |
@@ -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
|
||||
```
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user