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