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
+223
View File
@@ -0,0 +1,223 @@
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
//
// createBackend({ store, send, platform, version, withSync, onLanguage })
// store Store-Instanz (bereits geladen)
// send (kanal, ...args) → Ereignis an die Oberfläche
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
const crypto = require('crypto');
const { utils: sshUtils } = require('ssh2');
const { SshManager } = require('../main/ssh');
const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network');
const i18n = require('../i18n');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
const pending = new Map();
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pending.set(reqId, resolve);
send(ch, { reqId, ...req });
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
});
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
const confirmHostKey = async (target, fingerprint, known) =>
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
const ssh = new SshManager(store, confirmHostKey, askSecret);
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
let sync = null;
if (withSync) {
const { SyncService } = require('../main/sync');
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
store.onChange = () => sync.schedule();
}
const handlers = new Map();
const handle = (ch, fn) => handlers.set(ch, fn);
const OPEN_WHILE_LOCKED = new Set(['app:version']);
async function call(ch, args = []) {
const fn = handlers.get(ch);
if (!fn) throw new Error(`Unknown channel ${ch}`);
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
return fn(...args);
}
const listeners = {
'ssh:write': (id, d) => ssh.write(id, d),
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
'ssh:close': (id) => ssh.close(id),
'ask:reply': answer,
// Namen der Desktop-Vorlage (preload.js)
'secret:reply': answer,
'sync:pairReply': answer,
};
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
// ---------- App-Sperre (Passwort) ----------
const kdf = (pw, salt, N) => 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))));
const lockStatus = () => {
const { language, appTheme, accent } = store.get().settings;
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
};
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); };
handle('lock:status', lockStatus);
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
handle('lock:unlockPassword', async (pw) => {
const p = store.lock?.password;
if (!p) throw new Error(i18n.t('No password set.'));
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
afterUnlock();
return true;
});
handle('lock:setPassword', async (pw) => {
requireUnlocked();
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
const salt = crypto.randomBytes(16), N = 2 ** 15;
const kek = await kdf(pw, salt, N);
store.lock = store.lock || { password: null, fido: [] };
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
store.save();
return lockStatus();
});
handle('lock:removePassword', () => {
requireUnlocked();
if (store.lock) { store.lock.password = null; store.lock.bio = null; }
store.dropLockIfEmpty();
store.save();
return lockStatus();
});
// ---------- Tresor ----------
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
handle('vault:upsert', (col, item) => store.upsert(col, item));
handle('vault:remove', (col, id) => {
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
return store.remove(col, id);
});
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:exportData', () => publicData());
handle('vault:importData', (data) => {
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
for (const item of data?.[col] || []) store.upsert(col, item);
return true;
});
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
handle('vault:bulkImport', (entries) => {
const groups = store.get().groups;
const ensure = (parts) => {
let parent = null;
for (const label of parts) {
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
parent = g.id;
}
return parent;
};
let hosts = 0, skipped = 0;
for (const e of entries) {
const groupId = ensure(e.folder || []);
if (!e.host) continue;
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
if (dup) { skipped++; continue; }
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
hosts++;
}
store.save();
return { hosts, skipped, groups: groups.length };
});
handle('app:version', () => version);
// ---------- Schlüssel ----------
handle('key:generate', ({ type, bits, comment, passphrase }) => {
const opts = { comment: comment || `mrterm@${platform}` };
if (type === 'rsa') opts.bits = Number(bits) || 4096;
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
return { privateKey: k.private, publicKey: k.public };
});
handle('key:parse', ({ privateKey, passphrase }) => {
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
if (k instanceof Error) throw k;
const key = Array.isArray(k) ? k[0] : k;
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
});
// ---------- SSH-Terminal & SFTP ----------
function hostWithOverrides(ref) {
if (typeof ref === 'string') {
const h = store.resolveHost(ref);
if (!h) throw new Error(i18n.t('Host not found'));
return h;
}
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
return ref;
}
handle('ssh:open', async (sessionId, ref, size) => {
const host = hostWithOverrides(ref);
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true;
});
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
handle('sftp:close', (id) => ssh.close(id));
// ---------- Docker / Firewall / Netzwerk ----------
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
handle('docker:list', (id) => docker.list(id));
handle('docker:stats', (id) => docker.stats(id));
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
handle('docker:close', (id) => docker.close(id));
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
handle('firewall:list', (id, backend) => firewall.list(id, backend));
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
handle('firewall:close', (id) => firewall.close(id));
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
handle('network:read', (id) => network.read(id));
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
handle('network:remove', (id, model) => network.removeInterface(id, model));
handle('network:hostname', (id, name) => network.setHostname(id, name));
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
handle('network:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id));
// ---------- Synchronisation ----------
if (sync) {
handle('sync:status', () => sync.status());
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
handle('sync:pair', (id) => sync.pair(id));
handle('sync:unpair', (id) => sync.unpair(id));
handle('sync:now', () => sync.syncAll());
handle('sync:share', (sel) => sync.setShare(sel));
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
handle('sync:probe', (address) => sync.probe(address));
}
function close() {
ssh.closeAll?.();
sync?.stop();
for (const r of pending.values()) r(null);
pending.clear();
}
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
}
module.exports = { createBackend };