UNPKG

navflow-proxy-server

Version:

Dynamic WebSocket proxy server for NavFlow

398 lines (333 loc) 11.8 kB
/** * NavFlow Proxy Server - Dynamic WebSocket tunneling with subdomain routing */ const express = require('express'); const { createServer } = require('http'); const WebSocket = require('ws'); const cors = require('cors'); const TunnelManager = require('./tunnelManager'); const DeviceManager = require('./deviceManager'); const { createAuthMiddleware } = require('./auth'); const { createProxyHandler, handleCorsOptions } = require('./proxyHandler'); const app = express(); const PORT = process.env.PORT || 8080; // Initialize tunnel manager and device manager const tunnelManager = new TunnelManager(); const deviceManager = new DeviceManager(); // Middleware app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Tunnel-Auth', 'X-Tunnel-ID'] })); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true })); // Add request logging app.use((req, res, next) => { const tunnelId = req.headers['x-tunnel-id']; console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - Tunnel: ${tunnelId || 'none'}`); next(); }); // Authentication middleware (applied to all routes except system routes) app.use(createAuthMiddleware(tunnelManager)); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), activeTunnels: tunnelManager.tunnels.size }); }); // Stats endpoint for monitoring app.get('/stats', (req, res) => { res.json(tunnelManager.getStats()); }); // Device registration endpoint app.post('/api/devices/register', async (req, res) => { try { const { apiKey, ipAddress, port, deviceInfo } = req.body; // Validate required fields if (!apiKey) { return res.status(400).json({ error: 'Missing required fields', message: 'apiKey is required' }); } // Register the device const device = await deviceManager.registerDevice( apiKey, ipAddress || 'localhost', port || 3002, deviceInfo || null ); res.json({ success: true, device: device, message: 'Device registered successfully' }); } catch (error) { console.error('Device registration error:', error); res.status(400).json({ error: 'Device registration failed', message: error.message }); } }); // Get device by API key endpoint app.get('/api/devices/by-api-key/:apiKey', async (req, res) => { try { const { apiKey } = req.params; if (!apiKey) { return res.status(400).json({ error: 'Missing API key', message: 'API key is required' }); } const device = await deviceManager.getDeviceByApiKey(apiKey); if (!device) { return res.status(404).json({ error: 'Device not found', message: 'No device found with the provided API key' }); } res.json({ success: true, device: device }); } catch (error) { console.error('Get device error:', error); res.status(500).json({ error: 'Failed to retrieve device', message: error.message }); } }); // Test Firestore connection endpoint app.get('/api/test/firestore', async (req, res) => { try { if (!deviceManager.initialized) { return res.status(500).json({ error: 'Firebase not initialized', message: 'DeviceManager Firebase initialization failed' }); } // Test write operation const testData = { test: true, timestamp: new Date().toISOString(), message: 'Firestore connection test' }; const docRef = await deviceManager.db.collection('_test').add(testData); // Test read operation const testDoc = await docRef.get(); // Clean up test document await docRef.delete(); res.json({ success: true, message: 'Firestore connection test successful', permissions: { read: true, write: true, delete: true }, testDocumentId: docRef.id }); } catch (error) { console.error('Firestore test error:', error); let errorDetails = { success: false, message: 'Firestore connection test failed', error: error.message, code: error.code }; if (error.code === 'permission-denied') { errorDetails.message = 'Service account lacks Firestore permissions'; errorDetails.suggestion = 'Grant Firestore Database Admin role to runtime@buildship-pfw15o.iam.gserviceaccount.com'; } else if (error.code === 'unauthenticated') { errorDetails.message = 'Service account authentication failed'; errorDetails.suggestion = 'Check Cloud Run service account configuration'; } res.status(500).json(errorDetails); } }); // Root endpoint info app.get('/', (req, res) => { res.json({ message: 'NavFlow Proxy Server', version: '1.0.0', activeTunnels: tunnelManager.tunnels.size, instructions: 'Connect your local server via WebSocket to get tunnel credentials. Include X-Tunnel-ID and X-Tunnel-Auth headers to access your local server.' }); }); // Handle CORS preflight for all routes app.options('*', handleCorsOptions); // Main proxy handler - forwards all other requests through tunnels const proxyHandler = createProxyHandler(tunnelManager); app.all('*', proxyHandler); // Create HTTP server const server = createServer(app); // Handle WebSocket upgrade manually for Cloud Run compatibility server.on('upgrade', (request, socket, head) => { const pathname = new URL(request.url, 'http://localhost').pathname; if (pathname === '/tunnel') { wss.handleUpgrade(request, socket, head, (websocket) => { wss.emit('connection', websocket, request); }); } else if (pathname === '/client') { clientWss.handleUpgrade(request, socket, head, (websocket) => { clientWss.emit('connection', websocket, request); }); } else { socket.destroy(); } }); // WebSocket server for tunnel connections const wss = new WebSocket.Server({ noServer: true }); // WebSocket server for client connections (frontend) const clientWss = new WebSocket.Server({ noServer: true }); wss.on('connection', (ws, req) => { console.log('[WebSocket] New tunnel connection established'); // Register the tunnel and get credentials const credentials = tunnelManager.registerTunnel(ws); // Send tunnel credentials to the local server ws.send(JSON.stringify({ type: 'tunnel_registered', tunnelId: credentials.tunnelId, password: credentials.password, message: 'Tunnel established successfully' })); // Handle incoming messages from local server ws.on('message', (data) => { try { const message = JSON.parse(data); if (message.type === 'ping') { // Respond to ping ws.send(JSON.stringify({ type: 'pong', timestamp: new Date().toISOString() })); } else if (message.type === 'tunnel_info') { // Local server requesting tunnel information ws.send(JSON.stringify({ type: 'tunnel_info_response', tunnelId: credentials.tunnelId, password: credentials.password })); } else if (message.type === 'webrtc_offer') { // Forward WebRTC offer to connected clients tunnelManager.broadcastToClients(credentials.tunnelId, message); } else if (message.type === 'webrtc_answer') { // Forward WebRTC answer to connected clients tunnelManager.broadcastToClients(credentials.tunnelId, message); } else if (message.type === 'webrtc_ice_candidate') { // Forward ICE candidate to connected clients tunnelManager.broadcastToClients(credentials.tunnelId, message); } } catch (error) { console.error('[WebSocket] Error parsing message:', error); } }); ws.on('close', () => { console.log(`[WebSocket] Tunnel ${credentials.tunnelId} disconnected`); }); ws.on('error', (error) => { console.error(`[WebSocket] Error for tunnel ${credentials.tunnelId}:`, error); }); }); // Handle client WebSocket connections (frontend) clientWss.on('connection', (ws, req) => { console.log('[Client WebSocket] New client connection established'); let clientTunnelId = null; let isAuthenticated = false; ws.on('message', (data) => { try { const message = JSON.parse(data); if (message.type === 'authenticate') { // Authenticate client with tunnel credentials const { tunnelId, password } = message; if (tunnelManager.authenticateClient(tunnelId, password)) { clientTunnelId = tunnelId; isAuthenticated = true; tunnelManager.addClient(tunnelId, ws); ws.send(JSON.stringify({ type: 'authenticated', tunnelId: tunnelId, message: 'Client authenticated successfully' })); console.log(`[Client WebSocket] Client authenticated for tunnel ${tunnelId}`); } else { ws.send(JSON.stringify({ type: 'authentication_failed', message: 'Invalid tunnel credentials' })); ws.close(); } } else if (isAuthenticated) { // Forward WebRTC signaling messages and screen share requests to tunnel if (message.type === 'webrtc_offer' || message.type === 'webrtc_answer' || message.type === 'webrtc_ice_candidate' || message.type === 'start_screen_share') { tunnelManager.sendToTunnel(clientTunnelId, message); } } else { ws.send(JSON.stringify({ type: 'error', message: 'Client not authenticated' })); } } catch (error) { console.error('[Client WebSocket] Error parsing message:', error); } }); ws.on('close', () => { if (clientTunnelId) { tunnelManager.removeClient(clientTunnelId, ws); console.log(`[Client WebSocket] Client disconnected from tunnel ${clientTunnelId}`); } }); ws.on('error', (error) => { console.error('[Client WebSocket] Error:', error); }); }); // Periodic cleanup and maintenance setInterval(() => { tunnelManager.cleanupInactiveTunnels(); tunnelManager.pingTunnels(); }, 60000); // Every minute // Start server server.listen(PORT, () => { console.log(`🚀 NavFlow Proxy Server running on port ${PORT}`); console.log(`💡 Tunnel WebSocket: ws://localhost:${PORT}/tunnel`); console.log(`🔗 Client WebSocket: ws://localhost:${PORT}/client`); console.log(`🌐 Health check: http://localhost:${PORT}/health`); console.log(`📊 Stats: http://localhost:${PORT}/stats`); console.log(''); console.log('🔧 How to use:'); console.log('1. Start your local browser server (connects to /tunnel)'); console.log('2. It will connect and receive tunnel credentials'); console.log('3. Configure your frontend with the tunnel ID and password'); console.log('4. Frontend connects to /client for WebRTC signaling'); console.log('5. Include X-Tunnel-ID and X-Tunnel-Auth headers in HTTP requests'); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('Shutting down proxy server...'); // Close all tunnel connections for (const [tunnelId, tunnel] of tunnelManager.tunnels.entries()) { tunnel.websocket.close(); } server.close(() => { console.log('Proxy server shut down'); process.exit(0); }); }); process.on('SIGINT', () => { console.log('Shutting down proxy server...'); // Close all tunnel connections for (const [tunnelId, tunnel] of tunnelManager.tunnels.entries()) { tunnel.websocket.close(); } server.close(() => { console.log('Proxy server shut down'); process.exit(0); }); });