navflow-proxy-server
Version:
Dynamic WebSocket proxy server for NavFlow
398 lines (333 loc) • 11.8 kB
JavaScript
/**
* 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);
});
});