diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..453b9d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +mobile +build +docs +.git +.gitea-token +web-data diff --git a/.gitignore b/.gitignore index d6839ae..e758878 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ mobile/vendor/ mobile/www/ mobile/android/keystore.properties mobile/android/*.jks +web-data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d220e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet. +# docker compose up -d → http://:8080 (beim ersten Aufruf Admin-Konto anlegen) + +# ---- Build: Server samt ssh2/ws in eine Datei bündeln +FROM node:22-alpine AS build +WORKDIR /src +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY src ./src +RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \ + --external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \ + --outfile=dist/server.js + +# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche +FROM node:22-alpine +ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app +WORKDIR /app +COPY package.json ./ +COPY --from=build /src/dist/server.js dist/server.js +COPY src/i18n.js src/i18n.js +COPY src/renderer src/renderer +COPY src/web/public src/web/public +COPY --from=build /src/node_modules/@xterm node_modules/@xterm +RUN mkdir -p /data && chown node:node /data +USER node +VOLUME /data +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index 9a2ca6d..dec405a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app. - **History** of recent connections - **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) - **Backup** export and import +- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker) - **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 - **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet) @@ -82,6 +83,24 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app. | ![Settings](docs/screenshots/settings.png) | | | **Settings**: language, app lock, LAN sync and themes | | +## Web version (Docker) + +MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer. + +```bash +curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml +docker compose up -d +``` + +Then open `http://:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**. + +- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes. +- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer. +- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered. +- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets. +- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open. +- **SSH connections** start from the server, so the server must be able to reach your hosts. + ## Getting started 1. Click **New host**, enter the address, username and password or key, and click **Save**. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4d563b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# MrTerm Web +# docker compose up -d +# Danach http://:8080 öffnen und das Admin-Konto anlegen. +# Hinter einem Reverse-Proxy mit HTTPS (empfohlen): TRUST_PROXY=1 setzen und den Port nur lokal freigeben. +services: + mrterm: + build: + # Baut direkt aus dem Repository – kein Klonen nötig. Alternativ "context: ." in einem Klon. + context: https://git.mrblake.cc/MrBlake/MrTerm.git + image: mrterm-web + container_name: mrterm + restart: unless-stopped + ports: + - "8080:8080" + environment: + - TZ=Europe/Berlin + # - TRUST_PROXY=1 + volumes: + - mrterm-data:/data + +volumes: + mrterm-data: diff --git a/mobile/nodejs/server.js b/mobile/nodejs/server.js index 098c3a2..766bf22 100644 --- a/mobile/nodejs/server.js +++ b/mobile/nodejs/server.js @@ -1,16 +1,11 @@ -// Node-Backend der Android-App. Nutzt dieselben Module wie der Electron-Hauptprozess -// (Tresor, SSH, LAN-Sync, Docker, Firewall, Netzwerk) und spricht über die Capacitor-Bridge mit der Oberfläche. +// Node-Backend der Android-App. Die Kanäle kommen aus src/core/backend.js (gemeinsam mit der Web-Version); +// hier nur, was Android betrifft: Bridge-Anbindung, Entsperren per Fingerabdruck, Pause/Fortsetzen. // Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente]; // Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente]. const { channel } = require('bridge'); const crypto = require('crypto'); -const { utils: sshUtils } = require('ssh2'); const { Store } = require('../../src/main/store'); -const { SshManager } = require('../../src/main/ssh'); -const { DockerManager } = require('../../src/main/docker'); -const { FirewallManager } = require('../../src/main/firewall'); -const { NetworkConfigManager } = require('../../src/main/network'); -const { SyncService } = require('../../src/main/sync'); +const { createBackend } = require('../../src/core/backend'); const i18n = require('../../src/i18n'); const send = (ch, ...args) => channel.send('evt', ch, args); @@ -18,182 +13,36 @@ process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.mess process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); const store = new Store(); -const applyLanguage = () => i18n.setLanguage(store.get().settings.language, process.env.MRTERM_LOCALE || 'en'); - -// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) -const pending = new Map(); -function ask(ch, req, timeout = 0) { - return new Promise((resolve) => { - const reqId = crypto.randomUUID(); - pending.set(reqId, resolve); - send(ch, { reqId, ...req }); - if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout); - }); -} -const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); }; - -const confirmHostKey = async (target, fingerprint, known) => - !!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' })); -const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req }); - -const ssh = new SshManager(store, confirmHostKey, askSecret); -const docker = new DockerManager(ssh); -const firewall = new FirewallManager(ssh); -const network = new NetworkConfigManager(ssh); -const sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000))); -store.onChange = () => sync.schedule(); - -const handlers = new Map(); -const OPEN_WHILE_LOCKED = new Set(['app:version']); -const handle = (ch, fn) => handlers.set(ch, fn); +const applyLanguage = (lang = store.get().settings.language) => i18n.setLanguage(lang, process.env.MRTERM_LOCALE || 'en'); +const be = createBackend({ store, send, platform: 'android', version: process.env.MRTERM_VERSION || '0.0.0', withSync: true, onLanguage: applyLanguage }); channel.addListener('call', async (reqId, ch, args = []) => { - try { - const fn = handlers.get(ch); - if (!fn) throw new Error(`Unknown channel ${ch}`); - if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.')); - channel.send('reply', reqId, true, await fn(...args)); - } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); } + try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); } }); -const listeners = { - 'ssh:write': (id, d) => ssh.write(id, d), - 'ssh:resize': (id, c, r) => ssh.resize(id, c, r), - 'ssh:close': (id) => ssh.close(id), - 'ask:reply': answer, -}; -channel.addListener('send', (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } }); +channel.addListener('send', (ch, args = []) => be.notify(ch, args)); -// ---------- App-Sperre: Passwort, zusätzlich Fingerabdruck (Geheimnis aus dem Android Keystore) ---------- -const kdf = (pw, salt, N) => new Promise((resolve, reject) => - crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k)))); +// ---------- Fingerabdruck (Geheimnis aus dem Android Keystore, nur zusätzlich zum Passwort) ---------- const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32)); -function lockStatus() { - const { language, appTheme, accent } = store.get().settings; - return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, meta: { language, appTheme, accent } }; -} -const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }; -const afterUnlock = () => { applyLanguage(); sync.start().catch(() => {}); }; - -handle('lock:status', lockStatus); -handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); }); -handle('lock:unlockPassword', async (pw) => { - const p = store.lock?.password; - if (!p) throw new Error(i18n.t('No password set.')); - const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N); - try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); } - afterUnlock(); - return true; -}); -handle('lock:unlockBio', (secret) => { +be.handle('lock:unlockBio', (secret) => { if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.')); try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); } - afterUnlock(); + applyLanguage(); + be.sync.start().catch(() => {}); return true; }); -handle('lock:setPassword', async (pw) => { - requireUnlocked(); - if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.')); - const salt = crypto.randomBytes(16), N = 2 ** 15; - const kek = await kdf(pw, salt, N); - store.lock = store.lock || { password: null, fido: [] }; - store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) }; - store.save(); - return lockStatus(); -}); -handle('lock:removePassword', () => { - requireUnlocked(); - if (store.lock) { store.lock.password = null; store.lock.bio = null; } // Fingerabdruck nur zusätzlich zum Passwort - store.dropLockIfEmpty(); - store.save(); - return lockStatus(); -}); -handle('lock:setBio', (secret) => { - requireUnlocked(); +be.handle('lock:setBio', (secret) => { + be.requireUnlocked(); if (!store.lock?.password) throw new Error(i18n.t('Set a password first.')); store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null; store.save(); - return lockStatus(); + return be.lockStatus(); }); -// ---------- Tresor ---------- -const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; }; -handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: 'android' })); -handle('vault:upsert', (col, item) => store.upsert(col, item)); -handle('vault:remove', (col, id) => { - if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } }); - return store.remove(col, id); -}); -handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) applyLanguage(); }); -handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); -handle('app:version', () => process.env.MRTERM_VERSION || '0.0.0'); - -// ---------- Schlüssel ---------- -handle('key:generate', ({ type, bits, comment, passphrase }) => { - const opts = { comment: comment || 'mrterm@android' }; - if (type === 'rsa') opts.bits = Number(bits) || 4096; - if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; } - const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts); - return { privateKey: k.private, publicKey: k.public }; -}); -handle('key:parse', ({ privateKey, passphrase }) => { - const k = sshUtils.parseKey(privateKey, passphrase || undefined); - if (k instanceof Error) throw k; - const key = Array.isArray(k) ? k[0] : k; - return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() }; -}); - -// ---------- SSH-Terminal ---------- -function hostWithOverrides(ref) { - if (typeof ref === 'string') { - const h = store.resolveHost(ref); - if (!h) throw new Error(i18n.t('Host not found')); - return h; - } - if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label }; - return ref; -} -handle('ssh:open', async (sessionId, ref, size) => { - const host = hostWithOverrides(ref); - if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() }); - await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload)); - return true; -}); - -// ---------- Docker / Firewall / Netzwerk (gleiche Module wie am Desktop) ---------- -handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id))); -handle('docker:list', (id) => docker.list(id)); -handle('docker:stats', (id) => docker.stats(id)); -handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); -handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); -handle('docker:close', (id) => docker.close(id)); -handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id))); -handle('firewall:list', (id, backend) => firewall.list(id, backend)); -handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args)); -handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args)); -handle('firewall:close', (id) => firewall.close(id)); -handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id))); -handle('network:read', (id) => network.read(id)); -handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify)); -handle('network:remove', (id, model) => network.removeInterface(id, model)); -handle('network:hostname', (id, name) => network.setHostname(id, name)); -handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search)); -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)); // App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen -handle('app:pause', () => sync.stop()); -handle('app:resume', () => sync.start().catch(() => {})); +be.handle('app:pause', () => be.sync.stop()); +be.handle('app:resume', () => be.sync.start().catch(() => {})); store.load(); applyLanguage(); -sync.start().catch(() => {}); +be.sync.start().catch(() => {}); channel.send('ready'); diff --git a/package-lock.json b/package-lock.json index 9ece8af..078984d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ }, "devDependencies": { "electron": "^44.4.5", - "electron-builder": "^26.15.3" + "electron-builder": "^26.15.3", + "esbuild": "^0.28.2", + "ws": "^8.21.3" } }, "node_modules/@electron-internal/extract-zip": { @@ -325,6 +327,448 @@ "node": ">=14.14" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2071,6 +2515,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3992,6 +4478,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index bd64832..0742d53 100644 --- a/package.json +++ b/package.json @@ -68,12 +68,15 @@ }, "devDependencies": { "electron": "^44.4.5", - "electron-builder": "^26.15.3" + "electron-builder": "^26.15.3", + "esbuild": "^0.28.2", + "ws": "^8.21.3" }, "allowScripts": { "electron@44.4.5": true, "electron-winstaller@5.4.0": true, "ssh2@1.17.0": true, - "koffi@3.3.1": true + "koffi@3.3.1": true, + "esbuild@0.28.2": true } } diff --git a/src/core/backend.js b/src/core/backend.js new file mode 100644 index 0000000..5a14912 --- /dev/null +++ b/src/core/backend.js @@ -0,0 +1,223 @@ +// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker). +// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module. +// +// createBackend({ store, send, platform, version, withSync, onLanguage }) +// store Store-Instanz (bereits geladen) +// send (kanal, ...args) → Ereignis an die Oberfläche +// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle } +const crypto = require('crypto'); +const { utils: sshUtils } = require('ssh2'); +const { SshManager } = require('../main/ssh'); +const { DockerManager } = require('../main/docker'); +const { FirewallManager } = require('../main/firewall'); +const { NetworkConfigManager } = require('../main/network'); +const i18n = require('../i18n'); + +function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) { + // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) + const pending = new Map(); + const ask = (ch, req, timeout = 0) => new Promise((resolve) => { + const reqId = crypto.randomUUID(); + pending.set(reqId, resolve); + send(ch, { reqId, ...req }); + if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout); + }); + const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); }; + + const confirmHostKey = async (target, fingerprint, known) => + !!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' })); + const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req }); + + const ssh = new SshManager(store, confirmHostKey, askSecret); + const docker = new DockerManager(ssh); + const firewall = new FirewallManager(ssh); + const network = new NetworkConfigManager(ssh); + let sync = null; + if (withSync) { + const { SyncService } = require('../main/sync'); + sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000))); + store.onChange = () => sync.schedule(); + } + + const handlers = new Map(); + const handle = (ch, fn) => handlers.set(ch, fn); + const OPEN_WHILE_LOCKED = new Set(['app:version']); + + async function call(ch, args = []) { + const fn = handlers.get(ch); + if (!fn) throw new Error(`Unknown channel ${ch}`); + if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.')); + return fn(...args); + } + const listeners = { + 'ssh:write': (id, d) => ssh.write(id, d), + 'ssh:resize': (id, c, r) => ssh.resize(id, c, r), + 'ssh:close': (id) => ssh.close(id), + 'ask:reply': answer, + // Namen der Desktop-Vorlage (preload.js) + 'secret:reply': answer, + 'sync:pairReply': answer, + }; + const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } }; + + // ---------- App-Sperre (Passwort) ---------- + const kdf = (pw, salt, N) => new Promise((resolve, reject) => + crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k)))); + const lockStatus = () => { + const { language, appTheme, accent } = store.get().settings; + return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } }; + }; + const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }; + const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); }; + handle('lock:status', lockStatus); + handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); }); + handle('lock:unlockPassword', async (pw) => { + const p = store.lock?.password; + if (!p) throw new Error(i18n.t('No password set.')); + const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N); + try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); } + afterUnlock(); + return true; + }); + handle('lock:setPassword', async (pw) => { + requireUnlocked(); + if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.')); + const salt = crypto.randomBytes(16), N = 2 ** 15; + const kek = await kdf(pw, salt, N); + store.lock = store.lock || { password: null, fido: [] }; + store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) }; + store.save(); + return lockStatus(); + }); + handle('lock:removePassword', () => { + requireUnlocked(); + if (store.lock) { store.lock.password = null; store.lock.bio = null; } + store.dropLockIfEmpty(); + store.save(); + return lockStatus(); + }); + + // ---------- Tresor ---------- + const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; }; + handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform })); + handle('vault:upsert', (col, item) => store.upsert(col, item)); + handle('vault:remove', (col, id) => { + if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } }); + return store.remove(col, id); + }); + handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); }); + handle('vault:forgetHost', (id) => store.forgetKnownHost(id)); + handle('vault:exportData', () => publicData()); + handle('vault:importData', (data) => { + for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) + for (const item of data?.[col] || []) store.upsert(col, item); + return true; + }); + // entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager) + handle('vault:bulkImport', (entries) => { + const groups = store.get().groups; + const ensure = (parts) => { + let parent = null; + for (const label of parts) { + let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase()); + if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); } + parent = g.id; + } + return parent; + }; + let hosts = 0, skipped = 0; + for (const e of entries) { + const groupId = ensure(e.folder || []); + if (!e.host) continue; + const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol); + if (dup) { skipped++; continue; } + store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() }); + hosts++; + } + store.save(); + return { hosts, skipped, groups: groups.length }; + }); + handle('app:version', () => version); + + // ---------- Schlüssel ---------- + handle('key:generate', ({ type, bits, comment, passphrase }) => { + const opts = { comment: comment || `mrterm@${platform}` }; + if (type === 'rsa') opts.bits = Number(bits) || 4096; + if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; } + const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts); + return { privateKey: k.private, publicKey: k.public }; + }); + handle('key:parse', ({ privateKey, passphrase }) => { + const k = sshUtils.parseKey(privateKey, passphrase || undefined); + if (k instanceof Error) throw k; + const key = Array.isArray(k) ? k[0] : k; + return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() }; + }); + + // ---------- SSH-Terminal & SFTP ---------- + function hostWithOverrides(ref) { + if (typeof ref === 'string') { + const h = store.resolveHost(ref); + if (!h) throw new Error(i18n.t('Host not found')); + return h; + } + if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label }; + return ref; + } + handle('ssh:open', async (sessionId, ref, size) => { + const host = hostWithOverrides(ref); + if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() }); + await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload)); + return true; + }); + handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref))); + handle('sftp:list', (id, dir) => ssh.sftpList(id, dir)); + handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b)); + handle('sftp:close', (id) => ssh.close(id)); + + // ---------- Docker / Firewall / Netzwerk ---------- + handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id))); + handle('docker:list', (id) => docker.list(id)); + handle('docker:stats', (id) => docker.stats(id)); + handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); + handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); + handle('docker:close', (id) => docker.close(id)); + handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id))); + handle('firewall:list', (id, backend) => firewall.list(id, backend)); + handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args)); + handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args)); + handle('firewall:close', (id) => firewall.close(id)); + handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id))); + handle('network:read', (id) => network.read(id)); + handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify)); + handle('network:remove', (id, model) => network.removeInterface(id, model)); + handle('network:hostname', (id, name) => network.setHostname(id, name)); + handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search)); + 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 ---------- + if (sync) { + 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)); + } + + function close() { + ssh.closeAll?.(); + sync?.stop(); + for (const r of pending.values()) r(null); + pending.clear(); + } + + return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus }; +} + +module.exports = { createBackend }; diff --git a/src/i18n.js b/src/i18n.js index edc97f0..d8b6bd9 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -650,6 +650,38 @@ 'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.', 'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.', 'Set a password first.': 'Lege zuerst ein Passwort fest.', + // Web-Version + 'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.', + 'Account': 'Konto', + 'Signed in as {name}': 'Angemeldet als {name}', + 'Administrator': 'Administrator', + 'Current password': 'Aktuelles Passwort', + 'Sign out': 'Abmelden', + 'Users': 'Benutzer', + 'User': 'Benutzer', + 'Delete user?': 'Benutzer löschen?', + '“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.', + 'Add user': 'Benutzer hinzufügen', + 'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.', + 'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}', + 'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.', + 'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.', + 'Create account': 'Konto anlegen', + 'Sign in': 'Anmelden', + 'Administrators only.': 'Nur für Administratoren.', + 'Forbidden': 'Nicht erlaubt', + 'Not signed in': 'Nicht angemeldet', + 'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.', + 'The current password is wrong.': 'Das aktuelle Passwort ist falsch.', + 'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.', + 'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.', + 'User not found.': 'Benutzer nicht gefunden.', + 'Wrong username or password.': 'Benutzername oder Passwort falsch.', + 'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.', + 'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.', + 'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.', + 'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).', + 'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …', // Main-Prozess 'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Unknown host': 'Unbekannter Host', diff --git a/src/main/store.js b/src/main/store.js index 29e6d50..01a258a 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -1,6 +1,7 @@ // Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings. // Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt. -const { app, safeStorage } = require('electron'); +// Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts) +const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })(); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); @@ -57,8 +58,10 @@ function unbox(key, b) { } class Store { - constructor() { - this.dir = app.getPath('userData'); + // opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage + constructor(opts = {}) { + this.dir = opts.dir || electron.app.getPath('userData'); + this.crypto = opts.crypto || electron.safeStorage; this.file = path.join(this.dir, 'vault.dat'); this.data = structuredClone(DEFAULTS); this.encrypted = false; @@ -97,9 +100,9 @@ class Store { canEncrypt() { try { - if (!safeStorage.isEncryptionAvailable()) return false; + if (!this.crypto.isEncryptionAvailable()) return false; // Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung. - if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend?.() === 'basic_text') return false; + if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false; return true; } catch { return false; } } @@ -110,7 +113,7 @@ class Store { const raw = fs.readFileSync(this.file); let json; if (raw.slice(0, 4).toString() === 'ENC1') { - json = safeStorage.decryptString(raw.slice(4)); + json = this.crypto.decryptString(raw.slice(4)); this.encrypted = true; } else { json = raw.toString('utf8'); @@ -136,7 +139,7 @@ class Store { } const tmp = this.file + '.tmp'; if (this.canEncrypt()) { - fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)])); + fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)])); this.encrypted = true; } else { fs.writeFileSync(tmp, json, { mode: 0o600 }); diff --git a/src/renderer/app.js b/src/renderer/app.js index cf700ee..0650485 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -8,6 +8,8 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', ' const uid = () => crypto.randomUUID(); function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; } const T = I18N.t; +// Web-Version (Docker, src/web): gleiche Oberfläche im Browser; ohne RDP, VPN, Port-Forwarding und lokale Dateien +const WEB = api.platform === 'web'; const ICONS = { search: '', @@ -407,6 +409,7 @@ function connectHost(hst) { } async function openRdp(hst) { + if (WEB) return toast(T('RDP is not available in the web version.'), 'error'); if (settings().rdpEmbed !== false) { const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message })); if (s.ok) return new RdpSession(hst); @@ -863,6 +866,60 @@ function viewHistory(page) { } // ============================================================ Einstellungen +// ---------- Web-Version: Konto und Benutzerverwaltung (HTTP-API des Web-Servers) +async function webApi(path, body) { + const r = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: body === undefined ? undefined : JSON.stringify(body) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(T(j.error || r.statusText)); + return j; +} +function webAccountCard() { + const card = h(`

${T('Account')}

`); + const draw = async () => { + const box = $('.acc', card); + let me; + try { me = await webApi('/api/me'); } catch (e) { box.textContent = e.message; return; } + box.innerHTML = `

${esc(T('Signed in as {name}', { name: me.name }))}${me.admin ? ` · ${esc(T('Administrator'))}` : ''}

`; + const pw = h(``); + pw.onclick = async () => { + const r = await modal({ title: T('Change password'), body: `
`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] }); + if (!r) return; + if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error'); + try { await webApi('/api/password', { old: r.old, password: r.a }); toast(T('Saved'), 'ok'); } catch (e) { toast(e.message, 'error'); } + }; + const out = h(``); + out.onclick = async () => { await webApi('/api/logout', {}).catch(() => {}); location.href = '/'; }; + $('.row', box).append(pw, out); + if (!me.admin) return; + const users = await webApi('/api/users').catch(() => ({ users: [] })); + const list = h(`

${esc(T('Users'))}

`); + for (const u of users.users) { + const row = h(`
${esc(u.name)}
${u.admin ? esc(T('Administrator')) : esc(T('User'))}
`); + if (u.id !== me.id) { + const del = h(``); + del.onclick = async () => { + if (!(await confirmBox(T('Delete user?'), T('“{name}” and their vault will be permanently deleted.', { name: u.name })))) return; + try { await webApi('/api/users/delete', { id: u.id }); draw(); } catch (e) { toast(e.message, 'error'); } + }; + row.append(del); + } + list.append(row); + } + const add = h(``); + add.onclick = async () => { + const r = await modal({ title: T('Add user'), body: `
`, + buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add user'), value: 'form', cls: 'primary' }] }); + if (!r) return; + try { await webApi('/api/users', r); toast(T('Saved'), 'ok'); draw(); } catch (e) { toast(e.message, 'error'); } + }; + list.append(add); + box.append(list); + }; + draw(); + return card; +} + async function viewSettings(page) { const st = settings(); page.append(h(`

${T('Settings')}

`)); @@ -931,9 +988,9 @@ async function viewSettings(page) { if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)'; }).catch(() => {}); - const dataCard = h(`

${T('Data')}

${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}

`); + const dataCard = h(`

${T('Data')}

${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}

`); const btns = [ - [T('Import ~/.ssh/config'), importSshConfig], + ...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]), [T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }], [T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }], ]; @@ -985,7 +1042,8 @@ async function viewSettings(page) { ${C}+Shift+F${T('Search in terminal')} ${C}++/−/0${T('Font size')} ${C}+Shift+L${T('Lock now')}`); - c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); + if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard); + else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); } // ============================================================ App-Sperre @@ -1643,7 +1701,7 @@ class FilePane { - + ${WEB ? `` : ``}
`); this.el.addEventListener('click', (e) => { const p = e.target.closest('[data-p]')?.dataset.p; @@ -1652,16 +1710,34 @@ class FilePane { if (p === 'refresh') this.go(this.cwd); if (p === 'mkdir') this.mkdir(); if (p === 'xfer') this.transferSelected(); + if (p === 'upload') this.pickUpload(); }); $('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); }); const files = $('.files', this.el); files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), icon: 'refresh', run: () => this.go(this.cwd) }]); } }); // Drag & Drop zwischen den Panes - this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } }); + // Web-Version: Dateien aus dem Betriebssystem in den Server-Bereich ziehen = hochladen + const osFiles = (e) => WEB && [...(e.dataTransfer?.types || [])].includes('Files'); + this.el.addEventListener('dragover', (e) => { if ((S.drag && S.drag.pane !== this) || osFiles(e)) { e.preventDefault(); this.el.classList.add('dragover'); } }); this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); }); - this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; }); + this.el.addEventListener('drop', (e) => { + e.preventDefault(); this.el.classList.remove('dragover'); + if (osFiles(e)) return this.uploadFiles([...e.dataTransfer.files]); + if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); + S.drag = null; + }); } get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; } + pickUpload() { + const inp = h(''); + inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); }; + document.body.append(inp); + inp.click(); + } + async uploadFiles(files) { + for (const f of files) await this.sftp.webTransfer('upload', f, `${this.cwd.replace(/\/$/, '')}/${f.name}`, f.name); + this.go(this.cwd); + } async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); } async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); } async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); } @@ -1756,6 +1832,14 @@ class FilePane { async transferSelected() { const names = [...this.sel]; if (!names.length) return toast(T('Nothing selected')); + if (WEB) { + for (const n of names) { + const f = this.files.find((x) => x.name === n); + if (f?.isDir) { toast(T('Folders cannot be downloaded in the browser: {name}', { name: n })); continue; } + this.sftp.webTransfer('download', null, await this.join(this.cwd, n), n); + } + return; + } const target = this.other; for (const n of names) { const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n); @@ -1771,17 +1855,18 @@ class SftpSession { this.kind = 'sftp'; this.id = uid(); this.host = host; this.title = `SFTP · ${host.label || host.address}`; this.el = h(`
`); - this.local = new FilePane(this, 'local'); + this.local = WEB ? null : new FilePane(this, 'local'); this.remote = new FilePane(this, 'remote'); const wrap = $('.sftp', this.el); - wrap.append(this.local.el, h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`)); + if (this.local) wrap.append(this.local.el); + wrap.append(h(`
${esc(T('Connecting to {host} …', { host: host.address }))}
`)); this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total)); this.xfers = new Map(); addTab(this); this.open(); } async open() { - this.local.init(await api.call('local:home')); + if (this.local) this.local.init(await api.call('local:home')); try { const { home } = await api.call('sftp:open', this.id, this.host.id || this.host); $('.sftp', this.el).lastElementChild.replaceWith(this.remote.el); @@ -1810,6 +1895,32 @@ class SftpSession { setTimeout(() => row.remove(), 10000); } } + // Web-Version: Upload per HTTP (mit Fortschritt), Download als Browser-Download + webTransfer(dir, file, remotePath, name) { + const q = `sid=${encodeURIComponent(this.id)}&path=${encodeURIComponent(remotePath)}`; + if (dir === 'download') { + const a = h(``); + document.body.append(a); a.click(); a.remove(); + return Promise.resolve(); + } + const tid = uid(); + const row = h(`
${ICONS.upload}${esc(name)}0%
`); + $('.transfers', this.el).prepend(row); + this.xfers.set(tid, row); + return new Promise((resolve) => { + const x = new XMLHttpRequest(); + x.open('POST', `/web/upload?${q}`); + x.setRequestHeader('X-MrTerm', '1'); + x.upload.onprogress = (e) => this.progress(tid, e.loaded, e.total); + x.onload = () => { + if (x.status < 300) { row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = T('done'); setTimeout(() => row.remove(), 5000); } + else { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); const m = (() => { try { return JSON.parse(x.responseText).error; } catch { return x.statusText; } })(); toast(`${name}: ${m}`, 'error'); setTimeout(() => row.remove(), 10000); } + resolve(); + }; + x.onerror = () => { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); resolve(); }; + x.send(file); + }); + } progress(tid, done, total) { const row = this.xfers.get(tid); if (!row) return; const p = total ? Math.round((done / total) * 100) : 0; @@ -2466,6 +2577,19 @@ document.addEventListener('keydown', (e) => { } }); +// Web-Version: Hostschlüssel bestätigen (in Electron zeigt der Hauptprozess dafür einen Systemdialog) +api.on('hostkey:request', async (req) => { + const changed = !!req.previous; + const r = await modal({ + title: changed ? T('Host key has changed!') : T('Unknown host'), + body: `

${esc(changed ? T('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: req.host }) : T('The authenticity of {target} cannot be verified.', { target: req.host }))}

+
${esc(req.fingerprint)}
+ ${changed ? `

${esc(T('Previously stored:'))}

${esc(req.previous)}
` : ''}`, + buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: changed ? T('Connect anyway & replace') : T('Trust & connect'), value: true, cls: changed ? 'danger' : 'primary' }], + noEnter: changed, + }); + api.replySecret(req.reqId, r === true); +}); api.on('secret:request', async (req) => { const r = await modal({ title: req.title || T('Authentication'), diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 0395ca1..ef5179b 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -406,3 +406,6 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi [data-theme='macos'] .btn.primary { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); border-color: rgba(255, 255, 255, .45); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 4px 14px rgba(0, 122, 255, .3); } [data-theme='macos'] .modal-bg { -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); } [data-theme='macos'] .nav.active { box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 2px 8px rgba(30, 40, 80, .08); } + +/* Web-Version (Browser): Desktop-Funktionen ausblenden */ +.web .win-ctrls, .web #lockBtn, .web .nav[data-view='vpns'], .web .nav[data-view='forwards'], .web #updateBadge { display: none !important; } diff --git a/src/web/public/login.css b/src/web/public/login.css new file mode 100644 index 0000000..5fca32f --- /dev/null +++ b/src/web/public/login.css @@ -0,0 +1,16 @@ +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +body { background: var(--bg); color: var(--text); font-family: var(--font); display: grid; place-items: center; min-height: 100vh; padding: 16px; } +.box { width: 100%; max-width: 380px; background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px 28px 22px; text-align: center; box-shadow: 0 20px 60px #0006; } +.logo { width: 72px; height: 72px; border-radius: 18px; } +h1 { margin: 10px 0 4px; font-size: 26px; } +h1 span { color: var(--green); } +p { color: var(--muted); margin: 0 0 22px; font-size: 14px; } +form { display: flex; flex-direction: column; gap: 14px; text-align: left; } +label span { display: block; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; } +input { width: 100%; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; font: inherit; outline: none; } +input:focus { border-color: var(--accent); } +button { margin-top: 6px; background: var(--accent); color: var(--on-accent); border: 0; border-radius: 10px; padding: 12px; font: 600 15px var(--font); cursor: pointer; } +button:disabled { opacity: .6; } +.err { color: var(--red); font-size: 13px; } +.ver { margin-top: 18px; color: var(--faint); font-size: 11px; } diff --git a/src/web/public/login.html b/src/web/public/login.html new file mode 100644 index 0000000..e982b8a --- /dev/null +++ b/src/web/public/login.html @@ -0,0 +1,28 @@ + + + + + + MrTerm + + + + + +
+ +

