| 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; |