Add Android app: phone UI for hosts, SSH terminal with extra key row, keys, snippets, LAN sync and app lock (password + fingerprint), reusing the desktop Node modules via capacitor-nodejs; APKs per ABI in releases for Obtainium
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
// Ersatz für die von store.js genutzten Electron-APIs unter Android.
|
||||
// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
|
||||
// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
|
||||
const crypto = require('crypto');
|
||||
|
||||
const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
|
||||
const key = () => {
|
||||
const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
|
||||
return k.length === 32 ? k : null;
|
||||
};
|
||||
|
||||
const safeStorage = {
|
||||
isEncryptionAvailable: () => !!key(),
|
||||
encryptString(plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||
const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
|
||||
return Buffer.concat([iv, c.getAuthTag(), ct]);
|
||||
},
|
||||
decryptString(buf) {
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
|
||||
d.setAuthTag(buf.subarray(12, 28));
|
||||
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
|
||||
safeStorage,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
// 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');
|
||||
Reference in New Issue
Block a user