// 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 { AiAssistant } = require('../main/ai'); const i18n = require('../i18n'); const backup = require('./backup'); const composeTemplates = require('../main/compose-templates'); 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); const ai = new AiAssistant(store, send); 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('backup:export', (opts) => backup.createBackup(store, { ...opts, version })); handle('backup:inspect', (content) => backup.inspectBackup(content)); handle('backup:import', (content, password, opts) => { const r = backup.importBackup(store, content, password, opts); if (opts?.settings) onLanguage(store.get().settings.language); return r; }); // 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('docker:stacks', (id, dir) => docker.stacks(id, dir)); handle('docker:stackRead', (id, file) => docker.readStack(id, file)); handle('docker:stackSave', (id, data) => docker.saveStack(id, data)); handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts)); handle('docker:templates', (url) => composeTemplates.list(url)); handle('docker:template', (index, url) => composeTemplates.build(index, url)); 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)); // ---------- Support-Assistent (Ollama) ---------- handle('ai:models', (url) => ai.models(url)); handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context)); handle('ai:stop', (id) => ai.stop(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?.(); ai.stopAll(); 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 };