web-terminal-server
Version:
Professional web-based terminal server with persistent sessions, live sharing, smart port detection, Cloudflare tunnels, and full CLI support
374 lines (328 loc) • 10.4 kB
JavaScript
const { EventEmitter } = require('events');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
/**
* PortMonitor - Universal port monitoring system
* Detects all processes opening ports on the system
*/
class PortMonitor extends EventEmitter {
constructor(options = {}) {
super();
this.scanInterval = options.scanInterval || 2000; // 2 seconds default
this.knownPorts = new Map(); // port -> processInfo
this.intervalId = null;
this.platform = process.platform;
this.isMonitoring = false;
}
/**
* Start monitoring for port changes
*/
startMonitoring() {
if (this.isMonitoring) {
console.log('Port monitoring already active');
return;
}
console.log(`Starting port monitor (${this.platform}) - scanning every ${this.scanInterval}ms`);
this.isMonitoring = true;
// Initial scan
this.scanPorts();
// Set up interval
this.intervalId = setInterval(() => {
this.scanPorts();
}, this.scanInterval);
}
/**
* Stop monitoring
*/
stopMonitoring() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isMonitoring = false;
console.log('Port monitoring stopped');
}
/**
* Scan for open ports
*/
async scanPorts() {
try {
const currentPorts = await this.getAllOpenPorts();
// Check for new ports
for (const portInfo of currentPorts) {
const key = `${portInfo.port}:${portInfo.protocol}`;
const existing = this.knownPorts.get(key);
if (!existing) {
// New port discovered
console.log(`New port discovered: ${portInfo.port} (${portInfo.processName})`);
this.knownPorts.set(key, portInfo);
this.emit('port:discovered', portInfo);
} else if (existing.pid !== portInfo.pid) {
// Port changed process
console.log(`Port ${portInfo.port} changed process: ${existing.processName} -> ${portInfo.processName}`);
this.knownPorts.set(key, portInfo);
this.emit('port:changed', portInfo);
}
}
// Check for closed ports
for (const [key, portInfo] of this.knownPorts.entries()) {
const stillOpen = currentPorts.find(p =>
`${p.port}:${p.protocol}` === key
);
if (!stillOpen) {
console.log(`Port closed: ${portInfo.port} (${portInfo.processName})`);
this.knownPorts.delete(key);
this.emit('port:closed', portInfo);
}
}
} catch (error) {
console.error('Port scan error:', error);
this.emit('error', error);
}
}
/**
* Get all open ports based on platform
*/
async getAllOpenPorts() {
switch (this.platform) {
case 'darwin':
return await this.scanPortsMacOS();
case 'linux':
return await this.scanPortsLinux();
case 'win32':
return await this.scanPortsWindows();
default:
throw new Error(`Unsupported platform: ${this.platform}`);
}
}
/**
* Scan ports on macOS using lsof
*/
async scanPortsMacOS() {
try {
// lsof -i -P -n | grep LISTEN
const { stdout } = await execAsync('lsof -i -P -n | grep LISTEN');
const ports = [];
const lines = stdout.split('\n').filter(line => line.trim());
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length < 9) continue;
const processName = parts[0];
const pid = parseInt(parts[1]);
const user = parts[2];
const fd = parts[3];
const type = parts[4];
const device = parts[5];
const size = parts[6];
const protocol = parts[7];
const name = parts[8];
// Parse address:port
const match = name.match(/([^:]+):(\d+)$/);
if (match) {
const address = match[1];
const port = parseInt(match[2]);
// Skip localhost-only services if needed
const isLocalOnly = address === '127.0.0.1' || address === 'localhost';
ports.push({
port,
pid,
processName,
user,
protocol: protocol.toLowerCase(),
address,
isLocalOnly,
platform: 'darwin',
raw: line
});
}
}
return ports;
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('lsof command not found. Please install lsof.');
}
return [];
}
}
/**
* Scan ports on Linux using netstat or ss
*/
async scanPortsLinux() {
try {
// Try ss first (newer), fall back to netstat
let stdout;
let command;
try {
const result = await execAsync('ss -tlnp 2>/dev/null');
stdout = result.stdout;
command = 'ss';
} catch {
const result = await execAsync('netstat -tlnp 2>/dev/null');
stdout = result.stdout;
command = 'netstat';
}
const ports = [];
const lines = stdout.split('\n').filter(line => line.trim());
// Skip header
const dataLines = lines.slice(1);
for (const line of dataLines) {
if (command === 'ss') {
// Parse ss output
// State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
const match = line.match(/LISTEN\s+\d+\s+\d+\s+([^:]+):(\d+)\s+[^:]+:\*\s*(.*)?/);
if (match) {
const address = match[1];
const port = parseInt(match[2]);
const processInfo = match[3] || '';
// Extract process name and pid
const processMatch = processInfo.match(/users:\(\("([^"]+)",pid=(\d+)/);
const processName = processMatch ? processMatch[1] : 'unknown';
const pid = processMatch ? parseInt(processMatch[2]) : 0;
ports.push({
port,
pid,
processName,
protocol: 'tcp',
address,
isLocalOnly: address === '127.0.0.1',
platform: 'linux',
raw: line
});
}
} else {
// Parse netstat output
const parts = line.split(/\s+/);
if (parts.length >= 7 && parts[5] === 'LISTEN') {
const localAddress = parts[3];
const match = localAddress.match(/([^:]+):(\d+)$/);
if (match) {
const address = match[1];
const port = parseInt(match[2]);
const processInfo = parts[6] || '-';
// Extract process name and pid
const processMatch = processInfo.match(/(\d+)\/(.+)/);
const pid = processMatch ? parseInt(processMatch[1]) : 0;
const processName = processMatch ? processMatch[2] : 'unknown';
ports.push({
port,
pid,
processName,
protocol: parts[0].toLowerCase(),
address,
isLocalOnly: address === '127.0.0.1',
platform: 'linux',
raw: line
});
}
}
}
}
return ports;
} catch (error) {
return [];
}
}
/**
* Scan ports on Windows using netstat
*/
async scanPortsWindows() {
try {
// netstat -ano | findstr LISTENING
const { stdout } = await execAsync('netstat -ano | findstr LISTENING');
const ports = [];
const lines = stdout.split('\r\n').filter(line => line.trim());
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 5) {
const protocol = parts[0].toLowerCase();
const localAddress = parts[1];
const state = parts[3];
const pid = parseInt(parts[4]);
// Parse address:port
const match = localAddress.match(/([^:]+):(\d+)$/);
if (match) {
const address = match[1];
const port = parseInt(match[2]);
// Get process name from PID
let processName = 'unknown';
try {
const { stdout: tasklistOut } = await execAsync(`tasklist /FI "PID eq ${pid}" /FO CSV | findstr "${pid}"`);
const taskParts = tasklistOut.split(',');
if (taskParts.length > 0) {
processName = taskParts[0].replace(/"/g, '');
}
} catch {}
ports.push({
port,
pid,
processName,
protocol,
address,
isLocalOnly: address === '127.0.0.1',
platform: 'win32',
raw: line
});
}
}
}
return ports;
} catch (error) {
return [];
}
}
/**
* Get current known ports
*/
getKnownPorts() {
return Array.from(this.knownPorts.values());
}
/**
* Check if a specific port is open
*/
isPortOpen(port) {
for (const [key] of this.knownPorts) {
if (key.startsWith(`${port}:`)) {
return true;
}
}
return false;
}
/**
* Get process info for a specific port
*/
getPortInfo(port) {
for (const [key, value] of this.knownPorts) {
if (key.startsWith(`${port}:`)) {
return value;
}
}
return null;
}
/**
* Filter ports by process name
*/
getPortsByProcess(processName) {
return Array.from(this.knownPorts.values()).filter(p =>
p.processName.toLowerCase().includes(processName.toLowerCase())
);
}
/**
* Get statistics
*/
getStats() {
const ports = Array.from(this.knownPorts.values());
const processCounts = {};
ports.forEach(p => {
processCounts[p.processName] = (processCounts[p.processName] || 0) + 1;
});
return {
totalPorts: ports.length,
localPorts: ports.filter(p => p.isLocalOnly).length,
publicPorts: ports.filter(p => !p.isLocalOnly).length,
byProcess: processCounts,
platform: this.platform
};
}
}
module.exports = PortMonitor;