Files
MrTerm/src/core/backend.js
T

241 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };