Sync: warn CachyOS/UFW/firewalld users to open the sync ports with copyable commands; ask before sharing newly created 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:15:49 +02:00
co-authored by Claude Opus 5.5
parent 6a63e0e80d
commit eba3e38620
6 changed files with 113 additions and 10 deletions
+12
View File
@@ -585,6 +585,18 @@
'Pairing verification failed.': 'Die Überprüfung der Kopplung ist fehlgeschlagen.',
'Device is not ready for pairing': 'Das Gerät ist nicht zum Koppeln bereit',
'CachyOS: allow the sync ports in the firewall': 'CachyOS: Sync-Ports in der Firewall freigeben',
'Firewall active: allow the sync ports': 'Firewall aktiv: Sync-Ports freigeben',
'Otherwise other devices cannot find or reach this device. Run these commands once in a terminal:': 'Sonst können andere Geräte dieses Gerät weder finden noch erreichen. Führe diese Befehle einmal in einem Terminal aus:',
'Copy commands': 'Befehle kopieren',
'Already done, hide': 'Erledigt, ausblenden',
'CachyOS enables the UFW firewall by default. It blocks the ports MrTerm needs to find and reach other devices (UDP 47811, TCP 47812). Run these commands once in a terminal:': 'CachyOS aktiviert standardmäßig die Firewall UFW. Sie blockiert die Ports, über die MrTerm andere Geräte findet und erreicht (UDP 47811, TCP 47812). Führe diese Befehle einmal in einem Terminal aus:',
'No devices found? The firewall on this device may block them:': 'Keine Geräte gefunden? Die Firewall dieses Geräts blockiert sie eventuell:',
'Host password': 'Host-Passwort',
'Share with paired devices?': 'Mit gekoppelten Geräten teilen?',
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
'Don\'t share': 'Nicht teilen',
'Share': 'Teilen',
// Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host',
+1
View File
@@ -267,6 +267,7 @@ 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));
// ---------- VPN ----------
+31 -2
View File
@@ -14,6 +14,21 @@ const net = require('net');
const os = require('os');
const crypto = require('crypto');
const i18n = require('../i18n');
const fs = require('fs');
const { execFileSync } = require('child_process');
// Lokale Firewall erkennen, die Broadcasts/eingehende Verbindungen blockieren könnte (Linux).
// Ob die Ports bereits freigegeben sind, lässt sich ohne root nicht prüfen (UFW-Regeln sind nur für root lesbar).
function localFirewall() {
if (process.platform !== 'linux') return null;
const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } };
const active = (unit) => { try { return execFileSync('systemctl', ['is-active', unit], { timeout: 3000 }).toString().trim() === 'active'; } catch { return false; } };
const os = (read('/etc/os-release').match(/^ID=(.*)$/m) || [])[1]?.replace(/"/g, '') || '';
const ufw = /^ENABLED=yes/m.test(read('/etc/ufw/ufw.conf')) || active('ufw');
const firewalld = active('firewalld');
if (!ufw && !firewalld && os !== 'cachyos') return null;
return { os, ufw, firewalld };
}
const UDP_PORT = 47811;
const TCP_PORT = 47812;
@@ -170,6 +185,7 @@ class SyncService {
this.running = false;
this.lastError = '';
this.syncing = new Set();
this.firewall = localFirewall();
}
get cfg() {
@@ -271,7 +287,7 @@ class SyncService {
enabled: c.enabled, running: this.running, deviceId: c.deviceId, deviceName: c.deviceName, pairable: this.pairable, port: this.port,
peers: c.peers.map((p) => ({ id: p.id, name: p.name, lastSync: p.lastSync || 0, online: this.seen.has(p.id), address: p.address, error: p.error || '' })),
nearby: [...this.seen.values()].filter((p) => !c.peers.some((x) => x.id === p.id)).map(({ id, name, address, pairable }) => ({ id, name, address, pairable })),
share: c.share, asked: !!c.asked, sealed: !!this.store.sealed,
share: c.share, asked: !!c.asked, sealed: !!this.store.sealed, firewall: this.firewall,
};
}
emitState() { this.emit('sync:state', this.status()); }
@@ -452,12 +468,25 @@ class SyncService {
setShare(share) {
const clean = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []);
const c = this.cfg;
c.share = { keys: clean(share?.keys), hosts: clean(share?.hosts), vpns: clean(share?.vpns) };
c.share = { keys: clean(share?.keys), hosts: clean(share?.hosts), vpns: clean(share?.vpns), declined: { keys: [], hosts: [], vpns: [] } };
c.asked = true;
this.store.save();
return this.status();
}
// Einzelnes neues Geheimnis teilen oder ablehnen (Ablehnung wird gemerkt, damit nicht erneut gefragt wird)
shareItem(c, id, yes) {
if (!SECRETS[c] || typeof id !== 'string') throw new Error('Invalid');
const sh = this.cfg.share;
sh.declined ||= { keys: [], hosts: [], vpns: [] };
sh[c] = (sh[c] || []).filter((x) => x !== id);
sh.declined[c] = (sh.declined[c] || []).filter((x) => x !== id);
(yes ? sh[c] : sh.declined[c]).push(id);
this.store.save();
if (yes) this.schedule(500);
return this.status();
}
// Gerät per IP hinzufügen (wenn Broadcasts im Netz blockiert sind)
async probe(address, port = TCP_PORT) {
if (!/^[\w.:-]+$/.test(address)) throw new Error('Invalid address');
+54 -6
View File
@@ -556,7 +556,8 @@ function editHost(hst = {}) {
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null,
};
await call('vault:upsert', 'hosts', item);
const savedHost = await call('vault:upsert', 'hosts', item);
if (item.password) askShareSecret('hosts', savedHost, item.label || item.address);
reload();
}, isNew ? null : (() => { const b = h(`<button class="btn danger">${ICONS.trash}</button>`); b.title = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })());
setProto(proto);
@@ -617,7 +618,8 @@ async function generateKey() {
});
if (!r) return;
const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label });
await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
const newKey = await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
askShareSecret('keys', newKey, r.label);
await call('clipboard:write', k.publicKey);
toast(T('Key generated – public key copied to clipboard'), 'ok');
reload();
@@ -633,7 +635,8 @@ function editKey(k = {}, pick = false) {
const v = formValues(form);
if (!v.privateKey.trim()) throw new Error(T('Private key is missing.'));
const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase });
await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
const savedKey = await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
askShareSecret('keys', savedKey, savedKey.label);
reload();
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
async function loadFile() {
@@ -811,6 +814,7 @@ function editVpn(v = {}, pick = false) {
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
const saved = await call('vault:upsert', 'vpns', item);
askShareSecret('vpns', saved, saved.label);
for (const cb of $$('[data-host]', hostBox)) {
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
@@ -1103,9 +1107,14 @@ async function renderSyncCard(card) {
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); };
$('input', en).onchange = async (e) => {
S.syncStatus = await call('sync:enable', e.target.checked);
renderSyncCard(card);
if (e.target.checked && S.syncStatus.firewall) showFirewallHint();
};
card.append(en);
if (!st.enabled) return;
if (st.firewall && !syncFwDismissed()) card.append(firewallHintBox(() => renderSyncCard(card)));
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);
@@ -1128,7 +1137,46 @@ async function renderSyncCard(card) {
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>`));
if (!st.firewall || syncFwDismissed()) 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>`));
}
// Neues Geheimnis bei aktiver Synchronisation: fragen, ob es geteilt werden soll (Antwort wird gemerkt)
async function askShareSecret(c, item, label) {
try {
const st = S.syncStatus || (S.syncStatus = await api.call('sync:status'));
if (!st.enabled || !st.peers.length || !item?.id) return;
if ((st.share[c] || []).includes(item.id) || (st.share.declined?.[c] || []).includes(item.id)) return;
const what = { keys: T('SSH key'), hosts: T('Host password'), vpns: T('VPN configuration') }[c];
const r = await modal({ title: T('Share with paired devices?'), text: `${what}: ${label || ''}`,
body: `<p style="margin-top:0">${T('Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.', { names: st.peers.map((p) => p.name).join(', ') })}</p>`,
buttons: [{ label: T("Don't share"), value: 'no', cls: 'ghost' }, { label: T('Share'), value: 'yes', cls: 'primary' }], noEnter: true });
if (!r) return; // Dialog geschlossen: beim nächsten Speichern erneut fragen
S.syncStatus = await api.call('sync:shareItem', c, item.id, r === 'yes');
} catch {}
}
// Firewall-Hinweis (CachyOS / aktive UFW bzw. firewalld): Ports für die Synchronisation freigeben
const FW_CMDS = { ufw: 'sudo ufw allow 47811/udp\nsudo ufw allow 47812/tcp', firewalld: 'sudo firewall-cmd --permanent --add-port=47811/udp --add-port=47812/tcp\nsudo firewall-cmd --reload' };
const fwCmds = (f) => (f.firewalld && !f.ufw ? FW_CMDS.firewalld : FW_CMDS.ufw);
const syncFwDismissed = () => { try { return localStorage.getItem('mrterm.sync.fwHint') === '1'; } catch { return false; } };
function firewallHintBox(onDismiss) {
const f = S.syncStatus.firewall;
const box = h(`<div class="net-banner warn fw-hint"><div>${ICONS.wall}</div><div class="grow"><b>${f.os === 'cachyos' ? T('CachyOS: allow the sync ports in the firewall') : T('Firewall active: allow the sync ports')}</b>
<p>${T('Otherwise other devices cannot find or reach this device. Run these commands once in a terminal:')}</p><pre class="mono">${esc(fwCmds(f))}</pre>
<div class="row"><button class="btn sm" data-f="copy">${ICONS.copy}${T('Copy commands')}</button><button class="btn sm ghost" data-f="hide">${T('Already done, hide')}</button></div></div></div>`);
box.onclick = (e) => {
const a = e.target.closest('[data-f]')?.dataset.f;
if (a === 'copy') { api.call('clipboard:write', fwCmds(f)); toast(T('Copied'), 'ok'); }
if (a === 'hide') { try { localStorage.setItem('mrterm.sync.fwHint', '1'); } catch {} onDismiss?.(); }
};
return box;
}
async function showFirewallHint() {
const f = S.syncStatus.firewall;
const r = await modal({ title: f.os === 'cachyos' ? T('CachyOS: allow the sync ports in the firewall') : T('Firewall active: allow the sync ports'),
body: `<p style="margin-top:0">${T('CachyOS enables the UFW firewall by default. It blocks the ports MrTerm needs to find and reach other devices (UDP 47811, TCP 47812). Run these commands once in a terminal:')}</p><pre class="mono fw-pre">${esc(fwCmds(f))}</pre>`,
buttons: [{ label: T('Close'), value: null, cls: 'ghost' }, { label: T('Copy commands'), value: 'copy', cls: 'primary' }] });
if (r === 'copy') { await api.call('clipboard:write', fwCmds(f)); toast(T('Copied'), 'ok'); }
}
// Kopplungsdialog: dieses Gerät ist sichtbar, gefundene Geräte können gekoppelt werden
@@ -1141,7 +1189,7 @@ async function openPairing() {
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>`;
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>${S.syncStatus?.firewall ? `<div class="hint" style="color:var(--orange);margin-top:10px;font-size:12px">${T('No devices found? The firewall on this device may block them:')}<pre class="mono fw-pre">${esc(fwCmds(S.syncStatus.firewall))}</pre></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>`);
+6
View File
@@ -437,3 +437,9 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
.pair-list { max-height: 260px; overflow: auto; }
.share-list { max-height: 380px; overflow: auto; }
.share-list h4 { margin: 12px 0 6px; }
.fw-hint { align-items: flex-start; margin: 4px 0 14px; }
.fw-hint svg { width: 22px; height: 22px; }
.fw-hint .grow { flex: 1; min-width: 0; }
.fw-hint b { color: var(--text); }
.fw-hint p { margin: 4px 0 8px; color: var(--muted); }
.fw-hint pre, .fw-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 0 0 10px; color: var(--text); white-space: pre-wrap; user-select: text; }