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
+19 -12
View File
@@ -121,12 +121,14 @@ describe('API Client Auth Header', () => {
describe('FormData requests include auth header', () => {
it('importProjectFile includes Authorization header', async () => {
// Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)
const originalFetch = global.fetch;
let capturedHeaders: Headers | null = null;
server.use(
http.post('/api/v1/projects/import/file', ({ request }) => {
capturedHeaders = request.headers;
return HttpResponse.json({
global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
if (url.includes('/projects/import/file')) {
capturedHeaders = new Headers(init?.headers);
return Promise.resolve(new Response(JSON.stringify({
id: 1,
name: 'Test Project',
description: '',
@@ -140,16 +142,21 @@ describe('FormData requests include auth header', () => {
updated_at: '2026-01-01T00:00:00Z',
archives: [],
bom_items: [],
});
})
);
}), { status: 200 }));
}
return originalFetch(url, init);
});
setAuthToken('test-token');
const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
await api.importProjectFile(file);
try {
setAuthToken('test-token');
const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
await api.importProjectFile(file);
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
expect(capturedHeaders).not.toBeNull();
expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
} finally {
global.fetch = originalFetch;
}
});
it('exportProjectZip includes Authorization header', async () => {
@@ -42,6 +42,7 @@ const createMockSettings = (overrides = {}) => ({
access_code_set: false,
mode: 'immediate' as const,
model: '3DPrinter-X1-Carbon',
target_printer_id: null as number | null,
status: {
enabled: false,
running: false,
@@ -472,4 +473,100 @@ describe('VirtualPrinterSettings', () => {
});
});
});
describe('proxy mode', () => {
it('renders Proxy mode option', async () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Proxy')).toBeInTheDocument();
expect(screen.getByText('Relay to real printer')).toBeInTheDocument();
});
});
it('highlights proxy mode when selected', async () => {
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
createMockSettings({ mode: 'proxy', target_printer_id: 1 })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
const proxyButton = screen.getByText('Proxy').closest('button');
expect(proxyButton?.className).toContain('border-blue-500');
});
});
it('shows proxy status details when running in proxy mode', async () => {
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
createMockSettings({
enabled: true,
mode: 'proxy',
target_printer_id: 1,
status: {
enabled: true,
running: true,
mode: 'proxy',
name: 'Bambuddy (Proxy)',
serial: '00M00A391800001',
model: '3DPrinter-X1-Carbon',
model_name: 'X1C',
pending_files: 0,
proxy: {
running: true,
target_host: '192.168.1.100',
ftp_port: 9990,
mqtt_port: 8883,
ftp_connections: 1,
mqtt_connections: 2,
},
},
})
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('Status Details')).toBeInTheDocument();
// IP address appears in multiple places (target printer and status details)
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);
});
});
it('shows target printer dropdown in proxy mode', async () => {
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
createMockSettings({ mode: 'proxy' })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Target Printer')).toBeInTheDocument();
expect(screen.getByText('Select a printer...')).toBeInTheDocument();
});
});
it('changes mode to proxy on click', async () => {
const user = userEvent.setup();
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
createMockSettings({ mode: 'proxy' })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Proxy')).toBeInTheDocument();
});
const proxyButton = screen.getByText('Proxy').closest('button');
if (proxyButton) {
await user.click(proxyButton);
}
await waitFor(() => {
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });
});
});
});
});