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
JavaScript
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();
});
}