MrTerm

+

+
+ + + + + +
+
+
+ + + + diff --git a/src/web/public/login.js b/src/web/public/login.js new file mode 100644 index 0000000..7732cfe --- /dev/null +++ b/src/web/public/login.js @@ -0,0 +1,36 @@ +// Anmeldung bzw. Ersteinrichtung (Admin-Konto) der Web-Version +(async () => { + 'use strict'; + I18N.setLanguage('auto', navigator.language); + const T = I18N.t; + const $ = (s) => document.querySelector(s); + document.querySelectorAll('[data-t]').forEach((e) => { e.textContent = T(e.dataset.t); }); + + const st = await fetch('/api/state').then((r) => r.json()); + if (st.user) { location.href = '/app/src/renderer/index.html'; return; } + const setup = st.setup; + $('#ver').textContent = `MrTerm Web ${st.version}`; + $('#intro').textContent = setup ? T('Welcome! Create the administrator account for this MrTerm server.') : T('Sign in to your MrTerm server.'); + $('#submit').textContent = setup ? T('Create account') : T('Sign in'); + $('#repeatRow').hidden = !setup; + if (setup) $('[name=password]').autocomplete = 'new-password'; + + $('#form').onsubmit = async (e) => { + e.preventDefault(); + const f = Object.fromEntries(new FormData(e.target)); + const err = $('#err'); + err.hidden = true; + if (setup && f.password !== f.repeat) { err.textContent = T('The passwords do not match.'); err.hidden = false; return; } + $('#submit').disabled = true; + try { + const r = await fetch(setup ? '/api/setup' : '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MrTerm': '1' }, body: JSON.stringify({ name: f.name, password: f.password }) }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || r.statusText); + location.href = '/app/src/renderer/index.html'; + } catch (ex) { + err.textContent = T(ex.message); + err.hidden = false; + $('#submit').disabled = false; + } + }; +})(); diff --git a/src/web/public/web-api.js b/src/web/public/web-api.js new file mode 100644 index 0000000..1fd2c42 --- /dev/null +++ b/src/web/public/web-api.js @@ -0,0 +1,100 @@ +// window.api für die Web-Version: gleiche Schnittstelle wie src/preload.js (Electron), +// aber über einen WebSocket zum MrTerm-Web-Server. Dateidialoge, Zwischenablage und Links laufen im Browser. +(() => { + 'use strict'; + const listeners = new Map(); + const waits = new Map(); + let ws, seq = 0, queue = [], retry = 0, banner; + const emit = (ch, args) => listeners.get(ch)?.forEach((fn) => { try { fn(...args); } catch (e) { console.error(e); } }); + + function showBanner(text) { + if (!document.body) return; + if (!banner) { + banner = document.createElement('div'); + banner.style.cssText = 'position:fixed;left:50%;top:10px;transform:translateX(-50%);z-index:9999;background:var(--card,#222);color:var(--text,#eee);border:1px solid var(--border,#444);border-radius:10px;padding:8px 14px;font:13px system-ui;box-shadow:0 6px 24px #0006'; + document.body.append(banner); + } + banner.textContent = text; + banner.hidden = !text; + } + + function connect() { + ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`); + ws.onopen = () => { retry = 0; showBanner(''); for (const m of queue) ws.send(m); queue = []; }; + ws.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.t === 'reply') { + const w = waits.get(m.id); + if (!w) return; + waits.delete(m.id); + m.ok ? w.resolve(m.v) : w.reject(new Error(m.v)); + } else if (m.t === 'evt') emit(m.ch, m.args || []); + }; + ws.onclose = (e) => { + for (const w of waits.values()) w.reject(new Error('Connection to the MrTerm server lost.')); + waits.clear(); + if (e.code === 4001) { location.href = '/'; return; } + // Sitzung abgelaufen? Dann zur Anmeldung, sonst neu verbinden + fetch('/api/me', { headers: { 'X-MrTerm': '1' } }).then((r) => { + if (r.status === 401) { location.href = '/'; return; } + showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); + setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); + }).catch(() => { showBanner(window.I18N ? I18N.t('Connection lost – reconnecting …') : 'Connection lost – reconnecting …'); setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); }); + }; + } + const post = (m) => { const s = JSON.stringify(m); if (ws.readyState === 1) ws.send(s); else queue.push(s); }; + const remote = (ch, args) => new Promise((resolve, reject) => { const id = ++seq; waits.set(id, { resolve, reject }); post({ t: 'call', id, ch, args }); }); + + // Datei im Browser auswählen und als Text lesen + function pickFile(accept) { + return new Promise((resolve) => { + const inp = document.createElement('input'); + inp.type = 'file'; + if (accept) inp.accept = accept; + inp.style.display = 'none'; + inp.onchange = async () => { const f = inp.files[0]; inp.remove(); resolve(f ? { name: f.name, content: await f.text() } : null); }; + inp.addEventListener('cancel', () => { inp.remove(); resolve(null); }); + document.body.append(inp); + inp.click(); + }); + } + function downloadText(name, text) { + const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); + const a = Object.assign(document.createElement('a'), { href: url, download: name }); + document.body.append(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + // Kanäle, die im Browser selbst erledigt werden + const local = { + 'clipboard:write': (t) => navigator.clipboard.writeText(String(t)), + 'clipboard:read': () => navigator.clipboard.readText(), + 'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); }, + 'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; }, + 'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); }, + 'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; }, + 'import:pickFile': async () => pickFile(), + 'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); }, + }; + + window.api = { + platform: 'web', + call: (ch, ...args) => (local[ch] ? Promise.resolve().then(() => local[ch](...args)) : remote(ch, args)), + on(ch, fn) { + if (!listeners.has(ch)) listeners.set(ch, new Set()); + listeners.get(ch).add(fn); + return () => listeners.get(ch).delete(fn); + }, + win: { min() {}, max() {}, close() {} }, + ssh: { + write: (id, d) => post({ t: 'send', ch: 'ssh:write', args: [id, d] }), + resize: (id, c, r) => post({ t: 'send', ch: 'ssh:resize', args: [id, c, r] }), + close: (id) => post({ t: 'send', ch: 'ssh:close', args: [id] }), + }, + rdp: { bounds() {}, show() {}, hide() {}, focus() {}, close() {} }, + replySecret: (reqId, v) => post({ t: 'send', ch: 'secret:reply', args: [reqId, v] }), + pairReply: (reqId, ok) => post({ t: 'send', ch: 'sync:pairReply', args: [reqId, ok] }), + }; + document.documentElement.classList.add('web'); + connect(); +})(); diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..a65d844 --- /dev/null +++ b/src/web/server.js @@ -0,0 +1,376 @@ +// MrTerm Web: die Desktop-Oberfläche im Browser, selbst gehostet (Docker). +// - Benutzerkonten; jeder Tresor ist mit einem eigenen Datenschlüssel (DEK) verschlüsselt, +// den nur das Login-Passwort des Benutzers entpackt (scrypt → AES-256-GCM). +// - Oberfläche ↔ Server über WebSocket (/ws), gleiche Kanäle wie Electron/Android (src/core/backend.js). +// - SFTP-Up-/Downloads über HTTP (/web/upload, /web/download). +// +// Umgebung: PORT (8080), DATA_DIR (/data), TRUST_PROXY=1 (X-Forwarded-Proto/-For auswerten, z. B. hinter Traefik) +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { WebSocketServer } = require('ws'); +const { Store } = require('../main/store'); +const { createBackend } = require('../core/backend'); +const i18n = require('../i18n'); + +const ROOT = process.env.MRTERM_ROOT || path.resolve(__dirname, '..', '..'); +const DATA = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'web-data')); +const PORT = Number(process.env.PORT) || 8080; +const TRUST_PROXY = process.env.TRUST_PROXY === '1'; +const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { return '0.0.0'; } })(); +const SESSION_IDLE = 12 * 3600e3; +const SESSION_MAX = 7 * 24 * 3600e3; +const COOKIE = 'mrterm_sid'; + +process.on('uncaughtException', (e) => console.error('Uncaught exception:', e)); +process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); +fs.mkdirSync(path.join(DATA, 'users'), { recursive: true, mode: 0o700 }); + +// ============================================================ Krypto-Helfer +const scrypt = (pw, salt, N = 2 ** 15) => new Promise((resolve, reject) => + crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k)))); +function box(key, plain) { + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv('aes-256-gcm', key, iv); + const ct = Buffer.concat([c.update(plain), c.final()]); + return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') }; +} +function unbox(key, b) { + const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64')); + d.setAuthTag(Buffer.from(b.tag, 'base64')); + return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]); +} +// Verschlüsselung des Tresors mit dem DEK (Schnittstelle wie Electrons safeStorage) +const vaultCrypto = (dek) => ({ + isEncryptionAvailable: () => true, + encryptString(s) { + const iv = crypto.randomBytes(12); + const c = crypto.createCipheriv('aes-256-gcm', dek, iv); + const ct = Buffer.concat([c.update(String(s), 'utf8'), c.final()]); + return Buffer.concat([iv, c.getAuthTag(), ct]); + }, + decryptString(buf) { + const d = crypto.createDecipheriv('aes-256-gcm', dek, buf.subarray(0, 12)); + d.setAuthTag(buf.subarray(12, 28)); + return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8'); + }, +}); + +// ============================================================ Benutzer +const USERS_FILE = path.join(DATA, 'users.json'); +let users = (() => { try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')).users || []; } catch { return []; } })(); +function saveUsers() { + fs.writeFileSync(USERS_FILE + '.tmp', JSON.stringify({ users }, null, 2), { mode: 0o600 }); + fs.renameSync(USERS_FILE + '.tmp', USERS_FILE); +} +const validName = (n) => /^[A-Za-z0-9._@-]{1,64}$/.test(String(n || '')); +const validPw = (p) => typeof p === 'string' && p.length >= 8 && p.length <= 1024; +const publicUser = (u) => ({ id: u.id, name: u.name, admin: !!u.admin }); + +async function createUser(name, password, admin) { + if (!validName(name)) throw new Error('Usernames may contain letters, digits and . _ @ - (max. 64).'); + if (!validPw(password)) throw new Error('The password must be at least 8 characters long.'); + if (users.some((u) => u.name.toLowerCase() === name.toLowerCase())) throw new Error('This username is already taken.'); + const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16); + const dek = crypto.randomBytes(32); + const u = { + id: crypto.randomUUID(), name, admin: !!admin, createdAt: Date.now(), + auth: { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') }, + kek: { salt: kekSalt.toString('base64') }, + dek: box(await scrypt(password, kekSalt), dek), + }; + users.push(u); + saveUsers(); + return u; +} +// Prüft das Passwort und liefert den entpackten DEK (oder null) +async function verify(u, password) { + const hash = await scrypt(password, Buffer.from(u.auth.salt, 'base64')); + if (!crypto.timingSafeEqual(hash, Buffer.from(u.auth.hash, 'base64'))) return null; + try { return unbox(await scrypt(password, Buffer.from(u.kek.salt, 'base64')), u.dek); } catch { return null; } +} +async function setPassword(u, dek, password) { + if (!validPw(password)) throw new Error('The password must be at least 8 characters long.'); + const authSalt = crypto.randomBytes(16), kekSalt = crypto.randomBytes(16); + u.auth = { salt: authSalt.toString('base64'), hash: (await scrypt(password, authSalt)).toString('base64') }; + u.kek = { salt: kekSalt.toString('base64') }; + u.dek = box(await scrypt(password, kekSalt), dek); + saveUsers(); +} + +// ============================================================ Sitzungen +const sessions = new Map(); // token -> { userId, dek, created, last } +function newSession(u, dek) { + const token = crypto.randomBytes(32).toString('base64url'); + sessions.set(token, { userId: u.id, dek, created: Date.now(), last: Date.now() }); + return token; +} +function sessionOf(req) { + const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`)); + const s = m && sessions.get(m[1]); + if (!s) return null; + const now = Date.now(); + if (now - s.last > SESSION_IDLE || now - s.created > SESSION_MAX || !users.some((u) => u.id === s.userId)) { sessions.delete(m[1]); return null; } + s.last = now; + return { token: m[1], ...s, user: users.find((u) => u.id === s.userId) }; +} +setInterval(() => { for (const [t, s] of sessions) if (Date.now() - s.last > SESSION_IDLE || Date.now() - s.created > SESSION_MAX) sessions.delete(t); }, 600e3).unref(); + +const secure = (req) => req.socket.encrypted || (TRUST_PROXY && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https'); +const cookie = (req, token, maxAge) => `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure(req) ? '; Secure' : ''}`; +const clientIp = (req) => (TRUST_PROXY && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress || ''; + +// Anmeldeversuche begrenzen: 10 Fehlversuche je IP in 15 Minuten +const failures = new Map(); +const blocked = (ip) => { const f = failures.get(ip); return f && f.n >= 10 && Date.now() - f.first < 900e3; }; +const fail = (ip) => { const f = failures.get(ip); if (!f || Date.now() - f.first > 900e3) failures.set(ip, { n: 1, first: Date.now() }); else f.n++; }; + +// ============================================================ Laufzeit je Benutzer (Tresor + Backend) +const runtimes = new Map(); // userId -> { store, be, sockets, timer } +function runtimeFor(sess) { + let rt = runtimes.get(sess.userId); + if (rt) { clearTimeout(rt.timer); return rt; } + const dir = path.join(DATA, 'users', sess.userId); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const store = new Store({ dir, crypto: vaultCrypto(sess.dek) }); + store.load(); + const sockets = new Set(); + const send = (ch, ...args) => { const msg = JSON.stringify({ t: 'evt', ch, args }); for (const ws of sockets) if (ws.readyState === 1) ws.send(msg); }; + const be = createBackend({ store, send, platform: 'web', version: VERSION }); + // Desktop-Funktionen, die es im Browser nicht gibt: neutrale Antworten, damit die Oberfläche nicht stolpert + be.handle('vpn:status', () => ({})); + be.handle('fw:active', () => []); + be.handle('rdp:detect', () => ({})); + be.handle('rdp:embedSupported', () => ({ ok: false, reason: 'web' })); + be.handle('update:check', () => ({ available: false })); + be.handle('sync:status', () => ({ enabled: false, running: false, peers: [], nearby: [], share: { keys: [], hosts: [], vpns: [] } })); + rt = { store, be, sockets, timer: null }; + runtimes.set(sess.userId, rt); + return rt; +} +// Letzter Browser-Tab zu: SSH-Verbindungen nach kurzer Zeit schließen und den Tresor aus dem Speicher nehmen +function release(userId) { + const rt = runtimes.get(userId); + if (!rt || rt.sockets.size) return; + rt.timer = setTimeout(() => { if (!rt.sockets.size) { rt.be.close(); runtimes.delete(userId); } }, 60e3); +} +function dropUser(userId) { + for (const [t, s] of sessions) if (s.userId === userId) sessions.delete(t); + const rt = runtimes.get(userId); + if (rt) { for (const ws of rt.sockets) ws.close(4001, 'signed out'); rt.be.close(); runtimes.delete(userId); } +} + +// ============================================================ HTTP +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2' }; +const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"; +function headers(extra = {}) { + return { 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store', ...extra }; +} +const json = (res, code, obj, extra = {}) => { res.writeHead(code, headers({ 'Content-Type': 'application/json', ...extra })); res.end(JSON.stringify(obj)); }; +const redirect = (res, to) => { res.writeHead(302, headers({ Location: to })); res.end(); }; + +// Freigegebene statische Dateien: URL-Präfix → Verzeichnis +const STATIC = [ + ['/app/src/renderer/', path.join(ROOT, 'src', 'renderer')], + ['/app/src/i18n.js', path.join(ROOT, 'src', 'i18n.js')], + ['/app/node_modules/@xterm/', path.join(ROOT, 'node_modules', '@xterm')], + ['/web/', path.join(ROOT, 'src', 'web', 'public')], +]; +function staticFile(urlPath) { + for (const [prefix, dir] of STATIC) { + if (urlPath === prefix && !prefix.endsWith('/')) return dir; + if (!prefix.endsWith('/') || !urlPath.startsWith(prefix)) continue; + const file = path.resolve(dir, '.' + path.posix.normalize('/' + decodeURIComponent(urlPath.slice(prefix.length)))); + if (!file.startsWith(dir + path.sep)) return null; + if (dir.endsWith('@xterm') && !/[\\/](lib|css)[\\/][^\\/]+$/.test(file)) return null; + if (dir.endsWith('renderer') && /\.(rdm|md)$/.test(file)) return null; + return file; + } + return null; +} +function serveFile(res, file, transform) { + fs.stat(file, (err, st) => { + if (err || !st.isFile()) return json(res, 404, { error: 'Not found' }); + const type = MIME[path.extname(file)] || 'application/octet-stream'; + const cache = type.startsWith('text/html') ? 'no-store' : 'private, max-age=300'; + if (transform) { + const body = transform(fs.readFileSync(file, 'utf8')); + res.writeHead(200, headers({ 'Content-Type': type, 'Cache-Control': cache })); + return res.end(body); + } + res.writeHead(200, headers({ 'Content-Type': type, 'Content-Length': st.size, 'Cache-Control': cache })); + fs.createReadStream(file).pipe(res); + }); +} +function readJson(req, limit = 64 * 1024) { + return new Promise((resolve, reject) => { + let size = 0; const parts = []; + req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Request too large')); req.destroy(); } else parts.push(c); }); + req.on('end', () => { try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); } catch { reject(new Error('Invalid JSON')); } }); + req.on('error', reject); + }); +} + +const APP_URL = '/app/src/renderer/index.html'; + +async function api(req, res, url, sess) { + const p = url.pathname; + // Schreibende Anfragen nur mit eigenem Header (Browser senden ihn nicht seitenübergreifend ohne CORS-Freigabe) + if (req.method === 'POST' && req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' }); + if (p === '/api/state' && req.method === 'GET') return json(res, 200, { setup: !users.length, user: sess ? publicUser(sess.user) : null, version: VERSION }); + if (p === '/api/setup' && req.method === 'POST') { + if (users.length) return json(res, 409, { error: 'Setup is already complete.' }); + const b = await readJson(req); + const u = await createUser(b.name, b.password, true); + const dek = await verify(u, b.password); + return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) }); + } + if (p === '/api/login' && req.method === 'POST') { + const ip = clientIp(req); + if (blocked(ip)) return json(res, 429, { error: 'Too many failed attempts. Try again in 15 minutes.' }); + const b = await readJson(req); + const u = users.find((x) => x.name.toLowerCase() === String(b.name || '').toLowerCase()); + const dek = u ? await verify(u, String(b.password || '')) : (await scrypt('x', crypto.randomBytes(16)), null); + if (!dek) { fail(ip); return json(res, 401, { error: 'Wrong username or password.' }); } + failures.delete(ip); + return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, newSession(u, dek), SESSION_MAX / 1000) }); + } + if (!sess) return json(res, 401, { error: 'Not signed in' }); + if (p === '/api/me') return json(res, 200, publicUser(sess.user)); + if (p === '/api/logout' && req.method === 'POST') { + sessions.delete(sess.token); + return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(req, '', 0) }); + } + if (p === '/api/password' && req.method === 'POST') { + const b = await readJson(req); + if (!(await verify(sess.user, String(b.old || '')))) return json(res, 400, { error: 'The current password is wrong.' }); + await setPassword(sess.user, sess.dek, b.password); + for (const [t, s] of sessions) if (s.userId === sess.userId && t !== sess.token) sessions.delete(t); + return json(res, 200, { ok: true }); + } + if (p.startsWith('/api/users')) { + if (!sess.user.admin) return json(res, 403, { error: 'Administrators only.' }); + if (p === '/api/users' && req.method === 'GET') return json(res, 200, { users: users.map(publicUser) }); + if (p === '/api/users' && req.method === 'POST') { + const b = await readJson(req); + return json(res, 200, publicUser(await createUser(b.name, b.password, !!b.admin))); + } + if (p === '/api/users/delete' && req.method === 'POST') { + const b = await readJson(req); + const u = users.find((x) => x.id === b.id); + if (!u) return json(res, 404, { error: 'User not found.' }); + if (u.id === sess.userId) return json(res, 400, { error: 'You cannot delete yourself.' }); + if (u.admin && users.filter((x) => x.admin).length === 1) return json(res, 400, { error: 'The last administrator cannot be deleted.' }); + dropUser(u.id); + users = users.filter((x) => x.id !== u.id); + saveUsers(); + fs.rmSync(path.join(DATA, 'users', u.id), { recursive: true, force: true }); + return json(res, 200, { ok: true }); + } + } + return json(res, 404, { error: 'Not found' }); +} + +// SFTP-Dateien des angemeldeten Benutzers streamen +function sftpFor(sess, url) { + const rt = runtimes.get(sess.userId); + const sid = url.searchParams.get('sid') || ''; + const file = url.searchParams.get('path') || ''; + if (!rt || !file.startsWith('/')) throw new Error('Invalid request'); + return { sftp: rt.be.ssh.sftpOf(sid), file }; +} +function download(req, res, sess, url) { + let t; + try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); } + t.sftp.stat(t.file, (err, st) => { + if (err) return json(res, 404, { error: err.message }); + const name = path.posix.basename(t.file); + res.writeHead(200, headers({ + 'Content-Type': 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store', + 'Content-Disposition': `attachment; filename="${name.replace(/[^\x20-\x7e]|["\\]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(name)}`, + })); + const rs = t.sftp.createReadStream(t.file); + rs.on('error', () => res.destroy()); + rs.pipe(res); + }); +} +function upload(req, res, sess, url) { + let t; + try { t = sftpFor(sess, url); } catch (e) { return json(res, 400, { error: e.message }); } + const ws = t.sftp.createWriteStream(t.file); + let done = false; + const finish = (err) => { if (done) return; done = true; err ? json(res, 500, { error: err.message }) : json(res, 200, { ok: true }); }; + ws.on('error', finish); + ws.on('close', () => finish()); + req.on('error', finish); + req.pipe(ws); +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://x'); + const p = url.pathname; + const sess = sessionOf(req); + if (p === '/healthz') return json(res, 200, { ok: true }); + if (p.startsWith('/api/')) return await api(req, res, url, sess); + if (p === '/web/download' && req.method === 'GET') return sess ? download(req, res, sess, url) : json(res, 401, { error: 'Not signed in' }); + if (p === '/web/upload' && req.method === 'POST') { + if (!sess) return json(res, 401, { error: 'Not signed in' }); + if (req.headers['x-mrterm'] !== '1') return json(res, 403, { error: 'Forbidden' }); + return upload(req, res, sess, url); + } + if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed' }); + if (p === '/' || p === '/login') return sess && p === '/' ? redirect(res, APP_URL) : serveFile(res, path.join(ROOT, 'src', 'web', 'public', 'login.html')); + if (p === '/favicon.ico' || p === '/favicon.png') return serveFile(res, path.join(ROOT, 'src', 'renderer', 'logo.png')); + if (p === APP_URL) { + if (!sess) return redirect(res, '/'); + // Browser-Anbindung (window.api) vor den übrigen Skripten laden + return serveFile(res, staticFile(p), (html) => html.replace('\n