Compare commits

...
6 Commits
11 changed files with 824 additions and 30 deletions
+23
View File
@@ -42,6 +42,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
- **History** of recent connections - **History** of recent connections
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) - **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
- **Backup** export and import - **Backup** export and import
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color - **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet) - **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive - **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
@@ -70,6 +71,28 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual. `Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
## Synchronization between devices
Under **Settings → Synchronization**, you can keep several MrTerm installations in sync, for example your desktop and laptop. Devices talk to each other directly in your local network. There is no server or cloud involved.
1. Turn on **LAN synchronization** on both devices.
2. Click **Pair new device** on both devices and select the other one.
3. Both devices show a 6-digit code. If the codes match, click **Codes match** on both devices.
4. Choose which **SSH keys, host passwords and VPN configurations** this device may share. Nothing secret is shared unless you select it, and you can change the selection at any time. When you add a new key, password or VPN later, MrTerm asks whether to share it.
From then on, hosts, groups, snippets, port forwards, VPNs and known hosts are synchronized automatically whenever both devices are running on the same network. Deletions are synchronized too. If a change was made on both devices, the newest one wins. Device-specific settings such as theme, language and app lock stay local.
**Security:** pairing uses an X25519 key exchange confirmed by the matching code, so another device on the network can't intercept it. Every sync connection is mutually authenticated and encrypted with AES-256-GCM, using a new key for each session.
**Firewall:** devices find each other on UDP port 47811 and sync on TCP port 47812. **CachyOS enables the UFW firewall by default**, so run this once on CachyOS (and on any other Linux with UFW enabled):
```sh
sudo ufw allow 47811/udp
sudo ufw allow 47812/tcp
```
MrTerm shows these commands automatically when it detects CachyOS, UFW or firewalld. Windows asks for permission the first time. If devices can't find each other, you can also add one by its IP address.
## Docker ## Docker
Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory. Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.9.1", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.9.1", "version": "0.10.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.9.1", "version": "0.10.2",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
+54
View File
@@ -543,6 +543,60 @@
'A static IPv6 configuration needs at least one IPv6 address.': 'Eine statische IPv6-Konfiguration braucht mindestens eine IPv6-Adresse.', '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.', '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',
'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 // Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host', 'Unknown host': 'Unbekannter Host',
+33 -7
View File
@@ -15,6 +15,7 @@ const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker'); const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall'); const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network'); const { NetworkConfigManager } = require('./network');
const { SyncService } = require('./sync');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -75,6 +76,17 @@ const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh); const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(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() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
width: 1360, width: 1360,
@@ -134,7 +146,7 @@ function lockStatus() {
} }
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); } function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] }); 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:status', lockStatus);
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); }); handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
@@ -190,7 +202,9 @@ handle('lock:removeFido', (id) => {
}); });
// ---------- Vault ---------- // ---------- 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) => { handle('vault:upsert', async (col, item) => {
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren // Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
if (col === 'vpns' && item.id) { if (col === 'vpns' && item.id) {
@@ -202,7 +216,7 @@ handle('vault:upsert', async (col, item) => {
handle('vault:remove', async (col, id) => { handle('vault:remove', async (col, id) => {
if (col === 'vpns') { if (col === 'vpns') {
await vpn.forget(store.get().vpns.find((v) => v.id === id)); 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); return store.remove(col, id);
}); });
@@ -245,6 +259,17 @@ handle('network:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify)); handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id)); 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));
// ---------- VPN ---------- // ---------- VPN ----------
handle('vpn:status', () => vpn.status()); handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id)); handle('vpn:up', (id) => vpn.up(id));
@@ -254,18 +279,18 @@ handle('vault:settings', (s) => {
if ('language' in s) applyLanguage(); if ('language' in s) applyLanguage();
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false })); 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 () => { handle('vault:export', async () => {
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
if (r.canceled) return false; 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; return r.filePath;
}); });
handle('vault:import', async () => { handle('vault:import', async () => {
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] }); const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
if (r.canceled) return false; if (r.canceled) return false;
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); 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); for (const item of data[col] || []) store.upsert(col, item);
return true; return true;
}); });
@@ -525,6 +550,7 @@ app.whenReady().then(() => {
store.load(); store.load();
applyLanguage(); applyLanguage();
createWindow(); createWindow();
sync.start().catch(() => {});
scheduleUpdateCheck(); scheduleUpdateCheck();
}); });
@@ -535,7 +561,7 @@ function scheduleUpdateCheck() {
updateScheduled = true; updateScheduled = true;
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000); 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. // Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
if (process.env.MRTERM_SMOKE) { if (process.env.MRTERM_SMOKE) {
+13 -10
View File
@@ -149,8 +149,10 @@ class NetworkConfigManager {
} }
watch(id, s) { watch(id, s) {
s.conn.on('close', () => { if (this.sessions.get(id) === s && !s.replacing) { this.close(id); s.onClose(); } }); const conn = s.conn;
s.conn.on('error', () => {}); // Nur die aktuelle Verbindung darf die Sitzung beenden (nach apply wird sie ersetzt)
conn.on('close', () => { if (this.sessions.get(id) === s && s.conn === conn) { this.close(id); s.onClose(); } });
conn.on('error', () => {});
} }
async read(id) { async read(id) {
@@ -226,7 +228,7 @@ class NetworkConfigManager {
const be = BACKENDS[backendName || s.backend]; const be = BACKENDS[backendName || s.backend];
if (!be) throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend })); if (!be) throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend }));
const script = `export PATH=$PATH:/usr/sbin:/sbin const script = `export PATH=$PATH:/usr/sbin:/sbin
mkdir -p ${STATE} || exit 1; rm -f ${STATE}/pending ${STATE}/rolled-back mkdir -p ${STATE} || exit 1; rm -f ${STATE}/pending ${STATE}/applied ${STATE}/rolled-back
cd / && tar czf ${STATE}/backup.tgz $(for p in ${be.paths.join(' ')}; do [ -e "$p" ] && echo "$p"; done) || exit 1 cd / && tar czf ${STATE}/backup.tgz $(for p in ${be.paths.join(' ')}; do [ -e "$p" ] && echo "$p"; done) || exit 1
# Schreiben in einer Subshell: bei einem Fehler sofort den alten Stand wiederherstellen # Schreiben in einer Subshell: bei einem Fehler sofort den alten Stand wiederherstellen
if ! ( set -e if ! ( set -e
@@ -234,7 +236,7 @@ ${writes.join('\n')}
) >${STATE}/write.log 2>&1; then ${be.restore}; cat ${STATE}/write.log >&2; exit 5; fi ) >${STATE}/write.log 2>&1; then ${be.restore}; cat ${STATE}/write.log >&2; exit 5; fi
if ! (${be.validate}) >${STATE}/validate.log 2>&1; then ${be.restore}; cat ${STATE}/validate.log >&2; exit 4; fi if ! (${be.validate}) >${STATE}/validate.log 2>&1; then ${be.restore}; cat ${STATE}/validate.log >&2; exit 4; fi
touch ${STATE}/pending touch ${STATE}/pending
nohup setsid sh -c 'export PATH=$PATH:/usr/sbin:/sbin; sleep 2; (${be.apply}) >${STATE}/apply.log 2>&1; sleep ${ROLLBACK_SECONDS}; if [ -f ${STATE}/pending ]; then ${be.restore}; (${be.apply}) >>${STATE}/apply.log 2>&1; rm -f ${STATE}/pending; date "+%Y-%m-%d %H:%M:%S" > ${STATE}/rolled-back; fi' >/dev/null 2>&1 & nohup setsid sh -c 'export PATH=$PATH:/usr/sbin:/sbin; sleep 2; (${be.apply}) >${STATE}/apply.log 2>&1; touch ${STATE}/applied; sleep ${ROLLBACK_SECONDS}; if [ -f ${STATE}/pending ]; then ${be.restore}; (${be.apply}) >>${STATE}/apply.log 2>&1; rm -f ${STATE}/pending; date "+%Y-%m-%d %H:%M:%S" > ${STATE}/rolled-back; fi' >/dev/null 2>&1 &
echo started`; echo started`;
const r = await this.root(s, script); const r = await this.root(s, script);
if (r.code === 4) throw new Error(i18n.t('The new configuration is invalid and was not applied: {err}', { err: lastLine(r.err) })); if (r.code === 4) throw new Error(i18n.t('The new configuration is invalid and was not applied: {err}', { err: lastLine(r.err) }));
@@ -245,13 +247,14 @@ echo started`;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const { conn, jumps } = await this.ssh.connect(target, id); const { conn, jumps } = await this.ssh.connect(target, id);
const c = await this.root(s, `rm -f ${STATE}/pending`, conn); // Erst bestätigen, wenn das Anwenden fertig ist: sonst reißt z. B. ein Bond-Neustart die neue Verbindung wieder ab
if (c.code) { conn.end(); throw new Error(lastLine(c.err)); } const c = await this.root(s, `[ -f ${STATE}/applied ] || exit 7; rm -f ${STATE}/pending`, conn);
if (c.code) { conn.on('error', () => {}); conn.end(); jumps?.forEach((x) => { try { x.end(); } catch {} }); throw new Error(lastLine(c.err) || 'not applied yet'); }
// Neue Verbindung übernimmt die Sitzung // Neue Verbindung übernimmt die Sitzung
s.replacing = true; const old = { conn: s.conn, jumps: s.jumps };
try { s.conn.end(); } catch {} Object.assign(s, { conn, jumps });
s.jumps?.forEach((x) => { try { x.end(); } catch {} }); // Alte Verbindung ist nach dem IP-Wechsel meist tot: Fehler (z. B. Keepalive-Timeout) still verwerfen
Object.assign(s, { conn, jumps, replacing: false }); for (const c of [old.conn, ...(old.jumps || [])]) { c.on('error', () => {}); try { c.end(); } catch {} }
if (verifyAddress) s.host = target; if (verifyAddress) s.host = target;
this.watch(id, s); this.watch(id, s);
return { confirmed: true }; return { confirmed: true };
+19 -1
View File
@@ -15,6 +15,10 @@ const DEFAULTS = {
vpns: [], vpns: [],
knownHosts: {}, knownHosts: {},
history: [], 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: { settings: {
terminalTheme: 'mrterm', terminalTheme: 'mrterm',
fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace', fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace',
@@ -124,6 +128,7 @@ class Store {
save() { save() {
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
if (!this.muted) this.onChange?.();
let json = JSON.stringify(this.data, null, 2); let json = JSON.stringify(this.data, null, 2);
if (this.lockEnabled && this.dek) { if (this.lockEnabled && this.dek) {
const { language, appTheme, accent } = this.data.settings; const { language, appTheme, accent } = this.data.settings;
@@ -156,7 +161,20 @@ class Store {
remove(collection, id) { remove(collection, id) {
this.data[collection] = this.data[collection].filter((x) => x.id !== 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(); this.save();
} }
+502
View File
@@ -0,0 +1,502 @@
// 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 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;
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();
this.firewall = localFirewall();
}
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, firewall: this.firewall,
};
}
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), 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');
// 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 };
+1
View File
@@ -34,4 +34,5 @@ contextBridge.exposeInMainWorld('api', {
close: (id) => ipcRenderer.send('rdp:close', id), close: (id) => ipcRenderer.send('rdp:close', id),
}, },
replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v), replySecret: (reqId, v) => ipcRenderer.send('secret:reply', reqId, v),
pairReply: (reqId, ok) => ipcRenderer.send('sync:pairReply', reqId, ok),
}); });
+162 -9
View File
@@ -83,7 +83,7 @@ async function call(ch, ...a) {
} }
// ---------- Modals // ---------- Modals
function modal({ title, text = '', body = '', buttons = [] }) { function modal({ title, text = '', body = '', buttons = [], noEnter = false }) {
return new Promise((resolve) => { 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 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); 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('mousedown', (e) => { if (e.target === bg) done(null); });
bg.addEventListener('keydown', (e) => { bg.addEventListener('keydown', (e) => {
if (e.key === 'Escape') done(null); 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); $('#modalRoot').append(bg);
($('input,select,textarea', bg) || foot.lastElementChild).focus(); ($('input,select,textarea', bg) || foot.lastElementChild).focus();
@@ -556,7 +556,8 @@ function editHost(hst = {}) {
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean), 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, 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(); 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; })()); }, 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); setProto(proto);
@@ -617,7 +618,8 @@ async function generateKey() {
}); });
if (!r) return; if (!r) return;
const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label }); 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); await call('clipboard:write', k.publicKey);
toast(T('Key generated – public key copied to clipboard'), 'ok'); toast(T('Key generated – public key copied to clipboard'), 'ok');
reload(); reload();
@@ -633,7 +635,8 @@ function editKey(k = {}, pick = false) {
const v = formValues(form); const v = formValues(form);
if (!v.privateKey.trim()) throw new Error(T('Private key is missing.')); if (!v.privateKey.trim()) throw new Error(T('Private key is missing.'));
const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase }); 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(); reload();
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })()); }, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
async function loadFile() { 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, 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 : '' }; username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
const saved = await call('vault:upsert', 'vpns', item); const saved = await call('vault:upsert', 'vpns', item);
askShareSecret('vpns', saved, saved.label);
for (const cb of $$('[data-host]', hostBox)) { for (const cb of $$('[data-host]', hostBox)) {
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host); 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); const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
@@ -945,6 +949,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>`); 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)); renderLockSettings($('.lock-rows', secCard));
// ---- LAN-Synchronisation
const syncCard = h('<div class="settings-card sync-card"></div>');
renderSyncCard(syncCard);
// ---- Updates // ---- 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>`); 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 }); }); api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
@@ -977,7 +985,7 @@ async function viewSettings(page) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span> <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>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`); <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 // ============================================================ App-Sperre
@@ -1092,6 +1100,146 @@ 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);
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);
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);
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
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>${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>`);
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 // ============================================================ Design
function termTheme() { function termTheme() {
const id = settings().terminalTheme; const id = settings().terminalTheme;
@@ -2037,8 +2185,13 @@ class NetworkSession {
this.draw(); this.draw();
} }
async refresh() { async refresh(tries = 1) {
try { this.apply(await api.call('network:read', this.id)); } catch (e) { toast(e.message, 'error'); } for (let i = 1; ; i++) {
try { return this.apply(await api.call('network:read', this.id)); } catch (e) {
if (i >= tries) return toast(e.message, 'error');
await new Promise((r) => setTimeout(r, 3000));
}
}
} }
cfg(name) { return this.data.config.find((c) => c.name === name); } cfg(name) { return this.data.config.find((c) => c.name === name); }
@@ -2110,7 +2263,7 @@ class NetworkSession {
try { try {
const r = await fn(); const r = await fn();
ov.remove(); ov.remove();
if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(); } if (r?.confirmed) { toast(r.direct ? T('Saved') : T('Network change applied and confirmed'), 'ok'); await this.refresh(4); }
else { else {
setTabState(this, 'err'); setTabState(this, 'err');
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`; this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--orange);max-width:560px;text-align:center">${T('MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.')}</div></div>`;
+14
View File
@@ -429,3 +429,17 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
.net-card .dot.on { background: var(--green); } .net-card .dot.on { background: var(--green); }
.net-card .dot.err { background: var(--red); } .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; } .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; }
.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; }