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.

536 lines (464 loc) 14.9 kB
const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const pty = require('node-pty'); const os = require('os'); const path = require('path'); const session = require('express-session'); const passport = require('passport'); const https = require('https'); // Load environment configuration and auth setup require('dotenv').config(); require('./auth'); // Configure passport strategies const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // 1. Session Configuration const sessionParser = session({ secret: process.env.SESSION_SECRET || 'a-secure-default-session-secret', resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production' } // Use secure cookies in production }); app.use(sessionParser); // 2. Passport Initialization app.use(passport.initialize()); app.use(passport.session()); // Use the user's default shell. const shell = os.platform() === 'win32' ? 'powershell.exe' : (process.env.SHELL || 'bash'); // --- Persistent Terminal Session --- const term = pty.spawn(shell, [], { name: 'xterm-color', cols: 80, rows: 30, cwd: process.cwd(), env: process.env }); // --- Terminal History Buffer --- let history = []; const MAX_HISTORY_LINES = 100; term.on('data', (data) => { if (history.length > MAX_HISTORY_LINES * 2) { history = history.slice(history.length - MAX_HISTORY_LINES); } history.push(data); }); // Serve static files from 'public' app.use(express.static('public')); // Route for the terminal application (protected) app.get('/app', (req, res) => { if (!req.isAuthenticated()) { return res.redirect('/auth/google'); } res.sendFile(path.join(__dirname, 'public', 'app', 'terminal.html')); }); // 3. Authentication Routes app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] })); app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login-failed.html' }), function(req, res) { // Successful authentication, redirect home. res.redirect('/'); } ); 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) => { req.logout(function(err) { if (err) { return next(err); } res.redirect('/'); }); }); // Environment validation const requiredEnvVars = ['BASE_URL', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'SESSION_SECRET']; const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); if (missingEnvVars.length > 0) { console.error('❌ Missing required environment variables:'); missingEnvVars.forEach(varName => { console.error(` - ${varName}`); }); console.error('\nPlease run "shell-mirror" to set up authentication.'); process.exit(1); } // 4. WebSocket Connection Handler with Authentication wss.on('connection', function connection(ws, request) { // New WebSocket connection attempt // Parse session from the WebSocket request sessionParser(request, {}, () => { if (!request.session || !request.session.passport || !request.session.passport.user) { // Unauthorized WebSocket connection rejected ws.close(1008, 'Authentication required'); return; } // Authenticated WebSocket connection established // Send terminal history to newly connected client if (history.length > 0) { const recentHistory = history.slice(-MAX_HISTORY_LINES).join(''); ws.send(recentHistory); } // Setup heartbeat mechanism to prevent connection drops ws.isAlive = true; ws.on('pong', function() { this.isAlive = true; }); // Send ping every 30 seconds const heartbeatInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { if (ws.isAlive === false) { // WebSocket connection dead, terminating return ws.terminate(); } ws.isAlive = false; ws.ping(); } }, 30000); // Cleanup heartbeat on connection close ws.on('close', () => { clearInterval(heartbeatInterval); }); // Forward terminal output to this WebSocket connection const terminalDataHandler = (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }; term.on('data', terminalDataHandler); // Handle incoming WebSocket messages ws.on('message', function message(data) { try { const message = JSON.parse(data); if (message.type === 'input' && message.data) { // Forward user input to terminal term.write(message.data); } else if (message.type === 'resize' && message.cols && message.rows) { // Resize terminal term.resize(message.cols, message.rows); // Terminal resized } } catch (error) { console.error('Invalid WebSocket message:', error); } }); // Handle WebSocket disconnection ws.on('close', function close() { // WebSocket connection closed term.removeListener('data', terminalDataHandler); clearInterval(heartbeatInterval); }); // Handle WebSocket errors ws.on('error', function error(err) { console.error('WebSocket error:', err); term.removeListener('data', terminalDataHandler); clearInterval(heartbeatInterval); }); }); }); // Enhanced logging setup const fs = require('fs'); const logFile = path.join(process.cwd(), 'shell-mirror.log'); function logWithTimestamp(level, message, data = null) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, level, message, data, pid: process.pid, userEmail: process.env.USER_EMAIL || 'unknown' }; const logLine = `[${timestamp}] [${level}] ${message}${data ? ' | ' + JSON.stringify(data) : ''}\n`; // Console output console.log(logLine.trim()); // File output try { fs.appendFileSync(logFile, logLine); } catch (err) { console.error('Failed to write to log file:', err.message); } } // Command polling for Mac agent functionality if (process.env.USER_EMAIL && process.env.ACCESS_TOKEN) { logWithTimestamp('INFO', 'Starting command polling for Mac agent', { userEmail: process.env.USER_EMAIL, accessToken: process.env.ACCESS_TOKEN ? 'present' : 'missing' }); startCommandPolling(); } else { logWithTimestamp('WARN', 'Command polling not started - missing credentials', { hasUserEmail: !!process.env.USER_EMAIL, hasAccessToken: !!process.env.ACCESS_TOKEN }); } const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; server.listen(PORT, HOST, () => { console.log(`✅ Shell Mirror server is running on ${process.env.BASE_URL}`); if (process.env.BASE_URL.includes('localhost')) { console.log(` Local access: http://localhost:${PORT}`); } }); // Mac Agent Registration and Management var agentId = null; var isRegistered = false; function generateAgentId() { const hostname = os.hostname(); const username = os.userInfo().username; const timestamp = Date.now(); return `mac-${username}-${hostname}-${timestamp}`.replace(/[^a-zA-Z0-9-]/g, '-'); } async function registerAgent() { if (!process.env.USER_EMAIL || !process.env.ACCESS_TOKEN) { throw new Error('USER_EMAIL and ACCESS_TOKEN required for agent registration'); } agentId = generateAgentId(); const registrationData = { agentId: agentId, ownerEmail: process.env.USER_EMAIL, ownerName: os.userInfo().username, ownerToken: 'temp-token-placeholder', machineName: os.hostname(), agentVersion: require('./package.json').version, capabilities: ['terminal', 'file_access'] }; const options = { hostname: 'shellmirror.app', port: 443, path: '/php-backend/api/agent-register.php', method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'TerminalMirror-Agent/1.0.0' } }; const postData = JSON.stringify(registrationData); return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => data += chunk); res.on('end', () => { try { const result = JSON.parse(data); if (result.success) { isRegistered = true; logWithTimestamp('INFO', 'Agent registered successfully', { agentId }); resolve(true); } else { reject(new Error('Registration failed: ' + result.message)); } } catch (error) { reject(new Error('Failed to parse registration response: ' + error.message)); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } async function sendHeartbeat() { if (!isRegistered || !agentId) { logWithTimestamp('DEBUG', 'Skipping heartbeat - agent not registered or no agentId'); return; } const heartbeatData = { agentId: agentId, timestamp: Date.now() }; const options = { hostname: 'shellmirror.app', port: 443, path: '/php-backend/api/agent-heartbeat.php', method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'TerminalMirror-Agent/1.0.0', 'X-Agent-Secret': 'mac-agent-secret-2024', 'X-Agent-ID': agentId } }; const postData = JSON.stringify(heartbeatData); return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => data += chunk); res.on('end', () => { try { const result = JSON.parse(data); if (result.success) { resolve(true); } else { reject(new Error('Heartbeat failed: ' + result.message)); } } catch (error) { reject(new Error('Failed to parse heartbeat response: ' + error.message)); } }); }); req.on('error', reject); req.write(postData); req.end(); }); } // Mac Agent Command Polling async function startCommandPolling() { try { await registerAgent(); logWithTimestamp('INFO', 'Command polling started', { pollInterval: 2000 }); // Start heartbeat every 60 seconds setInterval(async () => { try { await sendHeartbeat(); } catch (error) { logWithTimestamp('ERROR', 'Heartbeat failed', { error: error.message }); } }, 60000); // Start command polling every 2 seconds setInterval(async () => { try { await pollForCommands(); } catch (error) { logWithTimestamp('ERROR', 'Command polling error', { error: error.message, stack: error.stack }); } }, 2000); } catch (error) { logWithTimestamp('ERROR', 'Failed to start agent', { error: error.message }); throw error; } } async function pollForCommands() { const options = { hostname: 'shellmirror.app', port: 443, path: '/php-backend/api/agent-poll.php', method: 'POST', headers: { 'Content-Type': 'application/json' } }; const postData = JSON.stringify({ userEmail: process.env.USER_EMAIL, accessToken: 'temp-token-placeholder' }); // Removed verbose debug logging return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { // Removed verbose poll response logging const response = JSON.parse(data); if (response.success && response.data && response.data.commands) { logWithTimestamp('INFO', 'Commands received from server', { commandCount: response.data.commands.length }); response.data.commands.forEach(command => { executeQueuedCommand(command); }); } else { logWithTimestamp('DEBUG', 'No commands in poll response', { response }); } resolve(response); } catch (error) { logWithTimestamp('ERROR', 'Failed to parse poll response', { error: error.message, rawResponse: data }); reject(error); } }); }); req.on('error', (error) => { logWithTimestamp('ERROR', 'Poll request failed', { error: error.message, hostname: options.hostname }); reject(error); }); req.write(postData); req.end(); }); } function executeQueuedCommand(command) { logWithTimestamp('INFO', 'Executing queued command', { commandId: command.id, command: command.command, sessionId: command.sessionId }); // Execute the command in the terminal term.write(command.command + '\r'); // Collect output for a short period let output = ''; const outputCollector = (data) => { output += data; }; term.on('data', outputCollector); // Send response back after collecting output setTimeout(() => { term.removeListener('data', outputCollector); logWithTimestamp('INFO', 'Command execution completed', { commandId: command.id, outputLength: output.length, outputPreview: output.substring(0, 100) }); sendCommandResponse(command.id, output); }, 1000); // Wait 1 second to collect output } async function sendCommandResponse(commandId, output) { const options = { hostname: 'shellmirror.app', port: 443, path: '/php-backend/api/agent-response.php', method: 'POST', headers: { 'Content-Type': 'application/json' } }; const postData = JSON.stringify({ commandId: commandId, output: output, success: true, userEmail: process.env.USER_EMAIL, accessToken: process.env.ACCESS_TOKEN }); logWithTimestamp('INFO', 'Sending command response', { commandId: commandId, outputLength: output.length, hostname: options.hostname }); return new Promise((resolve, reject) => { const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { logWithTimestamp('INFO', 'Command response sent', { commandId: commandId, statusCode: res.statusCode, responsePreview: data.substring(0, 100) }); resolve(data); }); }); req.on('error', (error) => { logWithTimestamp('ERROR', 'Failed to send command response', { commandId: commandId, error: error.message }); reject(error); }); req.write(postData); req.end(); }); }