Add MrTerm Web: self-hosted browser version (Docker Compose) with user accounts, per-user vaults encrypted by the login password, the desktop UI over WebSocket, SFTP upload/download over HTTP; share backend channels between Android and web (src/core/backend.js)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 14:52:26 +02:00
co-authored by Claude Opus 5.5
parent c143053319
commit 45b0be8557
18 changed files with 1567 additions and 187 deletions
+16
View File
@@ -0,0 +1,16 @@
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body { background: var(--bg); color: var(--text); font-family: var(--font); display: grid; place-items: center; min-height: 100vh; padding: 16px; }
.box { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px 28px 22px; text-align: center; box-shadow: 0 20px 60px #0006; }
.logo { width: 72px; height: 72px; border-radius: 18px; }
h1 { margin: 10px 0 4px; font-size: 26px; }
h1 span { color: var(--green); }
p { color: var(--muted); margin: 0 0 22px; font-size: 14px; }
form { display: flex; flex-direction: column; gap: 14px; text-align: left; }
label span { display: block; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
input { width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; font: inherit; outline: none; }
input:focus { border-color: var(--accent); }
button { margin-top: 6px; background: var(--accent); color: var(--on-accent); border: 0; border-radius: 10px; padding: 12px; font: 600 15px var(--font); cursor: pointer; }
button:disabled { opacity: .6; }
.err { color: var(--red); font-size: 13px; }
.ver { margin-top: 18px; color: var(--faint); font-size: 11px; }
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MrTerm</title>
<link rel="icon" href="/favicon.png" />
<link rel="stylesheet" href="/app/src/renderer/app-themes.css" />
<link rel="stylesheet" href="/web/login.css" />
</head>
<body>
<main class="box">
<img src="/favicon.png" alt="" class="logo" />
<h1>Mr<span>Term</span></h1>
<p id="intro"></p>
<form id="form" autocomplete="on">
<label><span data-t="Username">Username</span><input name="name" autocomplete="username" required autofocus /></label>
<label><span data-t="Password">Password</span><input name="password" type="password" autocomplete="current-password" required /></label>
<label id="repeatRow" hidden><span data-t="Repeat password">Repeat password</span><input name="repeat" type="password" autocomplete="new-password" /></label>
<div id="err" class="err" hidden></div>
<button id="submit" type="submit"></button>
</form>
<div class="ver" id="ver"></div>
</main>
<script src="/app/src/i18n.js"></script>
<script src="/web/login.js"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
// Anmeldung bzw. Ersteinrichtung (Admin-Konto) der Web-Version
(async () => {
'use strict';
I18N.setLanguage('auto', navigator.language);
const T = I18N.t;
const $ = (s) => document.querySelector(s);
document.querySelectorAll('[data-t]').forEach((e) => { e.textContent = T(e.dataset.t); });
const st = await fetch('/api/state').then((r) => r.json());
if (st.user) { location.href = '/app/src/renderer/index.html'; return; }
const setup = st.setup;
$('#ver').textContent = `MrTerm Web ${st.version}`;
$('#intro').textContent = setup ? T('Welcome! Create the administrator account for this MrTerm server.') : T('Sign in to your MrTerm server.');
$('#submit').textContent = setup ? T('Create account') : T('Sign in');
$('#repeatRow').hidden = !setup;
if (setup) $('[name=password]').autocomplete = 'new-password';
$('#form').onsubmit = async (e) => {
e.preventDefault();
const f = Object.fromEntries(new FormData(e.target));
const err = $('#err');
err.hidden = true;
if (setup && f.password !== f.repeat) { err.textContent = T('The passwords do not match.'); err.hidden = false; return; }
$('#submit').disabled = true;
try {
const r = await fetch(setup ? '/api/setup' : '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: JSON.stringify({ name: f.name, password: f.password }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || r.statusText);
location.href = '/app/src/renderer/index.html';
} catch (ex) {
err.textContent = T(ex.message);
err.hidden = false;
$('#submit').disabled = false;
}
};
})();
+100
View File
@@ -0,0 +1,100 @@
// window.api für die Web-Version: gleiche Schnittstelle wie src/preload.js (Electron),
// aber über einen WebSocket zum MrTerm-Web-Server. Dateidialoge, Zwischenablage und Links laufen im Browser.
(() => {
'use strict';
const listeners = new Map();
const waits = new Map();
let ws, seq = 0, queue = [], retry = 0, banner;
const emit = (ch, args) => listeners.get(ch)?.forEach((fn) => { try { fn(...args); } catch (e) { console.error(e); } });
function showBanner(text) {
if (!document.body) return;
if (!banner) {
banner = document.createElement('div');
banner.style.cssText = 'position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:9999;background:var(--card,#222);color:var(--text,#eee);border:1px solid var(--border,#444);border-radius:10px;padding:8px 14px;font:13px system-ui;box-shadow:0 6px 24px #0006';
document.body.append(banner);
}
banner.textContent = text;
banner.hidden = !text;
}
function connect() {
ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);
ws.onopen = () => { retry = 0; showBanner(''); for (const m of queue) ws.send(m); queue = []; };
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.t === 'reply') {
const w = waits.get(m.id);
if (!w) return;
waits.delete(m.id);
m.ok ? w.resolve(m.v) : w.reject(new Error(m.v));
} else if (m.t === 'evt') emit(m.ch, m.args || []);
};
ws.onclose = (e) => {
for (const w of waits.values()) w.reject(new Error('Connection to the MrTerm server lost.'));
waits.clear();
if (e.code === 4001) { location.href = '/'; return; }
// Sitzung abgelaufen? Dann zur Anmeldung, sonst neu verbinden
fetch('/api/me', { headers: { 'X-MrTerm': '1' } }).then((r) => {
if (r.status === 401) { location.href = '/'; return; }
showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …');
setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
}).catch(() => { showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); });
};
}
const post = (m) => { const s = JSON.stringify(m); if (ws.readyState === 1) ws.send(s); else queue.push(s); };
const remote = (ch, args) => new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); post({ t: 'call', id, ch, args }); });
// Datei im Browser auswählen und als Text lesen
function pickFile(accept) {
return new Promise((resolve) => {
const inp = document.createElement('input');
inp.type = 'file';
if (accept) inp.accept = accept;
inp.style.display = 'none';
inp.onchange = async () => { const f = inp.files[0]; inp.remove(); resolve(f ? { name: f.name, content: await f.text() } : null); };
inp.addEventListener('cancel', () => { inp.remove(); resolve(null); });
document.body.append(inp);
inp.click();
});
}
function downloadText(name, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Kanäle, die im Browser selbst erledigt werden
const local = {
'clipboard:write': (t) => navigator.clipboard.writeText(String(t)),
'clipboard:read': () => navigator.clipboard.readText(),
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
'import:pickFile': async () => pickFile(),
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },
};
window.api = {
platform: 'web',
call: (ch, ...args) => (local[ch] ? Promise.resolve().then(() => local[ch](...args)) : remote(ch, args)),
on(ch, fn) {
if (!listeners.has(ch)) listeners.set(ch, new Set());
listeners.get(ch).add(fn);
return () => listeners.get(ch).delete(fn);
},
win: { min() {}, max() {}, close() {} },
ssh: {
write: (id, d) => post({ t: 'send', ch: 'ssh:write', args: [id, d] }),
resize: (id, c, r) => post({ t: 'send', ch: 'ssh:resize', args: [id, c, r] }),
close: (id) => post({ t: 'send', ch: 'ssh:close', args: [id] }),
},
rdp: { bounds() {}, show() {}, hide() {}, focus() {}, close() {} },
replySecret: (reqId, v) => post({ t: 'send', ch: 'secret:reply', args: [reqId, v] }),
pairReply: (reqId, ok) => post({ t: 'send', ch: 'sync:pairReply', args: [reqId, ok] }),
};
document.documentElement.classList.add('web');
connect();
})();
+376
View File
@@ -0,0 +1,376 @@
// 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'}`));