Add encrypted LAN sync between MrTerm devices: UDP discovery, X25519 pairing with short authentication code, mutually authenticated AES-256-GCM sessions, last-writer-wins merge with deletions, and per-item opt-in for SSH keys, passwords and VPN configurations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 23:05:39 +02:00
co-authored by Claude Opus 5.5
parent ae5309f693
commit 41717b2d07
8 changed files with 694 additions and 11 deletions
+32 -7
View File
@@ -15,6 +15,7 @@ const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network');
const { SyncService } = require('./sync');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win;
@@ -75,6 +76,17 @@ const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
const pairReplies = new Map();
const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pairReplies.set(reqId, resolve);
send('sync:pairPrompt', { reqId, ...req });
setTimeout(() => { if (pairReplies.delete(reqId)) resolve(false); }, 115000);
}));
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
store.onChange = () => sync.schedule();
function createWindow() {
win = new BrowserWindow({
width: 1360,
@@ -134,7 +146,7 @@ function lockStatus() {
}
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); }
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); sync.start().catch(() => {}); }
handle('lock:status', lockStatus);
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
@@ -190,7 +202,9 @@ handle('lock:removeFido', (id) => {
});
// ---------- Vault ----------
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
// Kopplungsschlüssel und Löschvermerke bleiben im Hauptprozess
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: process.platform }));
handle('vault:upsert', async (col, item) => {
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
if (col === 'vpns' && item.id) {
@@ -202,7 +216,7 @@ handle('vault:upsert', async (col, item) => {
handle('vault:remove', async (col, id) => {
if (col === 'vpns') {
await vpn.forget(store.get().vpns.find((v) => v.id === id));
store.get().hosts.forEach((h) => { if (h.vpnId === id) h.vpnId = null; });
store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
}
return store.remove(col, id);
});
@@ -245,6 +259,16 @@ 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));
// ---------- 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:probe', (address) => sync.probe(address));
// ---------- VPN ----------
handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id));
@@ -254,18 +278,18 @@ handle('vault:settings', (s) => {
if ('language' in s) applyLanguage();
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
});
handle('vault:forgetHost', (id) => { delete store.get().knownHosts[id]; store.save(); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:export', async () => {
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
if (r.canceled) return false;
fs.writeFileSync(r.filePath, JSON.stringify(store.get(), null, 2), { mode: 0o600 });
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 });
return r.filePath;
});
handle('vault:import', async () => {
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
if (r.canceled) return false;
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards'])
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
for (const item of data[col] || []) store.upsert(col, item);
return true;
});
@@ -525,6 +549,7 @@ app.whenReady().then(() => {
store.load();
applyLanguage();
createWindow();
sync.start().catch(() => {});
scheduleUpdateCheck();
});
@@ -535,7 +560,7 @@ function scheduleUpdateCheck() {
updateScheduled = true;
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
}
app.on('window-all-closed', async () => { ssh.closeAll(); await vpn.downOnQuit(); app.quit(); });
app.on('window-all-closed', async () => { ssh.closeAll(); sync.stop(); await vpn.downOnQuit(); app.quit(); });
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
if (process.env.MRTERM_SMOKE) {