UNPKG

shell-mirror

Version:

Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.

381 lines (323 loc) 13.1 kB
const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const path = require('path'); const session = require('express-session'); const passport = require('passport'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs'); require('dotenv').config(); require('./auth'); // Configure passport strategies // Enhanced logging to file const LOG_FILE = path.join(__dirname, 'server-debug.log'); function logToFile(message) { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] ${message}\n`; console.log(logEntry.trim()); // Also log to console fs.appendFileSync(LOG_FILE, logEntry); } // Clear previous log fs.writeFileSync(LOG_FILE, `=== Server Debug Log Started ${new Date().toISOString()} ===\n`); const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // --- Session Management --- const sessionParser = session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds } }); app.use(sessionParser); app.use(passport.initialize()); app.use(passport.session()); // --- In-memory data stores --- const agents = new Map(); // Stores connected agent sockets const clients = new Map(); // Stores connected client (browser) sockets const dashboards = new Map(); // Stores connected dashboard sockets const sessions = new Map(); // Maps session IDs to agent/client pairs // --- Environment Detection --- const isLocalEnvironment = process.env.BASE_URL && process.env.BASE_URL.includes('localhost'); const isProduction = process.env.NODE_ENV === 'production'; logToFile(`🔧 Environment: ${isProduction ? 'PRODUCTION' : 'DEVELOPMENT'}`); logToFile(`🔧 Local environment: ${isLocalEnvironment ? 'YES' : 'NO'}`); // --- Static Files and Routes --- app.use(express.static('public')); // Health check endpoint for Heroku app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'Shell Mirror WebSocket Server', timestamp: new Date().toISOString() }); }); // Inject environment info into static files app.get('/', (req, res) => { // LOCAL_TESTING_ONLY: Add local testing indicator if (isLocalEnvironment && !isProduction) { const indexContent = require('fs').readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'); const modifiedContent = indexContent.replace( '<body>', `<body> <!-- LOCAL_TESTING_ONLY --> <div id="local-testing-banner" style="background: #ff9800; color: white; text-align: center; padding: 10px; font-weight: bold;"> 🔧 LOCAL DEVELOPMENT MODE - <a href="/app/local-test" style="color: white; text-decoration: underline;">Direct Local Testing</a> </div> <!-- END LOCAL_TESTING_ONLY -->` ); res.send(modifiedContent); } else { res.sendFile(path.join(__dirname, 'public', 'index.html')); } }); // LOCAL_TESTING_ONLY: Simple bypass for local development if (isLocalEnvironment && !isProduction) { app.get('/auth/local/bypass', (req, res) => { // Create a fake user session for local testing req.login({ id: 'local-test-user', name: 'Local Test User', email: 'test@local.dev' }, (err) => { if (err) return res.status(500).send('Login error'); res.redirect('/app'); }); }); app.get('/app/local-test', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'app', 'local_test.html')); }); } app.get('/app', (req, res) => { // LOCAL_TESTING_ONLY: Allow unauthenticated access in local environment if (isLocalEnvironment && !isProduction) { if (!req.isAuthenticated()) { // Auto-login for local testing req.login({ id: 'local-test-user', name: 'Local Test User', email: 'test@local.dev' }, (err) => { if (err) return res.redirect('/auth/google'); return res.sendFile(path.join(__dirname, 'public', 'app', 'terminal.html')); }); return; } } else { // Production: Strict Google OAuth requirement if (!req.isAuthenticated()) { return res.redirect('/auth/google'); } } res.sendFile(path.join(__dirname, 'public', 'app', 'terminal.html')); }); // --- Authentication Routes --- app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] })); app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login-failed.html' }), (req, res) => res.redirect('/app') ); app.get('/api/auth/status', (req, res) => { if (req.isAuthenticated()) { res.json({ authenticated: true, user: req.user }); } else { res.json({ authenticated: false }); } }); app.post('/api/auth/logout', (req, res, next) => { req.logout(err => { if (err) { return next(err); } res.redirect('/'); }); }); // --- WebRTC Signaling Logic --- wss.on('connection', (ws, req) => { const params = new URLSearchParams(req.url.slice(1)); const role = params.get('role'); const agentId = params.get('agentId'); if (role === 'dashboard') { // --- Dashboard Connection --- sessionParser(req, {}, () => { let userId; let isAuthenticated = false; if (req.session && req.session.passport && req.session.passport.user) { userId = req.session.passport.user.id; isAuthenticated = true; logToFile(`✅ Authenticated dashboard connected: ${userId}`); } else if (isLocalEnvironment && !isProduction) { // LOCAL_TESTING_ONLY: Allow unauthenticated connections for local testing userId = `local-test-dashboard-${uuidv4()}`; logToFile(`🔧 LOCAL TESTING: Unauthenticated dashboard connected: ${userId}`); } else { // Production: Reject unauthenticated connections logToFile('❌ Unauthenticated dashboard rejected in production environment'); ws.close(1008, 'Authentication required'); return; } ws.userId = userId; dashboards.set(userId, ws); // Send initial agent list const agentsList = Array.from(agents.keys()).map(id => ({ id })); ws.send(JSON.stringify({ type: 'agent-list', agents: agentsList })); logToFile(`📊 Sent initial agent list to dashboard: ${agentsList.length} agents`); ws.on('close', () => { logToFile(`🔌 Dashboard disconnected: ${userId}`); dashboards.delete(userId); }); ws.on('message', (message) => { try { const data = JSON.parse(message); if (data.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() })); } } catch (error) { logToFile(`❌ Error handling dashboard message: ${error.message}`); } }); }); } else if (role === 'discovery') { // --- Agent Discovery Connection --- logToFile('📍 Discovery client connected'); ws.on('message', (message) => { const data = JSON.parse(message); if (data.type === 'list-agents') { const agentsList = Array.from(agents.keys()).map(id => ({ id })); logToFile(`📋 Sending agent list: ${JSON.stringify(agentsList)}`); ws.send(JSON.stringify({ type: 'agent-list', agents: agentsList })); ws.close(); // Close discovery connection after sending list } }); } else if (role === 'agent') { // --- Agent Connection --- logToFile(`🖥️ Agent connected: ${agentId}`); ws.agentId = agentId; agents.set(agentId, ws); // Notify all dashboards of new agent notifyAgentConnected(agentId); ws.on('close', () => { logToFile(`🔌 Agent disconnected: ${agentId}`); agents.delete(agentId); // Notify all dashboards of agent disconnection notifyAgentDisconnected(agentId); }); ws.on('message', (message) => handleSignalingMessage(ws, message, 'agent')); } else if (role === 'client') { // --- Client (Browser) Connection --- sessionParser(req, {}, () => { let userId; let isAuthenticated = false; if (req.session && req.session.passport && req.session.passport.user) { userId = req.session.passport.user.id; isAuthenticated = true; logToFile(`✅ Authenticated client connected: ${userId}`); } else if (isLocalEnvironment && !isProduction) { // LOCAL_TESTING_ONLY: Allow unauthenticated connections for local testing userId = `local-test-client-${uuidv4()}`; logToFile(`🔧 LOCAL TESTING: Unauthenticated client connected: ${userId}`); } else { // Production: Reject unauthenticated connections logToFile('❌ Unauthenticated client rejected in production environment'); ws.close(1008, 'Authentication required'); return; } ws.userId = userId; clients.set(userId, ws); // Send the client its assigned ID ws.send(JSON.stringify({ type: 'server-hello', id: userId })); logToFile(`📧 Sent server-hello to client: ${userId}`); ws.on('close', () => { logToFile(`🔌 Client disconnected: ${userId}`); clients.delete(userId); // Notify agent if in a session }); ws.on('message', (message) => handleSignalingMessage(ws, message, 'client')); }); } else { ws.close(1008, 'Invalid role specified'); } }); // --- Dashboard Broadcast Functions --- function broadcastToDashboards(message) { const messageStr = JSON.stringify(message); dashboards.forEach((dashboardWs, userId) => { if (dashboardWs.readyState === WebSocket.OPEN) { dashboardWs.send(messageStr); logToFile(`📡 Broadcasted to dashboard ${userId}: ${message.type}`); } else { // Clean up closed connections dashboards.delete(userId); logToFile(`🔌 Removed closed dashboard connection: ${userId}`); } }); } function notifyAgentConnected(agentId) { broadcastToDashboards({ type: 'agent-connected', agentId: agentId, timestamp: Date.now() }); logToFile(`📢 Notified dashboards of agent connection: ${agentId}`); } function notifyAgentDisconnected(agentId) { broadcastToDashboards({ type: 'agent-disconnected', agentId: agentId, timestamp: Date.now() }); logToFile(`📢 Notified dashboards of agent disconnection: ${agentId}`); } function handleSignalingMessage(ws, rawMessage, senderRole) { try { const message = JSON.parse(rawMessage); logToFile(`📨 Handling message: type=${message.type} from=${message.from} to=${message.to} senderRole=${senderRole}`); if (message.type === 'client-hello') { logToFile(`🔔 CLIENT-HELLO: Client ${message.from} wants to connect to agent ${message.to}`); } const { to: target, from } = message; let targetWs; let targetType; if (senderRole === 'agent') { targetWs = clients.get(target); targetType = 'client'; } else { // sender is 'client' targetWs = agents.get(target); targetType = 'agent'; } logToFile(`🎯 Looking for ${targetType} with ID: ${target}`); logToFile(`📊 Available agents: ${JSON.stringify(Array.from(agents.keys()))}`); logToFile(`📊 Available clients: ${JSON.stringify(Array.from(clients.keys()))}`); if (targetWs && targetWs.readyState === WebSocket.OPEN) { logToFile(`✅ Relaying ${message.type} from ${senderRole} (${from}) to ${targetType} (${target})`); targetWs.send(rawMessage); if (message.type === 'client-hello') { logToFile(`🚀 CLIENT-HELLO successfully relayed to Mac agent!`); } } else { const status = targetWs ? `connection closed (readyState: ${targetWs.readyState})` : 'not found'; logToFile(`❌ FAILED to relay ${message.type}. Target ${targetType} (${target}) ${status}`); if (message.type === 'client-hello') { logToFile(`🚨 CLIENT-HELLO relay failed! Mac agent may have disconnected.`); } } } catch (error) { logToFile(`❌ Error handling signaling message: ${error.message} Raw message: ${rawMessage}`); } } // --- Server Startup --- const PORT = process.env.WS_PORT || process.env.PORT || 8080; const HOST = process.env.WS_HOST || process.env.HOST || '0.0.0.0'; const LOCAL_IP = process.env.WS_LOCAL_IP || process.env.LOCAL_IP || 'localhost'; server.listen(PORT, HOST, () => { const baseUrl = process.env.BASE_URL || `http://${LOCAL_IP}:${PORT}`; logToFile(`✅ Local WebSocket server is running on ${baseUrl}`); logToFile(`🌐 WebSocket URL: ws://${LOCAL_IP}:${PORT}`); logToFile(`🔧 Binding to: ${HOST}:${PORT}`); }); // Graceful shutdown process.on('SIGINT', () => { console.log('SIGINT received, shutting down gracefully...'); // Forcefully close all connected WebSocket clients wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.terminate(); } }); server.close(() => { console.log('Server has been shut down.'); process.exit(0); }); });