-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
159 lines (146 loc) · 6.7 KB
/
Copy pathserver.js
File metadata and controls
159 lines (146 loc) · 6.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
const express = require('express');
const si = require('systeminformation');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
function loadEnv() {
try {
const env = fs.readFileSync(path.join(__dirname, '.env'), 'utf8');
for (const line of env.split('\n')) {
const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
if (match && !(match[1] in process.env)) process.env[match[1]] = match[2];
}
} catch { /* environment-only configuration is valid */ }
}
function safeEqual(left, right) {
const a = Buffer.from(String(left));
const b = Buffer.from(String(right));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function parseCookies(header = '') {
return Object.fromEntries(header.split(';').map(part => part.trim().split('='))
.filter(([key, value]) => key && value).map(([key, value]) => [key, decodeURIComponent(value)]));
}
function sanitizeStats(stats) {
return {
...stats,
users: (stats.users || []).map(user => ({
...user,
processes: (user.processes || []).map(({ command: _command, ...process }) => process),
})),
loggedUsers: (stats.loggedUsers || []).map(({ ip: _ip, ...user }) => user),
};
}
async function collectSystemStats() {
const [cpu, mem, load, procs, disks, osInfo, users, time, net] = await Promise.all([
si.cpu(), si.mem(), si.currentLoad(), si.processes(), si.fsSize(),
si.osInfo(), si.users(), si.time(), si.networkStats(),
]);
const byUser = {};
for (const process of procs.list) {
const user = process.user || 'unknown';
byUser[user] ||= { user, cpu: 0, memRss: 0, count: 0, processes: [] };
byUser[user].cpu += process.cpu;
byUser[user].memRss += process.memRss || 0;
byUser[user].count += 1;
byUser[user].processes.push({
pid: process.pid, name: process.name, cpu: +process.cpu.toFixed(1),
memPercent: +process.mem.toFixed(1), memRss: process.memRss || 0,
state: process.state, started: process.started,
});
}
const usersList = Object.values(byUser).map(user => ({
...user, cpu: +user.cpu.toFixed(1),
processes: user.processes.sort((a, b) => b.cpu - a.cpu),
})).sort((a, b) => b.memRss - a.memRss);
return {
timestamp: Date.now(),
os: { distro: osInfo.distro, release: osInfo.release, hostname: osInfo.hostname },
uptime: time.uptime,
cpu: { brand: cpu.brand, cores: cpu.cores, load: +load.currentLoad.toFixed(1), perCore: load.cpus.map(core => +core.load.toFixed(1)) },
memory: { total: mem.total, used: mem.active, free: mem.available, swapTotal: mem.swaptotal, swapUsed: mem.swapused },
disks: disks.map(disk => ({ fs: disk.fs, mount: disk.mount, size: disk.size, used: disk.used, usePercent: disk.use })),
network: net.map(item => ({ iface: item.iface, rxSec: item.rx_sec, txSec: item.tx_sec })),
loggedUsers: users.map(user => ({ user: user.user, tty: user.tty, date: user.date, time: user.time })),
totalProcesses: procs.all, running: procs.running, sleeping: procs.sleeping, users: usersList,
};
}
function createApp({
password = process.env.SM_PASSWORD || '',
nodeEnv = process.env.NODE_ENV || 'development',
collectStats = collectSystemStats,
sessionTtlMs = 12 * 60 * 60 * 1000,
} = {}) {
if (nodeEnv === 'production' && !password) throw new Error('SM_PASSWORD is required in production.');
const app = express();
const sessions = new Map();
const attempts = new Map();
app.disable('x-powered-by');
app.use(express.json({ limit: '4kb' }));
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
});
next();
});
const sessionId = req => parseCookies(req.headers.cookie).sm_session;
const isAuthed = req => {
if (!password && nodeEnv !== 'production') return true;
const id = sessionId(req);
const expiresAt = sessions.get(id);
if (!expiresAt || expiresAt <= Date.now()) { if (id) sessions.delete(id); return false; }
sessions.set(id, Date.now() + sessionTtlMs);
return true;
};
const requireAuth = (req, res, next) => isAuthed(req) ? next() : res.status(401).json({ error: 'Unauthorized' });
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.get('/api/auth-required', (_req, res) => res.json({ required: Boolean(password) || nodeEnv === 'production' }));
app.post('/api/login', (req, res) => {
const key = req.ip;
const recent = (attempts.get(key) || []).filter(time => time > Date.now() - 15 * 60 * 1000);
if (recent.length >= 10) return res.status(429).json({ error: 'Too many attempts' });
if (!safeEqual(req.body?.password || '', password)) {
attempts.set(key, [...recent, Date.now()]);
return res.status(401).json({ error: 'Invalid credentials' });
}
attempts.delete(key);
const id = crypto.randomBytes(32).toString('base64url');
sessions.set(id, Date.now() + sessionTtlMs);
res.setHeader('Set-Cookie', `sm_session=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1000)}${nodeEnv === 'production' ? '; Secure' : ''}`);
res.status(204).end();
});
app.post('/api/logout', requireAuth, (req, res) => {
sessions.delete(sessionId(req));
res.setHeader('Set-Cookie', 'sm_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
res.status(204).end();
});
app.get('/api/stats', requireAuth, async (_req, res) => {
try { res.json(sanitizeStats(await collectStats())); }
catch { res.status(503).json({ error: 'Telemetry unavailable' }); }
});
app.get('/api/stream', requireAuth, (req, res) => {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
res.flushHeaders();
let active = true;
const send = async () => {
if (!active) return;
try { res.write(`data: ${JSON.stringify(sanitizeStats(await collectStats()))}\n\n`); }
catch { res.write('event: telemetry-error\ndata: {}\n\n'); }
};
send();
const interval = setInterval(send, 3000);
req.on('close', () => { active = false; clearInterval(interval); });
});
app.use(express.static(path.join(__dirname, 'public'), { etag: true, maxAge: nodeEnv === 'production' ? '1h' : 0 }));
return app;
}
if (require.main === module) {
loadEnv();
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || '127.0.0.1';
createApp().listen(port, host, () => console.log(`Server Manager listening on http://${host}:${port}`));
}
module.exports = { collectSystemStats, createApp, sanitizeStats };