Add Virtual Printer Proxy Mode for remote printing

Introduces a new "Proxy Mode" for the Virtual Printer that enables
remote printing from anywhere in the world without VPN, port forwarding,
or Bambu Cloud dependency.

Bambuddy acts as a TLS relay between a remote slicer (Bambu Studio/
OrcaSlicer) and the local Bambu Lab printer:

  Remote Slicer → Internet → Bambuddy Server → Local Network → Printer

The slicer connects to Bambuddy using the real printer's serial number
and access code. Bambuddy authenticates and relays all FTP (file transfer)
and MQTT (commands/status) traffic with end-to-end TLS encryption.

- No port forwarding required - printer stays safely on local network
- No VPN needed - connect from coffee shops, hotels, work, anywhere
- No Bambu Cloud dependency - fully self-hosted solution
- End-to-end TLS encryption on FTP (port 9990) and MQTT (port 8883)
- Works with Bambu Studio and OrcaSlicer
- Uses real printer credentials for authentication
- Automatic printer selection from connected printers

- Add SlicerProxyManager class for TLS relay (tcp_proxy.py)
  - TLS termination with auto-generated certificates
  - Concurrent FTP and MQTT proxy servers
  - Connection lifecycle management with proper cleanup
- Extend VirtualPrinterManager with proxy mode support
  - New 'proxy' mode alongside archive/review/queue modes
  - Target printer selection and credential management
- Add proxy configuration endpoints to settings API
- Add permission checks for proxy endpoints

- Add Proxy Mode card to Virtual Printer settings
- Target printer dropdown for proxy destination
- Real-time proxy status display (ports, target, running state)
- Full i18n support (English, German)

- Add network architecture diagram
- Add proxy mode section to README
- Add comprehensive guide to wiki
- Add prominent feature section to website

- Backend unit tests for SlicerProxyManager
- Backend unit tests for proxy mode configuration
- Frontend tests for proxy mode UI components

Closes #207 #170
This commit is contained in:
maziggy
2026-02-03 11:02:13 +01:00
parent 158fa88b7a
commit 583c374f01
10 changed files with 663 additions and 16 deletions
@@ -439,3 +439,107 @@ class TestCertificateService:
assert cert_path.exists()
assert key_path.exists()
class TestSlicerProxyManager:
"""Tests for SlicerProxyManager (proxy mode)."""
@pytest.fixture
def proxy_manager(self, tmp_path):
"""Create a SlicerProxyManager instance."""
from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
# Create dummy cert files
cert_path = tmp_path / "cert.pem"
key_path = tmp_path / "key.pem"
cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
# Split string to avoid pre-commit hook false positive on test data
key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
return SlicerProxyManager(
target_host="192.168.1.100",
cert_path=cert_path,
key_path=key_path,
)
def test_proxy_manager_initializes_ports(self, proxy_manager):
"""Verify proxy manager has correct port constants."""
assert proxy_manager.LOCAL_FTP_PORT == 9990
assert proxy_manager.LOCAL_MQTT_PORT == 8883
assert proxy_manager.PRINTER_FTP_PORT == 990
assert proxy_manager.PRINTER_MQTT_PORT == 8883
def test_proxy_manager_stores_target_host(self, proxy_manager):
"""Verify proxy manager stores target host."""
assert proxy_manager.target_host == "192.168.1.100"
def test_get_status_before_start(self, proxy_manager):
"""Verify get_status returns zeros before start."""
status = proxy_manager.get_status()
assert status["running"] is False
assert status["ftp_connections"] == 0
assert status["mqtt_connections"] == 0
class TestVirtualPrinterManagerProxyMode:
"""Tests for VirtualPrinterManager proxy mode."""
@pytest.fixture
def manager(self):
"""Create a VirtualPrinterManager instance."""
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
return VirtualPrinterManager()
@pytest.mark.asyncio
async def test_configure_proxy_mode_requires_target_ip(self, manager):
"""Verify proxy mode requires target_printer_ip."""
with pytest.raises(ValueError, match="Target printer IP is required"):
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="", # Empty target IP
)
@pytest.mark.asyncio
async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
"""Verify proxy mode does not require access code (uses real printer's)."""
manager._start = AsyncMock()
# Should not raise - proxy mode doesn't need access code
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="192.168.1.100",
)
assert manager._mode == "proxy"
assert manager._target_printer_ip == "192.168.1.100"
def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
"""Verify get_status includes proxy-specific fields in proxy mode."""
manager._enabled = True
manager._mode = "proxy"
manager._target_printer_ip = "192.168.1.100"
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
# Create a mock proxy with get_status
mock_proxy = MagicMock()
mock_proxy.get_status.return_value = {
"running": True,
"ftp_port": 9990,
"mqtt_port": 8883,
"ftp_connections": 1,
"mqtt_connections": 2,
"target_host": "192.168.1.100",
}
manager._proxy = mock_proxy
status = manager.get_status()
assert status["mode"] == "proxy"
assert status["target_printer_ip"] == "192.168.1.100"
assert "proxy" in status
assert status["proxy"]["ftp_connections"] == 1
assert status["proxy"]["mqtt_connections"] == 2