// 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('\n