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:
+42
@@ -543,6 +543,48 @@
|
||||
'A static IPv6 configuration needs at least one IPv6 address.': 'Eine statische IPv6-Konfiguration braucht mindestens eine IPv6-Adresse.',
|
||||
|
||||
'Authentication failed for {user}. Check the username, password or key.': 'Anmeldung für {user} fehlgeschlagen. Prüfe Benutzername, Passwort oder Schlüssel.',
|
||||
// Synchronisation
|
||||
'Synchronization': 'Synchronisation',
|
||||
'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.': 'Hosts, Gruppen, Snippets, Port-Weiterleitungen, VPNs und Known Hosts direkt zwischen MrTerm-Geräten im lokalen Netz abgleichen. Die Verbindung ist Ende-zu-Ende verschlüsselt, ohne Server oder Cloud.',
|
||||
'Enable LAN synchronization': 'LAN-Synchronisation aktivieren',
|
||||
'Device name': 'Gerätename',
|
||||
'No paired devices yet.': 'Noch keine gekoppelten Geräte.',
|
||||
'online': 'online',
|
||||
'offline': 'offline',
|
||||
'last sync {time}': 'zuletzt {time}',
|
||||
'Unpair': 'Entkoppeln',
|
||||
'Unpair device?': 'Gerät entkoppeln?',
|
||||
'“{name}” will no longer synchronize with this device.': '„{name}“ synchronisiert dann nicht mehr mit diesem Gerät.',
|
||||
'Shared secrets: {keys} SSH keys, {pw} host passwords, {vpn} VPN configurations': 'Geteilte Geheimnisse: {keys} SSH-Schlüssel, {pw} Host-Passwörter, {vpn} VPN-Konfigurationen',
|
||||
'Pair new device': 'Neues Gerät koppeln',
|
||||
'Sync now': 'Jetzt synchronisieren',
|
||||
'Synchronized with {n} device(s)': 'Mit {n} Gerät(en) synchronisiert',
|
||||
'Choose shared secrets': 'Geteilte Geheimnisse auswählen',
|
||||
'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).': 'Geräte finden sich über UDP-Port 47811 und synchronisieren über TCP-Port 47812. Ist eine Firewall aktiv, erlaube diese Ports im lokalen Netz (mit UFW: sudo ufw allow 47811/udp und sudo ufw allow 47812/tcp).',
|
||||
'Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.': 'Öffne „Neues Gerät koppeln“ auch auf dem anderen Gerät und wähle es dann hier aus. Beide Geräte zeigen einen Code, den du auf beiden Seiten bestätigst.',
|
||||
'Or enter an IP address, e.g. 192.168.0.25': 'Oder IP-Adresse eingeben, z. B. 192.168.0.25',
|
||||
'Searching for devices …': 'Suche nach Geräten …',
|
||||
'not ready for pairing': 'nicht bereit zum Koppeln',
|
||||
'Pair': 'Koppeln',
|
||||
'Pairing …': 'Kopple …',
|
||||
'Device found': 'Gerät gefunden',
|
||||
'Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.': 'Wähle, welche Geheimnisse dieses Gerät an gekoppelte Geräte senden darf. Alles andere (Hosts, Gruppen, Snippets …) wird ohne Passwörter und private Schlüssel synchronisiert.',
|
||||
'SSH keys': 'SSH-Schlüssel',
|
||||
'Host passwords': 'Host-Passwörter',
|
||||
'VPN configurations': 'VPN-Konfigurationen',
|
||||
'There are no secrets on this device yet.': 'Auf diesem Gerät gibt es noch keine Geheimnisse.',
|
||||
'Share nothing': 'Nichts teilen',
|
||||
'Select all': 'Alle auswählen',
|
||||
'Paired with {name}': 'Mit {name} gekoppelt',
|
||||
'Confirm pairing': 'Kopplung bestätigen',
|
||||
'Pairing with “{name}”. Does the other device show the same code?': 'Kopplung mit „{name}“. Zeigt das andere Gerät denselben Code?',
|
||||
'Codes differ': 'Codes unterschiedlich',
|
||||
'Codes match': 'Codes stimmen überein',
|
||||
'Pairing was rejected on this device.': 'Die Kopplung wurde auf diesem Gerät abgelehnt.',
|
||||
'Pairing was rejected on the other device.': 'Die Kopplung wurde auf dem anderen Gerät abgelehnt.',
|
||||
'Pairing verification failed.': 'Die Überprüfung der Kopplung ist fehlgeschlagen.',
|
||||
'Device is not ready for pairing': 'Das Gerät ist nicht zum Koppeln bereit',
|
||||
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
+32
-7
@@ -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) {
|
||||
|
||||
+19
-1
@@ -15,6 +15,10 @@ const DEFAULTS = {
|
||||
vpns: [],
|
||||
knownHosts: {},
|
||||
history: [],
|
||||
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||
tombstones: [],
|
||||
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||
sync: { enabled: false, deviceId: '', deviceName: '', peers: [], share: { keys: [], hosts: [], vpns: [] }, asked: false },
|
||||
settings: {
|
||||
terminalTheme: 'mrterm',
|
||||
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
|
||||
@@ -124,6 +128,7 @@ class Store {
|
||||
|
||||
save() {
|
||||
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
|
||||
if (!this.muted) this.onChange?.();
|
||||
let json = JSON.stringify(this.data, null, 2);
|
||||
if (this.lockEnabled && this.dek) {
|
||||
const { language, appTheme, accent } = this.data.settings;
|
||||
@@ -156,7 +161,20 @@ class Store {
|
||||
|
||||
remove(collection, id) {
|
||||
this.data[collection] = this.data[collection].filter((x) => x.id !== id);
|
||||
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) h.groupId = null; });
|
||||
if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) { h.groupId = null; h.updatedAt = Date.now(); } });
|
||||
this.tombstone(collection, id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
// Löschung für andere Geräte vermerken (180 Tage aufbewahren)
|
||||
tombstone(c, id) {
|
||||
const now = Date.now();
|
||||
this.data.tombstones = [...(this.data.tombstones || []).filter((t) => !(t.c === c && t.id === id) && now - t.at < 180 * 864e5), { c, id, at: now }];
|
||||
}
|
||||
|
||||
forgetKnownHost(id) {
|
||||
delete this.data.knownHosts[id];
|
||||
this.tombstone('knownHosts', id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
// LAN-Synchronisation zwischen MrTerm-Clients (Peer-to-Peer, Ende-zu-Ende verschlüsselt).
|
||||
//
|
||||
// Finden: UDP-Broadcast auf Port 47811 ("Beacon" mit Geräte-ID, Name, TCP-Port, koppelbar ja/nein).
|
||||
// Koppeln: X25519-Schlüsselaustausch mit Commitment (der Initiator legt sich per Hash auf seinen Schlüssel fest,
|
||||
// bevor er den des anderen kennt). Beide Geräte zeigen denselben 6-stelligen Vergleichscode (SAS);
|
||||
// der Nutzer bestätigt auf beiden Geräten. Ergebnis: dauerhafter Kopplungsschlüssel (32 Byte).
|
||||
// Sitzung: Gegenseitige Authentisierung per HMAC mit dem Kopplungsschlüssel, frische X25519-Schlüssel je Sitzung
|
||||
// (Forward Secrecy), danach alle Nachrichten AES-256-GCM-verschlüsselt.
|
||||
// Abgleich: Beide Seiten senden einen Schnappschuss; pro Eintrag gewinnt die neueste Änderung (updatedAt),
|
||||
// Löschvermerke (tombstones) entfernen Einträge. Geheimnisse (SSH-Schlüssel, Passwörter, VPN-Konfigurationen)
|
||||
// werden nur übertragen, wenn der Nutzer sie freigegeben hat.
|
||||
const dgram = require('dgram');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const UDP_PORT = 47811;
|
||||
const TCP_PORT = 47812;
|
||||
const PROTO = 1;
|
||||
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
|
||||
const SECRETS = { keys: ['privateKey', 'passphrase'], hosts: ['password'], vpns: ['config', 'password'] };
|
||||
const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex');
|
||||
const MAX_FRAME = 32 * 1024 * 1024;
|
||||
|
||||
const sha = (...parts) => crypto.createHash('sha256').update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest();
|
||||
const hmac = (key, ...parts) => crypto.createHmac('sha256', key).update(Buffer.concat(parts.map((p) => Buffer.from(p)))).digest();
|
||||
const hkdf = (ikm, salt, info) => Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, 32));
|
||||
const ts = (x) => x?.updatedAt || x?.createdAt || 0;
|
||||
|
||||
function x25519() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
||||
return { privateKey, pub: publicKey.export({ type: 'spki', format: 'der' }).subarray(-32) };
|
||||
}
|
||||
function dh(privateKey, peerPub) {
|
||||
const pk = crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519, Buffer.from(peerPub)]), format: 'der', type: 'spki' });
|
||||
return crypto.diffieHellman({ privateKey, publicKey: pk });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Framing (4 Byte Länge + Nutzlast)
|
||||
class Channel {
|
||||
constructor(sock) {
|
||||
this.sock = sock;
|
||||
this.buf = Buffer.alloc(0);
|
||||
this.queue = [];
|
||||
this.waiters = [];
|
||||
this.keys = null; // { send, recv } nach dem Handshake
|
||||
sock.on('data', (d) => this.onData(d));
|
||||
const fail = (e) => { this.closed = e || new Error('closed'); this.waiters.splice(0).forEach((w) => w.reject(this.closed)); };
|
||||
sock.on('close', () => fail());
|
||||
sock.on('error', (e) => fail(e));
|
||||
sock.setTimeout(180000, () => sock.destroy(new Error('timeout')));
|
||||
}
|
||||
onData(d) {
|
||||
this.buf = Buffer.concat([this.buf, d]);
|
||||
while (this.buf.length >= 4) {
|
||||
const len = this.buf.readUInt32BE(0);
|
||||
if (len > MAX_FRAME) { this.sock.destroy(new Error('frame too large')); return; }
|
||||
if (this.buf.length < 4 + len) break;
|
||||
const frame = this.buf.subarray(4, 4 + len);
|
||||
this.buf = this.buf.subarray(4 + len);
|
||||
let msg;
|
||||
try { msg = this.decode(frame); } catch (e) { this.sock.destroy(e); return; }
|
||||
const w = this.waiters.shift();
|
||||
if (w) w.resolve(msg); else this.queue.push(msg);
|
||||
}
|
||||
}
|
||||
encode(obj) {
|
||||
const plain = Buffer.from(JSON.stringify(obj));
|
||||
if (!this.keys) return plain;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', this.keys.send, iv);
|
||||
return Buffer.concat([iv, c.update(plain), c.final(), c.getAuthTag()]);
|
||||
}
|
||||
decode(frame) {
|
||||
if (!this.keys) return JSON.parse(frame.toString('utf8'));
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', this.keys.recv, frame.subarray(0, 12));
|
||||
d.setAuthTag(frame.subarray(frame.length - 16));
|
||||
return JSON.parse(Buffer.concat([d.update(frame.subarray(12, frame.length - 16)), d.final()]).toString('utf8'));
|
||||
}
|
||||
send(obj) {
|
||||
const p = this.encode(obj);
|
||||
const h = Buffer.alloc(4); h.writeUInt32BE(p.length);
|
||||
this.sock.write(Buffer.concat([h, p]));
|
||||
}
|
||||
recv(type, timeout = 30000) {
|
||||
const msg = this.queue.length ? Promise.resolve(this.queue.shift()) : this.closed ? Promise.reject(this.closed) : new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
||||
let t;
|
||||
const to = new Promise((_, rej) => { t = setTimeout(() => rej(new Error('timeout')), timeout); });
|
||||
return Promise.race([msg, to]).finally(() => clearTimeout(t)).then((m) => {
|
||||
if (m?.t === 'error') throw new Error(m.msg || 'remote error');
|
||||
if (type && m?.t !== type) throw new Error(`unexpected message ${m?.t}`);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
close() { try { this.sock.end(); } catch {} }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zusammenführen
|
||||
// Schnappschuss für ein Gerät; nicht freigegebene Geheimnisse werden entfernt
|
||||
function snapshot(data) {
|
||||
const share = data.sync?.share || {};
|
||||
const out = { tombstones: data.tombstones || [], knownHosts: data.knownHosts || {} };
|
||||
for (const c of COLLECTIONS) {
|
||||
out[c] = (data[c] || []).map((x) => {
|
||||
const fields = SECRETS[c];
|
||||
if (!fields || (share[c] || []).includes(x.id)) return x;
|
||||
const y = { ...x, _noSecret: true };
|
||||
fields.forEach((f) => delete y[f]);
|
||||
return y;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Eingehenden Schnappschuss übernehmen; liefert true, wenn sich etwas geändert hat
|
||||
function merge(data, incoming) {
|
||||
let changed = false;
|
||||
const tombs = new Map((data.tombstones || []).map((t) => [`${t.c}|${t.id}`, t]));
|
||||
for (const t of incoming.tombstones || []) {
|
||||
const k = `${t.c}|${t.id}`;
|
||||
if (!tombs.has(k) || tombs.get(k).at < t.at) { tombs.set(k, t); changed = true; }
|
||||
}
|
||||
data.tombstones = [...tombs.values()];
|
||||
for (const c of COLLECTIONS) {
|
||||
const list = data[c] || (data[c] = []);
|
||||
for (const inc of incoming[c] || []) {
|
||||
if (!inc?.id) continue;
|
||||
const tomb = tombs.get(`${c}|${inc.id}`);
|
||||
if (tomb && tomb.at >= ts(inc)) continue;
|
||||
const i = list.findIndex((x) => x.id === inc.id);
|
||||
const local = list[i];
|
||||
if (local && ts(local) >= ts(inc)) continue;
|
||||
const { _noSecret, ...item } = inc;
|
||||
// Nicht freigegebene Geheimnisse: lokale Werte behalten
|
||||
if (_noSecret && local) (SECRETS[c] || []).forEach((f) => { if (local[f] !== undefined) item[f] = local[f]; });
|
||||
if (i >= 0) list[i] = item; else list.push(item);
|
||||
changed = true;
|
||||
}
|
||||
// Löschvermerke anwenden
|
||||
const before = list.length;
|
||||
data[c] = list.filter((x) => { const t = tombs.get(`${c}|${x.id}`); return !t || t.at < ts(x); });
|
||||
if (data[c].length !== before) changed = true;
|
||||
}
|
||||
for (const [id, kh] of Object.entries(incoming.knownHosts || {})) {
|
||||
const t = tombs.get(`knownHosts|${id}`);
|
||||
if (t && t.at >= (kh.addedAt || 0)) continue;
|
||||
const local = data.knownHosts[id];
|
||||
if (!local || (kh.addedAt || 0) > (local.addedAt || 0)) { data.knownHosts[id] = kh; changed = true; }
|
||||
}
|
||||
for (const t of tombs.values()) {
|
||||
if (t.c === 'knownHosts' && data.knownHosts[t.id] && (data.knownHosts[t.id].addedAt || 0) <= t.at) { delete data.knownHosts[t.id]; changed = true; }
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Dienst
|
||||
class SyncService {
|
||||
/**
|
||||
* @param {import('./store').Store} store
|
||||
* @param {(event: string, payload?: any) => void} emit Ereignisse an die Oberfläche
|
||||
* @param {(req: { code, name, id }) => Promise<boolean>} confirmPair Vergleichscode anzeigen und Bestätigung abwarten
|
||||
*/
|
||||
constructor(store, emit, confirmPair) {
|
||||
this.store = store;
|
||||
this.emit = emit;
|
||||
this.confirmPair = confirmPair;
|
||||
this.seen = new Map(); // id -> { id, name, address, port, pairable, at }
|
||||
this.pairable = false;
|
||||
this.running = false;
|
||||
this.lastError = '';
|
||||
this.syncing = new Set();
|
||||
}
|
||||
|
||||
get cfg() {
|
||||
const d = this.store.get();
|
||||
d.sync ||= { enabled: false, peers: [], share: { keys: [], hosts: [], vpns: [] } };
|
||||
const s = d.sync;
|
||||
if (!s.deviceId) s.deviceId = crypto.randomUUID();
|
||||
if (!s.deviceName) s.deviceName = os.hostname();
|
||||
s.peers ||= []; s.share ||= { keys: [], hosts: [], vpns: [] };
|
||||
return s;
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Start/Stop
|
||||
async start() {
|
||||
if (this.running || this.store.sealed || !this.cfg.enabled) return;
|
||||
this.running = true;
|
||||
this.server = net.createServer((sock) => this.accept(sock).catch(() => sock.destroy()));
|
||||
this.server.on('error', () => {});
|
||||
await new Promise((res) => {
|
||||
this.server.once('error', () => this.server.listen(0, res));
|
||||
this.server.listen(TCP_PORT, res);
|
||||
});
|
||||
this.port = this.server.address().port;
|
||||
this.udp = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||||
this.udp.on('error', () => {});
|
||||
this.udp.on('message', (msg, rinfo) => this.onBeacon(msg, rinfo));
|
||||
await new Promise((res) => this.udp.bind(UDP_PORT, () => { try { this.udp.setBroadcast(true); } catch {} res(); }));
|
||||
this.beaconTimer = setInterval(() => this.beacon(), 5000);
|
||||
this.syncTimer = setInterval(() => this.syncAll(), 120000);
|
||||
this.beacon();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
clearInterval(this.beaconTimer); clearInterval(this.syncTimer); clearTimeout(this.debounce);
|
||||
try { this.server.close(); } catch {}
|
||||
try { this.udp.close(); } catch {}
|
||||
this.seen.clear();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
setEnabled(on, name) {
|
||||
const c = this.cfg;
|
||||
c.enabled = !!on;
|
||||
if (name) c.deviceName = String(name).slice(0, 60);
|
||||
this.store.save();
|
||||
if (on) this.start(); else this.stop();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Finden
|
||||
broadcastAddrs() {
|
||||
const out = new Set(['255.255.255.255']);
|
||||
for (const list of Object.values(os.networkInterfaces())) {
|
||||
for (const a of list || []) {
|
||||
if (a.family !== 'IPv4' || a.internal) continue;
|
||||
const ip = a.address.split('.').map(Number), mask = a.netmask.split('.').map(Number);
|
||||
out.add(ip.map((o, i) => (o & mask[i]) | (~mask[i] & 255)).join('.'));
|
||||
}
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
beacon() {
|
||||
if (!this.running) return;
|
||||
const c = this.cfg;
|
||||
const msg = Buffer.from(JSON.stringify({ mrterm: PROTO, id: c.deviceId, name: c.deviceName, port: this.port, pairable: this.pairable }));
|
||||
for (const addr of this.broadcastAddrs()) this.udp.send(msg, UDP_PORT, addr, () => {});
|
||||
// Einträge älter als 20 s gelten als offline
|
||||
for (const [id, p] of this.seen) if (Date.now() - p.at > 20000) { this.seen.delete(id); this.emitState(); }
|
||||
}
|
||||
|
||||
onBeacon(buf, rinfo) {
|
||||
let b;
|
||||
try { b = JSON.parse(buf.toString('utf8')); } catch { return; }
|
||||
if (b?.mrterm !== PROTO || !b.id || b.id === this.cfg.deviceId || !Number.isInteger(b.port)) return;
|
||||
const known = this.seen.get(b.id);
|
||||
this.seen.set(b.id, { id: b.id, name: String(b.name || '').slice(0, 60), address: rinfo.address, port: b.port, pairable: !!b.pairable, at: Date.now() });
|
||||
const peer = this.cfg.peers.find((p) => p.id === b.id);
|
||||
if (peer && peer.address !== rinfo.address) { peer.address = rinfo.address; peer.port = b.port; }
|
||||
if (!known) { this.emitState(); if (peer) this.syncWith(peer).catch(() => {}); }
|
||||
else if (known.pairable !== !!b.pairable) this.emitState();
|
||||
}
|
||||
|
||||
setPairable(on) {
|
||||
this.pairable = !!on;
|
||||
clearTimeout(this.pairTimer);
|
||||
if (on) this.pairTimer = setTimeout(() => this.setPairable(false), 180000);
|
||||
this.beacon();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Status für die Oberfläche
|
||||
status() {
|
||||
const c = this.cfg;
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
emitState() { this.emit('sync:state', this.status()); }
|
||||
|
||||
// ------------------------------------------------ Verbindungen
|
||||
connect(address, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.connect({ host: address, port, timeout: 8000 });
|
||||
sock.once('connect', () => { sock.setTimeout(0); resolve(new Channel(sock)); });
|
||||
sock.once('timeout', () => { sock.destroy(); reject(new Error('timeout')); });
|
||||
sock.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Eingehende Verbindung: Kopplung oder Synchronisation
|
||||
async accept(sock) {
|
||||
const ch = new Channel(sock);
|
||||
try {
|
||||
const first = await ch.recv(null, 10000);
|
||||
if (first.t === 'pair') await this.pairResponder(ch, first);
|
||||
else if (first.t === 'hello') await this.sessionResponder(ch, first);
|
||||
} catch (e) {
|
||||
try { ch.send({ t: 'error', msg: e.message }); } catch {}
|
||||
} finally { ch.close(); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Kopplung
|
||||
// Initiator (Gerät, auf dem der Nutzer ein anderes Gerät ausgewählt hat)
|
||||
async pair(id) {
|
||||
const target = this.seen.get(id);
|
||||
if (!target) throw new Error('Device not found');
|
||||
const ch = await this.connect(target.address, target.port);
|
||||
try {
|
||||
const me = x25519();
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const c = this.cfg;
|
||||
ch.send({ t: 'pair', v: PROTO, id: c.deviceId, name: c.deviceName, commit: sha(me.pub, nonce).toString('base64') });
|
||||
const r = await ch.recv('pair-pub');
|
||||
ch.send({ t: 'pair-reveal', pub: me.pub.toString('base64'), nonce: nonce.toString('base64') });
|
||||
const peerPub = Buffer.from(r.pub, 'base64');
|
||||
const { code, key } = this.derivePair(dh(me.privateKey, peerPub), peerPub, me.pub, r.id, c.deviceId);
|
||||
return await this.finishPair(ch, { id: r.id, name: r.name, address: target.address, port: target.port }, code, key, 'initiator');
|
||||
} finally { ch.close(); }
|
||||
}
|
||||
|
||||
// Gegenseite: nur solange "koppelbar" aktiv ist
|
||||
async pairResponder(ch, msg) {
|
||||
if (!this.pairable) throw new Error(i18n.t('Device is not ready for pairing'));
|
||||
const me = x25519();
|
||||
const c = this.cfg;
|
||||
ch.send({ t: 'pair-pub', id: c.deviceId, name: c.deviceName, pub: me.pub.toString('base64') });
|
||||
const rev = await ch.recv('pair-reveal');
|
||||
const peerPub = Buffer.from(rev.pub, 'base64');
|
||||
if (!sha(peerPub, Buffer.from(rev.nonce, 'base64')).equals(Buffer.from(msg.commit, 'base64'))) throw new Error('Commitment mismatch');
|
||||
const { code, key } = this.derivePair(dh(me.privateKey, peerPub), me.pub, peerPub, c.deviceId, msg.id);
|
||||
const addr = ch.sock.remoteAddress?.replace(/^::ffff:/, '');
|
||||
await this.finishPair(ch, { id: msg.id, name: String(msg.name || '').slice(0, 60), address: addr, port: this.seen.get(msg.id)?.port || TCP_PORT }, code, key, 'responder');
|
||||
}
|
||||
|
||||
// Vergleichscode und Kopplungsschlüssel aus dem Schlüsselaustausch (Reihenfolge: Responder, Initiator)
|
||||
derivePair(shared, pubR, pubI, idR, idI) {
|
||||
const transcript = Buffer.concat([pubR, pubI, Buffer.from(`${idR}|${idI}`)]);
|
||||
const code = String(sha('mrterm-sas', transcript).readUInt32BE(0) % 1000000).padStart(6, '0');
|
||||
const key = hkdf(shared, transcript, 'mrterm-pair-key');
|
||||
return { code, key };
|
||||
}
|
||||
|
||||
async finishPair(ch, peer, code, key, role) {
|
||||
const ok = await this.confirmPair({ code, name: peer.name, id: peer.id, role });
|
||||
ch.send({ t: 'pair-confirm', ok, mac: ok ? hmac(key, 'confirm', role).toString('base64') : '' });
|
||||
const r = await ch.recv('pair-confirm', 120000);
|
||||
const other = role === 'initiator' ? 'responder' : 'initiator';
|
||||
if (!ok) throw new Error(i18n.t('Pairing was rejected on this device.'));
|
||||
if (!r.ok) throw new Error(i18n.t('Pairing was rejected on the other device.'));
|
||||
if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'confirm', other))) throw new Error(i18n.t('Pairing verification failed.'));
|
||||
const c = this.cfg;
|
||||
c.peers = [...c.peers.filter((p) => p.id !== peer.id), { ...peer, key: key.toString('base64'), pairedAt: Date.now() }];
|
||||
this.store.save();
|
||||
this.setPairable(false);
|
||||
this.emit('sync:paired', { id: peer.id, name: peer.name });
|
||||
this.emitState();
|
||||
if (role === 'initiator') setTimeout(() => this.syncWith(c.peers.find((p) => p.id === peer.id)).catch(() => {}), 1500);
|
||||
return true;
|
||||
}
|
||||
|
||||
unpair(id) {
|
||||
const c = this.cfg;
|
||||
c.peers = c.peers.filter((p) => p.id !== id);
|
||||
this.store.save();
|
||||
this.emitState();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
// ------------------------------------------------ Sitzung (authentisiert + verschlüsselt)
|
||||
sessionKeys(pairKey, shared, transcript, initiator) {
|
||||
const k1 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-i2r');
|
||||
const k2 = hkdf(Buffer.concat([pairKey, shared]), transcript, 'mrterm-r2i');
|
||||
return initiator ? { send: k1, recv: k2 } : { send: k2, recv: k1 };
|
||||
}
|
||||
|
||||
async syncWith(peer) {
|
||||
if (!peer || !this.running || this.store.sealed || this.syncing.has(peer.id)) return;
|
||||
const live = this.seen.get(peer.id);
|
||||
const address = live?.address || peer.address;
|
||||
const port = live?.port || peer.port || TCP_PORT;
|
||||
if (!address) return;
|
||||
this.syncing.add(peer.id);
|
||||
let ch;
|
||||
try {
|
||||
ch = await this.connect(address, port);
|
||||
const key = Buffer.from(peer.key, 'base64');
|
||||
const me = x25519();
|
||||
const nI = crypto.randomBytes(16);
|
||||
const c = this.cfg;
|
||||
ch.send({ t: 'hello', v: PROTO, id: c.deviceId, eph: me.pub.toString('base64'), nonce: nI.toString('base64') });
|
||||
const r = await ch.recv('hello');
|
||||
if (r.id !== peer.id) throw new Error('Unexpected device');
|
||||
const transcript = Buffer.concat([Buffer.from(c.deviceId), Buffer.from(peer.id), me.pub, Buffer.from(r.eph, 'base64'), nI, Buffer.from(r.nonce, 'base64')]);
|
||||
if (!crypto.timingSafeEqual(Buffer.from(r.mac, 'base64'), hmac(key, 'responder', transcript))) throw new Error('Authentication failed');
|
||||
ch.send({ t: 'auth', mac: hmac(key, 'initiator', transcript).toString('base64') });
|
||||
ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(r.eph, 'base64')), transcript, true);
|
||||
ch.send({ t: 'state', data: snapshot(this.store.get()) });
|
||||
const theirs = await ch.recv('state');
|
||||
this.applyRemote(theirs.data);
|
||||
Object.assign(peer, { lastSync: Date.now(), error: '', address, port });
|
||||
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||
} catch (e) {
|
||||
peer.error = e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
this.syncing.delete(peer.id);
|
||||
ch?.close();
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
|
||||
async sessionResponder(ch, msg) {
|
||||
const c = this.cfg;
|
||||
const peer = c.peers.find((p) => p.id === msg.id);
|
||||
if (!peer || this.store.sealed) throw new Error('Unknown device');
|
||||
const key = Buffer.from(peer.key, 'base64');
|
||||
const me = x25519();
|
||||
const nR = crypto.randomBytes(16);
|
||||
const transcript = Buffer.concat([Buffer.from(msg.id), Buffer.from(c.deviceId), Buffer.from(msg.eph, 'base64'), me.pub, Buffer.from(msg.nonce, 'base64'), nR]);
|
||||
ch.send({ t: 'hello', id: c.deviceId, eph: me.pub.toString('base64'), nonce: nR.toString('base64'), mac: hmac(key, 'responder', transcript).toString('base64') });
|
||||
const a = await ch.recv('auth');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(a.mac, 'base64'), hmac(key, 'initiator', transcript))) throw new Error('Authentication failed');
|
||||
ch.keys = this.sessionKeys(key, dh(me.privateKey, Buffer.from(msg.eph, 'base64')), transcript, false);
|
||||
const theirs = await ch.recv('state');
|
||||
ch.send({ t: 'state', data: snapshot(this.store.get()) });
|
||||
this.applyRemote(theirs.data);
|
||||
Object.assign(peer, { lastSync: Date.now(), error: '', address: ch.sock.remoteAddress?.replace(/^::ffff:/, '') || peer.address });
|
||||
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
applyRemote(data) {
|
||||
if (merge(this.store.get(), data || {})) {
|
||||
this.store.muted = true; this.store.save(); this.store.muted = false;
|
||||
this.emit('sync:changed');
|
||||
// Änderungen an weitere gekoppelte Geräte weitergeben
|
||||
this.schedule(3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Nach lokalen Änderungen (entprellt) mit allen erreichbaren Geräten abgleichen
|
||||
schedule(delay = 2000) {
|
||||
if (!this.running) return;
|
||||
clearTimeout(this.debounce);
|
||||
this.debounce = setTimeout(() => this.syncAll(), delay);
|
||||
}
|
||||
|
||||
async syncAll() {
|
||||
const results = await Promise.allSettled(this.cfg.peers.filter((p) => this.seen.has(p.id) || p.address).map((p) => this.syncWith(p)));
|
||||
return results.filter((r) => r.status === 'fulfilled').length;
|
||||
}
|
||||
|
||||
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.asked = true;
|
||||
this.store.save();
|
||||
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');
|
||||
// Kurzer Test, ob dort ein MrTerm lauscht; das Gerät erscheint danach unter "In der Nähe"
|
||||
const ch = await this.connect(address, Number(port) || TCP_PORT);
|
||||
ch.close();
|
||||
this.seen.set(`manual:${address}`, { id: `manual:${address}`, name: address, address, port: Number(port) || TCP_PORT, pairable: true, at: Date.now() + 600000 });
|
||||
this.emitState();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SyncService, snapshot, merge, Channel };
|
||||
@@ -34,4 +34,5 @@ contextBridge.exposeInMainWorld('api', {
|
||||
close: (id) => ipcRenderer.send('rdp:close', id),
|
||||
},
|
||||
replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v),
|
||||
pairReply: (reqId, ok) => ipcRenderer.send('sync:pairReply', reqId, ok),
|
||||
});
|
||||
|
||||
+103
-3
@@ -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;
|
||||
|
||||
@@ -429,3 +429,11 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
.net-card .dot.on { background: var(--green); }
|
||||
.net-card .dot.err { background: var(--red); }
|
||||
.flabel { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; color: var(--muted); font-weight: 600; margin: 4px 0 6px; }
|
||||
|
||||
/* Synchronisation */
|
||||
.sync-peers .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||||
.sync-peers .dot.on { background: var(--green); }
|
||||
.pair-code { font-size: 34px; font-weight: 700; letter-spacing: 6px; text-align: center; padding: 14px 0 6px; font-variant-numeric: tabular-nums; color: var(--accent); }
|
||||
.pair-list { max-height: 260px; overflow: auto; }
|
||||
.share-list { max-height: 380px; overflow: auto; }
|
||||
.share-list h4 { margin: 12px 0 6px; }
|
||||
|
||||
Reference in New Issue
Block a user