Files
MrTerm/src/web/server.js
T

377 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker).
// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt,
// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM).
// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js).
// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download).
//
// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik)
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { Store } = require('../main/store');
const { createBackend } = require('../core/backend');
const i18n = require('../i18n');
const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..');
const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data'));
const PORT = Number(process.env.PORT) || 8080;
const TRUST_PROXY = process.env.TRUST_PROXY === '1';
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })();
const SESSION_IDLE = 12 * 3600e3;
const SESSION_MAX = 7 * 24 * 3600e3;
const COOKIE = 'mrterm_sid';
process.on('uncaughtException', (e) => console.error('Uncaught exception:', e));
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 });
// ============================================================ Krypto-Helfer
const scrypt = (pw, salt, N = 2 ** 15) => new Promise((resolve, reject) =>
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
function box(key, plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([c.update(plain), c.final()]);
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
}
function unbox(key, b) {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
d.setAuthTag(Buffer.from(b.tag, 'base64'));
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
}
// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage)
const vaultCrypto = (dek) => ({
isEncryptionAvailable: () => true,
encryptString(s) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', dek, iv);
const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
},
decryptString(buf) {
const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
},
});
// ============================================================ Benutzer
const USERS_FILE = path.join(DATA, 'users.json');
let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })();
function saveUsers() {
fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 });
fs.renameSync(USERS_FILE + '.tmp', USERS_FILE);
}
const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || ''));
const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024;
const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin });
async function createUser(name, password, admin) {
if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).');
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.');
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
const dek = crypto.randomBytes(32);
const u = {
id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(),
auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') },
kek: { salt: kekSalt.toString('base64') },
dek: box(await scrypt(password, kekSalt), dek),
};
users.push(u);
saveUsers();
return u;
}
// Prüft das Passwort und liefert den entpackten DEK (oder null)
async function verify(u, password) {
const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64'));
if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null;
try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; }
}
async function setPassword(u, dek, password) {
if (!validPw(password)) throw new Error('The password must be at least 8 characters long.');
const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16);
u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') };
u.kek = { salt: kekSalt.toString('base64') };
u.dek = box(await scrypt(password, kekSalt), dek);
saveUsers();
}
// ============================================================ Sitzungen
const sessions = new Map(); // token -> { userId, dek, created, last }
function newSession(u, dek) {
const token = crypto.randomBytes(32).toString('base64url');
sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() });
return token;
}
function sessionOf(req) {
const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
const s = m && sessions.get(m[1]);
if (!s) return null;
const now = Date.now();
if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; }
s.last = now;
return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) };
}
setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref();
const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https');
const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`;
const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || '';
// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten
const failures = new Map();
const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; };
const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; };
// ============================================================ Laufzeit je Benutzer (Tresor + Backend)
const runtimes = new Map(); // userId -> { store, be, sockets, timer }
function runtimeFor(sess) {
let rt = runtimes.get(sess.userId);
if (rt) { clearTimeout(rt.timer); return rt; }
const dir = path.join(DATA, 'users', sess.userId);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const store = new Store({ dir, crypto: vaultCrypto(sess.dek) });
store.load();
const sockets = new Set();
const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); };
const be = createBackend({ store, send, platform: 'web', version: VERSION });
// Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert
be.handle('vpn:status', () => ({}));
be.handle('fw:active', () => []);
be.handle('rdp:detect', () => ({}));
be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' }));
be.handle('update:check', () => ({ available: false }));
be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } }));
rt = { store, be, sockets, timer: null };
runtimes.set(sess.userId, rt);
return rt;
}
// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen
function release(userId) {
const rt = runtimes.get(userId);
if (!rt || rt.sockets.size) return;
rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3);
}
function dropUser(userId) {
for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t);
const rt = runtimes.get(userId);
if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); }
}
// ============================================================ HTTP
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
function headers(extra = {}) {
return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra };
}
const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); };
const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); };
// Freigegebene statische Dateien: URL-Präfix → Verzeichnis
const STATIC = [
['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')],
['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')],
['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')],
['/web/', path.join(ROOT, 'src', 'web', 'public')],
];
function staticFile(urlPath) {
for (const [prefix, dir] of STATIC) {
if (urlPath === prefix && !prefix.endsWith('/')) return dir;
if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue;
const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length))));
if (!file.startsWith(dir + path.sep)) return null;
if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null;
if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null;
return file;
}
return null;
}
function serveFile(res, file, transform) {
fs.stat(file, (err, st) => {
if (err || !st.isFile()) return json(res, 404, { error: 'Not found' });
const type = MIME[path.extname(file)] || 'application/octet-stream';
const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300';
if (transform) {
const body = transform(fs.readFileSync(file, 'utf8'));
res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache }));
return res.end(body);
}
res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache }));
fs.createReadStream(file).pipe(res);
});
}
function readJson(req, limit = 64 * 1024) {
return new Promise((resolve, reject) => {
let size = 0; const parts = [];
req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); });
req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } });
req.on('error', reject);
});
}
const APP_URL = '/app/src/renderer/index.html';
async function api(req, res, url, sess) {
const p = url.pathname;
// Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe)
if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION });
if (p === '/api/setup' && req.method === 'POST') {
if (users.length) return json(res, 409, { error: 'Setup is already complete.' });
const b = await readJson(req);
const u = await createUser(b.name, b.password, true);
const dek = await verify(u, b.password);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
}
if (p === '/api/login' && req.method === 'POST') {
const ip = clientIp(req);
if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' });
const b = await readJson(req);
const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase());
const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null);
if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); }
failures.delete(ip);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) });
}
if (!sess) return json(res, 401, { error: 'Not signed in' });
if (p === '/api/me') return json(res, 200, publicUser(sess.user));
if (p === '/api/logout' && req.method === 'POST') {
sessions.delete(sess.token);
return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) });
}
if (p === '/api/password' && req.method === 'POST') {
const b = await readJson(req);
if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' });
await setPassword(sess.user, sess.dek, b.password);
for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t);
return json(res, 200, { ok: true });
}
if (p.startsWith('/api/users')) {
if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' });
if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) });
if (p === '/api/users' && req.method === 'POST') {
const b = await readJson(req);
return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin)));
}
if (p === '/api/users/delete' && req.method === 'POST') {
const b = await readJson(req);
const u = users.find((x) => x.id === b.id);
if (!u) return json(res, 404, { error: 'User not found.' });
if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' });
if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' });
dropUser(u.id);
users = users.filter((x) => x.id !== u.id);
saveUsers();
fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true });
return json(res, 200, { ok: true });
}
}
return json(res, 404, { error: 'Not found' });
}
// SFTP-Dateien des angemeldeten Benutzers streamen
function sftpFor(sess, url) {
const rt = runtimes.get(sess.userId);
const sid = url.searchParams.get('sid') || '';
const file = url.searchParams.get('path') || '';
if (!rt || !file.startsWith('/')) throw new Error('Invalid request');
return { sftp: rt.be.ssh.sftpOf(sid), file };
}
function download(req, res, sess, url) {
let t;
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
t.sftp.stat(t.file, (err, st) => {
if (err) return json(res, 404, { error: err.message });
const name = path.posix.basename(t.file);
res.writeHead(200, headers({
'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store',
'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`,
}));
const rs = t.sftp.createReadStream(t.file);
rs.on('error', () => res.destroy());
rs.pipe(res);
});
}
function upload(req, res, sess, url) {
let t;
try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); }
const ws = t.sftp.createWriteStream(t.file);
let done = false;
const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); };
ws.on('error', finish);
ws.on('close', () => finish());
req.on('error', finish);
req.pipe(ws);
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://x');
const p = url.pathname;
const sess = sessionOf(req);
if (p === '/healthz') return json(res, 200, { ok: true });
if (p.startsWith('/api/')) return await api(req, res, url, sess);
if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' });
if (p === '/web/upload' && req.method === 'POST') {
if (!sess) return json(res, 401, { error: 'Not signed in' });
if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' });
return upload(req, res, sess, url);
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' });
if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html'));
if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png'));
if (p === APP_URL) {
if (!sess) return redirect(res, '/');
// Browser-Anbindung (window.api) vor den übrigen Skripten laden
return serveFile(res, staticFile(p), (html) => html.replace('<script ', '<script src="/web/web-api.js"></script>\n <script '));
}
const file = staticFile(p);
if (!file) return json(res, 404, { error: 'Not found' });
// Nur die Anmeldeseite und ihre Dateien sind ohne Sitzung erreichbar
const PUBLIC = ['/web/login.js', '/web/login.css', '/app/src/i18n.js', '/app/src/renderer/app-themes.css', '/app/src/renderer/logo.png'];
if (!sess && !PUBLIC.includes(p)) return json(res, 401, { error: 'Not signed in' });
return serveFile(res, file);
} catch (e) {
console.error(e);
if (!res.headersSent) json(res, 500, { error: e.message || 'Server error' });
}
});
// ============================================================ WebSocket
const wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 * 1024 });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, 'http://x');
const sess = sessionOf(req);
// Nur Verbindungen von der eigenen Seite (Schutz vor Cross-Site-WebSocket-Hijacking)
const origin = req.headers.origin ? new URL(req.headers.origin).host : '';
const host = (TRUST_PROXY && req.headers['x-forwarded-host']) || req.headers.host;
if (url.pathname !== '/ws' || !sess || origin !== host) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); return socket.destroy(); }
wss.handleUpgrade(req, socket, head, (ws) => {
const rt = runtimeFor(sess);
rt.sockets.add(ws);
const lang = String(req.headers['accept-language'] || 'en').split(',')[0];
ws.on('message', async (raw) => {
let m;
try { m = JSON.parse(raw.toString('utf8')); } catch { return; }
if (!sessions.has(sess.token)) return ws.close(4001, 'signed out');
if (m.t === 'send') return rt.be.notify(String(m.ch), Array.isArray(m.args) ? m.args : []);
if (m.t !== 'call') return;
try {
i18n.setLanguage(rt.store.get().settings.language, lang);
const v = await rt.be.call(String(m.ch), Array.isArray(m.args) ? m.args : []);
ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: true, v: v === undefined ? null : v }));
} catch (e) {
if (ws.readyState === 1) ws.send(JSON.stringify({ t: 'reply', id: m.id, ok: false, v: e?.message || String(e) }));
}
});
ws.on('close', () => { rt.sockets.delete(ws); release(sess.userId); });
ws.send(JSON.stringify({ t: 'hello', user: publicUser(sess.user), version: VERSION }));
});
});
server.on('error', (e) => { console.error(`Cannot listen on port ${PORT}: ${e.message}`); process.exit(1); });
server.listen(PORT, () => console.log(`MrTerm Web ${VERSION} on port ${PORT} (data: ${DATA})${users.length ? '' : ' – open it in the browser to create the admin account'}`));