// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess // (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche. // Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente]; // Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente]. const { channel } = require('bridge'); const crypto = require('crypto'); const { utils: sshUtils } = require('ssh2'); const { Store } = require('../../src/main/store'); const { SshManager } = require('../../src/main/ssh'); const { DockerManager } = require('../../src/main/docker'); const { FirewallManager } = require('../../src/main/firewall'); const { NetworkConfigManager } = require('../../src/main/network'); const { SyncService } = require('../../src/main/sync'); const i18n = require('../../src/i18n'); const send = (ch, ...args) => channel.send('evt', ch, args); process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); }); process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); const store = new Store(); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en'); // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) const pending = new Map(); function ask(ch, req, timeout = 0) { return 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 sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000))); store.onChange = () => sync.schedule(); const handlers = new Map(); const OPEN_WHILE_LOCKED = new Set(['app:version']); const handle = (ch, fn) => handlers.set(ch, fn); channel.addListener('call', async (reqId, ch, args = []) => { try { 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.')); channel.send('reply', reqId, true, await fn(...args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); } }); 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, }; channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } }); // ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ---------- 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 bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32)); function lockStatus() { const { language, appTheme, accent } = store.get().settings; return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } }; } const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }; const afterUnlock = () => { applyLanguage(); 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:unlockBio', (secret) => { if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.')); try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your 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; } // Fingerabdruck nur zusätzlich zum Passwort store.dropLockIfEmpty(); store.save(); return lockStatus(); }); handle('lock:setBio', (secret) => { requireUnlocked(); if (!store.lock?.password) throw new Error(i18n.t('Set a password first.')); store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null; 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: 'android' })); 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) applyLanguage(); }); handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0'); // ---------- Schlüssel ---------- handle('key:generate', ({ type, bits, comment, passphrase }) => { const opts = { comment: comment || 'mrterm@android' }; 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 ---------- 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; }); // ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ---------- 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:close', (id) => network.close(id)); // ---------- Synchronisation ---------- 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)); // App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen handle('app:pause', () => sync.stop()); handle('app:resume', () => sync.start().catch(() => {})); store.load(); applyLanguage(); sync.start().catch(() => {}); channel.send('ready');