|
|
import express from "express"; |
|
|
import session from "express-session"; |
|
|
import bcrypt from "bcrypt"; |
|
|
import helmet from "helmet"; |
|
|
import { createServer } from "http"; |
|
|
import { Server as IOServer } from "socket.io"; |
|
|
import path from "path"; |
|
|
import { fileURLToPath } from "url"; |
|
|
import dotenv from "dotenv"; |
|
|
import os from "os"; |
|
|
import { spawn, exec } from "child_process"; |
|
|
import { promisify } from "util"; |
|
|
import fs from "fs/promises"; |
|
|
|
|
|
dotenv.config(); |
|
|
const __filename = fileURLToPath(import.meta.url); |
|
|
const __dirname = path.dirname(__filename); |
|
|
const execAsync = promisify(exec); |
|
|
|
|
|
const app = express(); |
|
|
const httpServer = createServer(app); |
|
|
const io = new IOServer(httpServer, { |
|
|
cors: { |
|
|
origin: process.env.NODE_ENV === "production" ? false : ["http://localhost:3000"], |
|
|
methods: ["GET", "POST"] |
|
|
} |
|
|
}); |
|
|
|
|
|
const PORT = process.env.PORT || 3000; |
|
|
const SESSION_SECRET = process.env.SESSION_SECRET || "devsecret"; |
|
|
const PASSWORD_HASH = process.env.WEBTERM_PASSWORD_HASH; |
|
|
|
|
|
|
|
|
const isWindows = process.platform === "win32"; |
|
|
const isLinux = process.platform === "linux"; |
|
|
const isMacOS = process.platform === "darwin"; |
|
|
|
|
|
|
|
|
class SystemMonitor { |
|
|
constructor() { |
|
|
this.cpuHistory = []; |
|
|
this.networkHistory = []; |
|
|
this.previousNetworkStats = null; |
|
|
} |
|
|
|
|
|
async getCPUUsage() { |
|
|
return new Promise((resolve) => { |
|
|
const startMeasure = this.cpuAverage(); |
|
|
setTimeout(() => { |
|
|
const endMeasure = this.cpuAverage(); |
|
|
const idleDifference = endMeasure.idle - startMeasure.idle; |
|
|
const totalDifference = endMeasure.total - startMeasure.total; |
|
|
const percentageCPU = 100 - Math.floor(100 * idleDifference / totalDifference); |
|
|
resolve(Math.max(0, Math.min(100, percentageCPU))); |
|
|
}, 100); |
|
|
}); |
|
|
} |
|
|
|
|
|
cpuAverage() { |
|
|
const cpus = os.cpus(); |
|
|
let user = 0, nice = 0, sys = 0, idle = 0, irq = 0; |
|
|
|
|
|
for (let cpu of cpus) { |
|
|
user += cpu.times.user; |
|
|
nice += cpu.times.nice || 0; |
|
|
sys += cpu.times.sys; |
|
|
irq += cpu.times.irq || 0; |
|
|
idle += cpu.times.idle; |
|
|
} |
|
|
|
|
|
return { |
|
|
idle: idle / cpus.length, |
|
|
total: (user + nice + sys + idle + irq) / cpus.length |
|
|
}; |
|
|
} |
|
|
|
|
|
async getMemoryInfo() { |
|
|
const totalMem = os.totalmem(); |
|
|
const freeMem = os.freemem(); |
|
|
const usedMem = totalMem - freeMem; |
|
|
|
|
|
return { |
|
|
total: Math.round(totalMem / 1024 / 1024), |
|
|
used: Math.round(usedMem / 1024 / 1024), |
|
|
free: Math.round(freeMem / 1024 / 1024), |
|
|
percentage: Math.round((usedMem / totalMem) * 100) |
|
|
}; |
|
|
} |
|
|
|
|
|
async getDiskUsage() { |
|
|
try { |
|
|
if (isWindows) { |
|
|
const { stdout } = await execAsync('wmic logicaldisk get size,freespace,caption /format:csv'); |
|
|
const lines = stdout.split('\n').filter(line => line.includes(':')); |
|
|
|
|
|
if (lines.length > 0) { |
|
|
const parts = lines[0].split(','); |
|
|
if (parts.length >= 3) { |
|
|
const freeSpace = parseInt(parts[1]) || 0; |
|
|
const totalSpace = parseInt(parts[2]) || 1; |
|
|
const usedSpace = totalSpace - freeSpace; |
|
|
const percentage = Math.round((usedSpace / totalSpace) * 100); |
|
|
|
|
|
return { |
|
|
total: Math.round(totalSpace / 1024 / 1024 / 1024), |
|
|
used: Math.round(usedSpace / 1024 / 1024 / 1024), |
|
|
free: Math.round(freeSpace / 1024 / 1024 / 1024), |
|
|
percentage: percentage |
|
|
}; |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
const { stdout } = await execAsync('df -h / | tail -1'); |
|
|
const parts = stdout.trim().split(/\s+/); |
|
|
|
|
|
if (parts.length >= 5) { |
|
|
const percentage = parseInt(parts[4].replace('%', '')) || 0; |
|
|
return { |
|
|
total: parts[1], |
|
|
used: parts[2], |
|
|
free: parts[3], |
|
|
percentage: percentage |
|
|
}; |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn('Disk usage detection failed:', error.message); |
|
|
} |
|
|
|
|
|
return { total: 0, used: 0, free: 0, percentage: 0 }; |
|
|
} |
|
|
|
|
|
async getProcessList() { |
|
|
try { |
|
|
let processes = []; |
|
|
|
|
|
if (isWindows) { |
|
|
const { stdout } = await execAsync( |
|
|
'powershell "Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 Name, CPU | ConvertTo-Csv -NoTypeInformation"' |
|
|
); |
|
|
|
|
|
const lines = stdout.split('\n').slice(1).filter(line => line.trim()); |
|
|
processes = lines.map(line => { |
|
|
const [name, cpu] = line.replace(/"/g, '').split(','); |
|
|
return { |
|
|
name: name || 'Unknown', |
|
|
cpu: parseFloat(cpu) || 0 |
|
|
}; |
|
|
}).filter(p => p.name !== 'Unknown').slice(0, 8); |
|
|
|
|
|
} else { |
|
|
|
|
|
const { stdout } = await execAsync('ps aux --sort=-%cpu | head -9 | tail -8'); |
|
|
const lines = stdout.split('\n').filter(line => line.trim()); |
|
|
|
|
|
processes = lines.map(line => { |
|
|
const parts = line.trim().split(/\s+/); |
|
|
return { |
|
|
name: parts[10] ? path.basename(parts[10]) : 'unknown', |
|
|
cpu: parseFloat(parts[2]) || 0 |
|
|
}; |
|
|
}); |
|
|
} |
|
|
|
|
|
return processes; |
|
|
} catch (error) { |
|
|
console.warn('Process list detection failed:', error.message); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
async getNetworkStats() { |
|
|
try { |
|
|
if (isWindows) { |
|
|
|
|
|
const { stdout } = await execAsync( |
|
|
'powershell -Command "Get-NetAdapterStatistics | Select-Object -Property ReceivedBytes, SentBytes | ConvertTo-Json"' |
|
|
); |
|
|
|
|
|
const stats = JSON.parse(stdout); |
|
|
let totalRx = 0, totalTx = 0; |
|
|
if (Array.isArray(stats)) { |
|
|
stats.forEach(s => { |
|
|
totalRx += parseInt(s.ReceivedBytes) || 0; |
|
|
totalTx += parseInt(s.SentBytes) || 0; |
|
|
}); |
|
|
} else if (stats) { |
|
|
totalRx = parseInt(stats.ReceivedBytes) || 0; |
|
|
totalTx = parseInt(stats.SentBytes) || 0; |
|
|
} |
|
|
|
|
|
if (this.previousNetworkStats) { |
|
|
const rxSpeed = Math.max(0, totalRx - this.previousNetworkStats.rx); |
|
|
const txSpeed = Math.max(0, totalTx - this.previousNetworkStats.tx); |
|
|
this.previousNetworkStats = { rx: totalRx, tx: totalTx }; |
|
|
return { rx: rxSpeed, tx: txSpeed }; |
|
|
} else { |
|
|
this.previousNetworkStats = { rx: totalRx, tx: totalTx }; |
|
|
return { rx: 0, tx: 0 }; |
|
|
} |
|
|
} else { |
|
|
|
|
|
const data = await fs.readFile('/proc/net/dev', 'utf8'); |
|
|
const lines = data.split('\n'); |
|
|
let totalRx = 0, totalTx = 0; |
|
|
|
|
|
for (let line of lines) { |
|
|
if (line.includes(':') && !line.includes('lo:')) { |
|
|
const parts = line.split(':')[1].trim().split(/\s+/); |
|
|
totalRx += parseInt(parts[0]) || 0; |
|
|
totalTx += parseInt(parts[8]) || 0; |
|
|
} |
|
|
} |
|
|
if (this.previousNetworkStats) { |
|
|
const rxSpeed = Math.max(0, totalRx - this.previousNetworkStats.rx); |
|
|
const txSpeed = Math.max(0, totalTx - this.previousNetworkStats.tx); |
|
|
this.previousNetworkStats = { rx: totalRx, tx: totalTx }; |
|
|
return { rx: rxSpeed, tx: txSpeed }; |
|
|
} else { |
|
|
this.previousNetworkStats = { rx: totalRx, tx: totalTx }; |
|
|
return { rx: 0, tx: 0 }; |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn('Network stats detection failed:', error.message); |
|
|
return { rx: 0, tx: 0 }; |
|
|
} |
|
|
} |
|
|
|
|
|
formatUptime(seconds) { |
|
|
const days = Math.floor(seconds / 86400); |
|
|
const hours = Math.floor((seconds % 86400) / 3600); |
|
|
const minutes = Math.floor((seconds % 3600) / 60); |
|
|
|
|
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`; |
|
|
if (hours > 0) return `${hours}h ${minutes}m`; |
|
|
return `${minutes}m`; |
|
|
} |
|
|
|
|
|
async getSystemInfo() { |
|
|
try { |
|
|
const [cpuUsage, memInfo, diskInfo, processes, networkStats] = await Promise.all([ |
|
|
this.getCPUUsage(), |
|
|
this.getMemoryInfo(), |
|
|
this.getDiskUsage(), |
|
|
this.getProcessList(), |
|
|
this.getNetworkStats() |
|
|
]); |
|
|
|
|
|
const cpuInfo = os.cpus()[0]; |
|
|
const uptime = os.uptime(); |
|
|
|
|
|
return { |
|
|
|
|
|
platform: os.platform(), |
|
|
release: os.release(), |
|
|
arch: os.arch(), |
|
|
hostname: os.hostname(), |
|
|
uptime: this.formatUptime(uptime), |
|
|
|
|
|
|
|
|
cpu_model: cpuInfo.model, |
|
|
cpu_cores: os.cpus().length, |
|
|
cpu_usage: cpuUsage, |
|
|
|
|
|
|
|
|
total_mem: memInfo.total, |
|
|
used_mem: memInfo.used, |
|
|
free_mem: memInfo.free, |
|
|
mem_usage: memInfo.percentage, |
|
|
|
|
|
|
|
|
disk_total: diskInfo.total, |
|
|
disk_used: diskInfo.used, |
|
|
disk_free: diskInfo.free, |
|
|
disk_usage: diskInfo.percentage, |
|
|
|
|
|
|
|
|
network_rx: networkStats.rx, |
|
|
network_tx: networkStats.tx, |
|
|
|
|
|
|
|
|
processes: processes, |
|
|
|
|
|
|
|
|
loadavg: isWindows ? [0, 0, 0] : os.loadavg(), |
|
|
|
|
|
|
|
|
user_info: os.userInfo(), |
|
|
|
|
|
timestamp: Date.now() |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('System info error:', error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const systemMonitor = new SystemMonitor(); |
|
|
|
|
|
|
|
|
app.use(helmet({ |
|
|
contentSecurityPolicy: { |
|
|
directives: { |
|
|
defaultSrc: ["'self'"], |
|
|
scriptSrc: [ |
|
|
"'self'", |
|
|
"'unsafe-inline'", |
|
|
"https://cdn.jsdelivr.net" |
|
|
], |
|
|
styleSrc: [ |
|
|
"'self'", |
|
|
"'unsafe-inline'", |
|
|
"https://cdn.jsdelivr.net", |
|
|
"https://fonts.googleapis.com" |
|
|
], |
|
|
fontSrc: [ |
|
|
"'self'", |
|
|
"https://fonts.googleapis.com", |
|
|
"https://fonts.gstatic.com" |
|
|
], |
|
|
imgSrc: ["'self'", "data:"], |
|
|
connectSrc: ["'self'", "ws:", "wss:"] |
|
|
} |
|
|
}, |
|
|
crossOriginEmbedderPolicy: false |
|
|
})); |
|
|
|
|
|
app.use(express.urlencoded({ extended: false })); |
|
|
app.use(express.json()); |
|
|
app.use(express.static(path.join(__dirname, "public"))); |
|
|
app.set("view engine", "ejs"); |
|
|
app.set("views", path.join(__dirname, "views")); |
|
|
|
|
|
|
|
|
app.use( |
|
|
session({ |
|
|
secret: SESSION_SECRET, |
|
|
resave: false, |
|
|
saveUninitialized: false, |
|
|
cookie: { |
|
|
httpOnly: true, |
|
|
sameSite: "lax", |
|
|
secure: process.env.NODE_ENV === "production", |
|
|
maxAge: 24 * 60 * 60 * 1000 |
|
|
}, |
|
|
}) |
|
|
); |
|
|
|
|
|
|
|
|
function requireAuth(req, res, next) { |
|
|
if (req.session?.authed) return next(); |
|
|
return res.redirect("/login"); |
|
|
} |
|
|
|
|
|
|
|
|
app.get("/login", (req, res) => { |
|
|
if (req.session?.authed) return res.redirect("/"); |
|
|
res.render("login", { error: null }); |
|
|
}); |
|
|
|
|
|
app.post("/login", async (req, res) => { |
|
|
const { password } = req.body; |
|
|
try { |
|
|
if (!PASSWORD_HASH) { |
|
|
console.error("PASSWORD_HASH not set in environment variables"); |
|
|
return res.render("login", { error: "Server configuration error" }); |
|
|
} |
|
|
|
|
|
const isValid = await bcrypt.compare(password || "", PASSWORD_HASH); |
|
|
if (isValid) { |
|
|
req.session.authed = true; |
|
|
return res.redirect("/"); |
|
|
} else { |
|
|
return res.render("login", { error: "Invalid password" }); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("Login error:", error); |
|
|
return res.render("login", { error: "Authentication error" }); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.get("/logout", (req, res) => { |
|
|
req.session.destroy(() => res.redirect("/login")); |
|
|
}); |
|
|
|
|
|
app.get("/", requireAuth, (req, res) => { |
|
|
res.render("terminal"); |
|
|
}); |
|
|
|
|
|
app.get("/sysinfo", requireAuth, async (req, res) => { |
|
|
try { |
|
|
const systemInfo = await systemMonitor.getSystemInfo(); |
|
|
if (systemInfo) { |
|
|
res.json(systemInfo); |
|
|
} else { |
|
|
res.status(500).json({ error: "Failed to fetch system information" }); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("System info endpoint error:", error); |
|
|
res.status(500).json({ error: "Internal server error" }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
class Terminal { |
|
|
constructor(socket) { |
|
|
this.socket = socket; |
|
|
this.process = null; |
|
|
this.isActive = true; |
|
|
} |
|
|
|
|
|
async initialize() { |
|
|
try { |
|
|
|
|
|
const nodePty = await import("node-pty"); |
|
|
return this.initWithPty(nodePty); |
|
|
} catch (error) { |
|
|
console.warn("node-pty not available, using fallback:", error.message); |
|
|
return this.initWithSpawn(); |
|
|
} |
|
|
} |
|
|
|
|
|
initWithPty(nodePty) { |
|
|
const shell = this.getShell(); |
|
|
const env = { ...process.env }; |
|
|
|
|
|
|
|
|
if (isWindows) { |
|
|
env.TERM = 'windows-ansi'; |
|
|
} else { |
|
|
env.TERM = 'xterm-256color'; |
|
|
env.COLORTERM = 'truecolor'; |
|
|
} |
|
|
|
|
|
this.process = nodePty.spawn(shell, [], { |
|
|
name: isWindows ? 'windows-ansi' : 'xterm-256color', |
|
|
cols: 80, |
|
|
rows: 24, |
|
|
cwd: this.getInitialDirectory(), |
|
|
env: env, |
|
|
useConpty: isWindows, |
|
|
}); |
|
|
|
|
|
this.process.onData(data => { |
|
|
if (this.isActive) { |
|
|
this.socket.emit("terminal_output", data); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.process.onExit(() => { |
|
|
if (this.isActive) { |
|
|
this.socket.emit("terminal_output", "\r\n\x1b[91mTerminal session ended\x1b[0m\r\n"); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
this.socket.on("terminal_input", (data) => { |
|
|
if (this.process && this.isActive) { |
|
|
this.process.write(data); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.socket.on("terminal_resize", ({ cols, rows }) => { |
|
|
if (this.process && this.isActive) { |
|
|
this.process.resize(cols || 80, rows || 24); |
|
|
} |
|
|
}); |
|
|
|
|
|
console.log(`Terminal initialized with node-pty (${shell})`); |
|
|
return true; |
|
|
} |
|
|
|
|
|
initWithSpawn() { |
|
|
const shell = this.getShell(); |
|
|
const args = this.getShellArgs(); |
|
|
|
|
|
this.process = spawn(shell, args, { |
|
|
cwd: this.getInitialDirectory(), |
|
|
env: { ...process.env, TERM: isWindows ? 'windows-ansi' : 'xterm-256color' }, |
|
|
stdio: ['pipe', 'pipe', 'pipe'] |
|
|
}); |
|
|
|
|
|
this.process.stdout.on("data", (data) => { |
|
|
if (this.isActive) { |
|
|
this.socket.emit("terminal_output", data.toString()); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.process.stderr.on("data", (data) => { |
|
|
if (this.isActive) { |
|
|
this.socket.emit("terminal_output", data.toString()); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.process.on("exit", (code) => { |
|
|
if (this.isActive) { |
|
|
this.socket.emit("terminal_output", `\r\n\x1b[91mProcess exited with code ${code}\x1b[0m\r\n`); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.socket.on("terminal_input", (data) => { |
|
|
if (this.process && this.process.stdin && this.isActive) { |
|
|
this.process.stdin.write(data); |
|
|
} |
|
|
}); |
|
|
|
|
|
console.log(`Terminal initialized with spawn fallback (${shell})`); |
|
|
return true; |
|
|
} |
|
|
|
|
|
getShell() { |
|
|
if (isWindows) { |
|
|
return process.env.COMSPEC || "cmd.exe"; |
|
|
} else { |
|
|
return process.env.SHELL || "/bin/bash"; |
|
|
} |
|
|
} |
|
|
|
|
|
getShellArgs() { |
|
|
if (isWindows) { |
|
|
return []; |
|
|
} else { |
|
|
return ["-l"]; |
|
|
} |
|
|
} |
|
|
|
|
|
getInitialDirectory() { |
|
|
let dir; |
|
|
if (isWindows) { |
|
|
dir = process.env.USERPROFILE || process.cwd(); |
|
|
} else { |
|
|
dir = process.env.HOME || process.cwd(); |
|
|
} |
|
|
try { |
|
|
require('fs').accessSync(dir, require('fs').constants.R_OK | require('fs').constants.X_OK); |
|
|
return dir; |
|
|
} catch (e) { |
|
|
console.warn(`Initial directory "${dir}" not accessible, fallback to process.cwd()`); |
|
|
return process.cwd(); |
|
|
} |
|
|
} |
|
|
|
|
|
destroy() { |
|
|
this.isActive = false; |
|
|
if (this.process) { |
|
|
try { |
|
|
if (typeof this.process.kill === 'function') { |
|
|
this.process.kill(); |
|
|
} else if (typeof this.process.destroy === 'function') { |
|
|
this.process.destroy(); |
|
|
} |
|
|
} catch (error) { |
|
|
console.warn("Error destroying terminal process:", error.message); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
io.on("connection", async (socket) => { |
|
|
console.log(`Socket connected: ${socket.id} from ${socket.handshake.address}`); |
|
|
|
|
|
|
|
|
const terminal = new Terminal(socket); |
|
|
const initSuccess = await terminal.initialize(); |
|
|
|
|
|
if (!initSuccess) { |
|
|
socket.emit("terminal_output", "\r\n\x1b[91mFailed to initialize terminal\x1b[0m\r\n"); |
|
|
} |
|
|
|
|
|
|
|
|
const welcomeMessage = |
|
|
`\x1b[36mWelcome to Web Terminal\x1b[0m\r\n` + |
|
|
`\x1b[90mPlatform:\x1b[0m ${process.platform} (${process.arch})\r\n` + |
|
|
`\x1b[90mShell:\x1b[0m ${terminal.getShell()}\r\n` + |
|
|
`\x1b[90mType 'help' or 'exit' as needed.\x1b[0m\r\n\r\n`; |
|
|
|
|
|
socket.emit("terminal_output", welcomeMessage); |
|
|
|
|
|
|
|
|
const systemInfoInterval = setInterval(async () => { |
|
|
try { |
|
|
const systemInfo = await systemMonitor.getSystemInfo(); |
|
|
if (systemInfo && socket.connected) { |
|
|
socket.emit("system_info", systemInfo); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error("System monitoring error:", error); |
|
|
} |
|
|
}, 2000); |
|
|
|
|
|
|
|
|
socket.on("disconnect", () => { |
|
|
console.log(`Socket disconnected: ${socket.id}`); |
|
|
clearInterval(systemInfoInterval); |
|
|
terminal.destroy(); |
|
|
}); |
|
|
|
|
|
|
|
|
socket.on("error", (error) => { |
|
|
console.error(`Socket error for ${socket.id}:`, error); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
process.on('uncaughtException', (error) => { |
|
|
console.error('Uncaught Exception:', error); |
|
|
}); |
|
|
|
|
|
process.on('unhandledRejection', (reason, promise) => { |
|
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
httpServer.listen(PORT, () => { |
|
|
console.log(`β
Web Terminal Server running on http://localhost:${PORT}`); |
|
|
console.log(`π Platform: ${process.platform} (${process.arch})`); |
|
|
console.log(`π Default Shell: ${isWindows ? process.env.COMSPEC || "cmd.exe" : process.env.SHELL || "/bin/bash"}`); |
|
|
console.log(`π Authentication: ${PASSWORD_HASH ? 'Enabled' : 'Disabled (set WEBTERM_PASSWORD_HASH)'}`); |
|
|
}); |
|
|
|
|
|
export default app; |