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
+103 -3
View File
@@ -83,7 +83,7 @@ async function call(ch, ...a) {
}
// ---------- Modals
function modal({ title, text = '', body = '', buttons = [] }) {
function modal({ title, text = '', body = '', buttons = [], noEnter = false }) {
return new Promise((resolve) => {
const bg = h(`<div class="modal-bg"><div class="modal"><div class="mbody"><h3>${esc(title)}</h3>${text ? `<p>${esc(text)}</p>` : ''}${body}</div><div class="mfoot"></div></div></div>`);
const foot = $('.mfoot', bg);
@@ -96,7 +96,7 @@ function modal({ title, text = '', body = '', buttons = [] }) {
bg.addEventListener('mousedown', (e) => { if (e.target === bg) done(null); });
bg.addEventListener('keydown', (e) => {
if (e.key === 'Escape') done(null);
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); foot.lastElementChild.click(); }
if (e.key === 'Enter' && !noEnter && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); foot.lastElementChild.click(); }
});
$('#modalRoot').append(bg);
($('input,select,textarea', bg) || foot.lastElementChild).focus();
@@ -945,6 +945,10 @@ async function viewSettings(page) {
const secCard = h(`<div class="settings-card"><h3>${T('App lock')}</h3><p style="color:var(--muted);margin-top:0">${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}</p><div class="lock-rows"></div></div>`);
renderLockSettings($('.lock-rows', secCard));
// ---- LAN-Synchronisation
const syncCard = h('<div class="settings-card sync-card"></div>');
renderSyncCard(syncCard);
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">${T('Installed version: {v}', { v: '…' })}</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
@@ -977,7 +981,7 @@ async function viewSettings(page) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
c.append(langCard, secCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ App-Sperre
@@ -1092,6 +1096,102 @@ function renderLockSettings(box) {
}
}
// ============================================================ LAN-Synchronisation
S.syncStatus = null;
async function renderSyncCard(card) {
if (!S.syncStatus) { try { S.syncStatus = await api.call('sync:status'); } catch { return; } }
const st = S.syncStatus;
card.innerHTML = `<h3>${T('Synchronization')}</h3><p style="color:var(--muted);margin-top:0">${T('Synchronize hosts, groups, snippets, port forwards, VPNs and known hosts directly between MrTerm devices in your local network. The connection is end-to-end encrypted; no server or cloud is involved.')}</p>`;
const en = check(T('Enable LAN synchronization'), 'syncOn', st.enabled);
$('input', en).onchange = async (e) => { S.syncStatus = await call('sync:enable', e.target.checked); renderSyncCard(card); };
card.append(en);
if (!st.enabled) return;
const name = field(T('Device name'), 'devName', st.deviceName);
$('input', name).onchange = async (e) => { S.syncStatus = await call('sync:enable', true, e.target.value.trim()); };
card.append(name);
const list = h('<div class="sync-peers"></div>');
if (!st.peers.length) list.append(h(`<div class="hint" style="color:var(--faint);padding:6px 0">${T('No paired devices yet.')}</div>`));
for (const p of st.peers) {
const row = h(`<div class="lock-row"><span class="dot ${p.online ? 'on' : ''}"></span><div class="grow"><b>${esc(p.name)}</b><div class="sub">${p.online ? T('online') : T('offline')}${p.lastSync ? ` · ${T('last sync {time}', { time: relTime(p.lastSync) })}` : ''}${p.error ? ` · <span style="color:var(--red)">${esc(p.error)}</span>` : ''}</div></div></div>`);
const rm = h(`<button class="btn ghost" title="${esc(T('Unpair'))}">${ICONS.trash}</button>`);
rm.onclick = async () => { if (await confirmBox(T('Unpair device?'), T('“{name}” will no longer synchronize with this device.', { name: p.name }), T('Unpair'))) { S.syncStatus = await call('sync:unpair', p.id); renderSyncCard(card); } };
row.append(rm);
list.append(row);
}
card.append(list);
const shared = (c, arr, pred = () => true) => `${(st.share[c] || []).filter((id) => arr.some((x) => x.id === id)).length}/${arr.filter(pred).length}`;
card.append(h(`<div class="hint" style="color:var(--muted);font-size:12px;margin-top:10px">${T('Shared secrets: {keys} SSH keys, {pw} host passwords, {vpn} VPN configurations', { keys: shared('keys', S.vault.keys), pw: shared('hosts', S.vault.hosts, (x) => x.password), vpn: shared('vpns', S.vault.vpns) })}</div>`));
const acts = h('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
const pair = h(`<button class="btn primary">${ICONS.plus}${T('Pair new device')}</button>`); pair.onclick = openPairing;
const now = h(`<button class="btn">${ICONS.refresh}${T('Sync now')}</button>`);
now.onclick = async () => { now.disabled = true; try { const n = await call('sync:now'); toast(T('Synchronized with {n} device(s)', { n }), 'ok'); } catch {} now.disabled = false; };
const sec = h(`<button class="btn">${ICONS.key}${T('Choose shared secrets')}</button>`); sec.onclick = chooseSharedSecrets;
acts.append(pair, now, sec);
card.append(acts);
card.append(h(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('Devices find each other via UDP port 47811 and synchronize via TCP port 47812. If a firewall is active, allow these ports in your local network (with UFW: sudo ufw allow 47811/udp and sudo ufw allow 47812/tcp).')}</div>`));
}
// Kopplungsdialog: dieses Gerät ist sichtbar, gefundene Geräte können gekoppelt werden
async function openPairing() {
S.syncStatus = await call('sync:pairable', true);
const body = `<p style="margin-top:0">${T('Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.')}</p><div class="pair-list list"></div>
<div class="row" style="margin-top:12px"><div class="field" style="flex:1;margin:0"><input name="ip" placeholder="${esc(T('Or enter an IP address, e.g. 192.168.0.25'))}"/></div><button class="btn" data-probe type="button">${T('Search')}</button></div>`;
let timer;
const draw = () => {
const box = $('.pair-list');
if (!box) return clearInterval(timer);
const near = S.syncStatus?.nearby || [];
box.innerHTML = near.length ? '' : `<div class="hint" style="color:var(--muted);display:flex;gap:10px;align-items:center"><span class="spinner sm"></span>${T('Searching for devices …')}</div>`;
for (const d of near) {
const row = h(`<div class="card" style="padding:10px 12px;cursor:default"><div class="avatar" style="background:var(--icon-bg);color:var(--accent)">${ICONS.screen}</div><div class="meta"><div class="title">${esc(d.name)}</div><div class="sub">${esc(d.address)}${d.pairable ? '' : ` · ${T('not ready for pairing')}`}</div></div></div>`);
const b = h(`<button class="btn sm primary" ${d.pairable ? '' : 'disabled'}>${T('Pair')}</button>`);
b.onclick = async () => {
b.disabled = true; b.innerHTML = `<span class="spinner sm"></span>${T('Pairing …')}`;
try { await call('sync:pair', d.id); $('.pair-list')?.closest('.modal-bg')?.remove(); } catch {}
b.disabled = false; b.textContent = T('Pair');
};
row.append(b);
box.append(row);
}
};
setTimeout(() => {
draw();
timer = setInterval(draw, 1500);
const probe = $('[data-probe]');
if (probe) probe.onclick = async () => { const ip = $('[name=ip]', probe.closest('.modal')).value.trim(); if (ip) { try { await call('sync:probe', ip); toast(T('Device found'), 'ok'); } catch {} } };
}, 0);
await modal({ title: T('Pair new device'), body, buttons: [{ label: T('Close'), value: null, cls: 'ghost' }] });
clearInterval(timer);
S.syncStatus = await call('sync:pairable', false).catch(() => S.syncStatus);
}
// Auswahl, welche Geheimnisse dieses Gerät an gekoppelte Geräte weitergibt
async function chooseSharedSecrets() {
const sh = S.syncStatus?.share || { keys: [], hosts: [], vpns: [] };
const sect = (title, c, items, label) => items.length ? `<h4>${title}</h4>${items.map((x) => `<label class="check"><input type="checkbox" name="${c}|${esc(x.id)}" ${(sh[c] || []).includes(x.id) ? 'checked' : ''}/>${esc(label(x))}</label>`).join('')}` : '';
const hostsPw = S.vault.hosts.filter((x) => x.password);
const body = `<p style="margin-top:0">${T('Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.')}</p>
<div class="share-list">${sect(T('SSH keys'), 'keys', S.vault.keys, (x) => x.label)}${sect(T('Host passwords'), 'hosts', hostsPw, (x) => `${x.label || x.address} (${x.username || '?'}@${x.address})`)}${sect(T('VPN configurations'), 'vpns', S.vault.vpns, (x) => x.label)}
${!S.vault.keys.length && !hostsPw.length && !S.vault.vpns.length ? `<div class="hint">${T('There are no secrets on this device yet.')}</div>` : ''}</div>`;
const r = await modal({ title: T('Choose shared secrets'), body, buttons: [{ label: T('Share nothing'), value: 'none', cls: 'ghost' }, { label: T('Select all'), value: 'all', cls: '' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const picked = { keys: [], hosts: [], vpns: [] };
if (typeof r === 'object') for (const [k, on] of Object.entries(r)) { const [c, id] = k.split('|'); if (on && picked[c]) picked[c].push(id); }
const sel = r === 'none' ? { keys: [], hosts: [], vpns: [] } : r === 'all' ? { keys: S.vault.keys.map((x) => x.id), hosts: hostsPw.map((x) => x.id), vpns: S.vault.vpns.map((x) => x.id) } : picked;
S.syncStatus = await call('sync:share', sel);
toast(T('Saved'), 'ok');
const card = $('.sync-card'); if (card) renderSyncCard(card);
}
api.on('sync:state', (st) => { S.syncStatus = st; const card = $('.sync-card'); if (card && !card.contains(document.activeElement)) renderSyncCard(card); });
api.on('sync:changed', () => { if (!S.lock?.locked && !$('.lock-screen')) reload(); });
api.on('sync:paired', async (p) => { toast(T('Paired with {name}', { name: p.name }), 'ok'); chooseSharedSecrets(); });
api.on('sync:pairPrompt', async (req) => {
const r = await modal({ title: T('Confirm pairing'), body: `<p style="margin-top:0">${esc(T('Pairing with “{name}”. Does the other device show the same code?', { name: req.name }))}</p><div class="pair-code">${req.code.slice(0, 3)} ${req.code.slice(3)}</div>`,
buttons: [{ label: T('Codes differ'), value: false, cls: 'danger' }, { label: T('Codes match'), value: true, cls: 'primary' }], noEnter: true });
api.pairReply(req.reqId, r === true);
});
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;