Files
MrTerm/mobile/nodejs/server.js
T

200 lines
10 KiB
JavaScript

// 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');