webpty / app.js
Ditzzy AF
fix: Improve Windows network stats accuracy and terminal height
f937534
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;
// Platform detection
const isWindows = process.platform === "win32";
const isLinux = process.platform === "linux";
const isMacOS = process.platform === "darwin";
// === System Utilities ===
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), // MB
used: Math.round(usedMem / 1024 / 1024), // MB
free: Math.round(freeMem / 1024 / 1024), // MB
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), // GB
used: Math.round(usedSpace / 1024 / 1024 / 1024), // GB
free: Math.round(freeSpace / 1024 / 1024 / 1024), // GB
percentage: percentage
};
}
}
} else {
// Linux/macOS
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 {
// Linux/macOS - using ps command
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) {
// Jalankan PowerShell dengan benar
const { stdout } = await execAsync(
'powershell -Command "Get-NetAdapterStatistics | Select-Object -Property ReceivedBytes, SentBytes | ConvertTo-Json"'
);
// Ambil total RX dan TX dari semua adapter
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;
}
// Hitung delta per detik (seperti di Linux)
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 {
// Linux - read from /proc/net/dev
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 {
// System info
platform: os.platform(),
release: os.release(),
arch: os.arch(),
hostname: os.hostname(),
uptime: this.formatUptime(uptime),
// CPU info
cpu_model: cpuInfo.model,
cpu_cores: os.cpus().length,
cpu_usage: cpuUsage,
// Memory info
total_mem: memInfo.total,
used_mem: memInfo.used,
free_mem: memInfo.free,
mem_usage: memInfo.percentage,
// Disk info
disk_total: diskInfo.total,
disk_used: diskInfo.used,
disk_free: diskInfo.free,
disk_usage: diskInfo.percentage,
// Network info (speeds in bytes per second)
network_rx: networkStats.rx,
network_tx: networkStats.tx,
// Process list
processes: processes,
// Load average (Linux/macOS only)
loadavg: isWindows ? [0, 0, 0] : os.loadavg(),
// User info
user_info: os.userInfo(),
timestamp: Date.now()
};
} catch (error) {
console.error('System info error:', error);
return null;
}
}
}
const systemMonitor = new SystemMonitor();
// === Middleware ===
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"));
// === Session ===
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 // 24 hours
},
})
);
// === Auth Middleware ===
function requireAuth(req, res, next) {
if (req.session?.authed) return next();
return res.redirect("/login");
}
// === Routes ===
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" });
}
});
// === Terminal Class for better process management ===
class Terminal {
constructor(socket) {
this.socket = socket;
this.process = null;
this.isActive = true;
}
async initialize() {
try {
// Try to use node-pty first
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 };
// Set better environment for cross-platform compatibility
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, // Use ConPTY on Windows 10+
});
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");
}
});
// Socket event handlers
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"]; // Login shell
}
}
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);
}
}
}
}
// === Socket.IO Connection Handler ===
io.on("connection", async (socket) => {
console.log(`Socket connected: ${socket.id} from ${socket.handshake.address}`);
// Create terminal instance
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");
}
// Send welcome message
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);
// Real-time system monitoring
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);
// Cleanup on disconnect
socket.on("disconnect", () => {
console.log(`Socket disconnected: ${socket.id}`);
clearInterval(systemInfoInterval);
terminal.destroy();
});
// Error handling
socket.on("error", (error) => {
console.error(`Socket error for ${socket.id}:`, error);
});
});
// === Error Handling ===
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
// === Graceful Shutdown ===
// === Start Server ===
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;