Compare commits

...
48 Commits
Author SHA1 Message Date
MrBlake 10acdaeb0b Version 0.13.2 2026-09-27 14:09:14 +02:00
MrBlakeandClaude Opus 5.5 daff240aa6 App lock: fix registering security keys with a FIDO2 PIN on Linux (Electron has no WebAuthn PIN prompt, so Chromium failed with NotAllowedError after touching the key); register via a built-in CTAP2 client over hidraw with PIN entry in the key window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:53:38 +02:00
MrBlakeandClaude Opus 5.5 66d4350221 README: add update instructions for the Docker web version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:06:14 +02:00
MrBlake 6c6e25df36 Version 0.13.1 2026-09-26 15:03:55 +02:00
MrBlakeandClaude Opus 5.5 32bf502ffa Web: fix SSH, SFTP and all other sessions failing over plain http:// (crypto.randomUUID only exists in secure contexts), add a clipboard fallback without HTTPS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:03:55 +02:00
MrBlake a2a0893b8c Version 0.13.0 2026-09-26 14:52:26 +02:00
MrBlakeandClaude Opus 5.5 45b0be8557 Add MrTerm Web: self-hosted browser version (Docker Compose) with user accounts, per-user vaults encrypted by the login password, the desktop UI over WebSocket, SFTP upload/download over HTTP; share backend channels between Android and web (src/core/backend.js)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:52:26 +02:00
MrBlake c143053319 Version 0.12.0 2026-09-26 14:31:51 +02:00
MrBlakeandClaude Opus 5.5 58709e09f2 Themes: share app theme colors between desktop and Android (app-themes.css), add theme picker and accent color to the Android app, and add Windows XP, Windows 11, macOS (Liquid Glass) and Cyberpunk 2077 themes with matching terminal schemes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:31:51 +02:00
MrBlake 27091ab5d1 Version 0.11.4 2026-09-26 14:15:09 +02:00
MrBlakeandClaude Opus 5.5 6a6971e1cb Android: stop adding keyboard padding on top of the system's own resize, which left an empty block above the keyboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:15:09 +02:00
MrBlake edea4ce0d2 Version 0.11.3 2026-09-26 00:12:21 +02:00
MrBlakeandClaude Opus 5.5 b431428260 Android: handle status bar, navigation bar and keyboard insets natively and consume them, so the keyboard no longer shrinks the view twice and content no longer sits under the status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:12:21 +02:00
MrBlake aeb6c590db Version 0.11.2 2026-09-26 00:07:24 +02:00
MrBlakeandClaude Opus 5.5 428e74b2d8 Android: load @capacitor/core in the web view (registerPlugin was missing, so the app stopped on the loading screen) and show script errors on the start screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:07:24 +02:00
MrBlake b94f18d2e2 Version 0.11.1 2026-09-26 00:03:26 +02:00
MrBlakeandClaude Opus 5.5 7cff93419d Android: fix app hanging on the loading screen (Node start only returns when Node exits, wait for whenReady instead), build the native bridge with 16 KB page alignment, show an error instead of an endless spinner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:03:26 +02:00
MrBlake 1980d3044b Version 0.11.0 2026-09-25 23:56:47 +02:00
MrBlakeandClaude Opus 5.5 4542aff75f Add Android app: phone UI for hosts, SSH terminal with extra key row, keys, snippets, LAN sync and app lock (password + fingerprint), reusing the desktop Node modules via capacitor-nodejs; APKs per ABI in releases for Obtainium
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:56:39 +02:00
MrBlake 430c18b84e Version 0.10.3 2026-09-25 23:32:05 +02:00
MrBlakeandClaude Opus 5.5 98c7763c47 README: add screenshots of hosts, terminal, host settings, Docker, firewall, network, VPN and settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:32:05 +02:00
MrBlake e4396db3c8 Version 0.10.2 2026-09-25 23:22:21 +02:00
MrBlakeandClaude Opus 5.5 98d8cfa993 Network: confirm a change only after it has been fully applied, keep the session when the old connection times out, and retry reloading so changes show up without reopening the tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:22:16 +02:00
MrBlake 1517764cca Version 0.10.1 2026-09-25 23:15:49 +02:00
MrBlakeandClaude Opus 5.5 eba3e38620 Sync: warn CachyOS/UFW/firewalld users to open the sync ports with copyable commands; ask before sharing newly created SSH keys, passwords and VPN configurations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:15:49 +02:00
MrBlake 6a63e0e80d Version 0.10.0 2026-09-25 23:05:39 +02:00
MrBlakeandClaude Opus 5.5 41717b2d07 Add encrypted LAN sync between MrTerm devices: UDP discovery, X25519 pairing with short authentication code, mutually authenticated AES-256-GCM sessions, last-writer-wins merge with deletions, and per-item opt-in for SSH keys, passwords and VPN configurations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:05:39 +02:00
MrBlake ae5309f693 Version 0.9.1 2026-09-25 22:51:33 +02:00
MrBlakeandClaude Opus 5.5 487ed069fc Fix main-process crash on SSH connection errors: handle repeated ssh2 error events, use the Windows OpenSSH agent only when it is running, show uncaught errors as a toast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:51:33 +02:00
MrBlake 7a9b70e645 Version 0.9.0 2026-09-25 22:40:58 +02:00
MrBlakeandClaude Opus 5.5 352a887a67 Add network management over SSH for Ubuntu (netplan) and Debian/Proxmox (ifupdown): interfaces, IPs, DHCP, gateway, DNS, bonds with LACP, bridges, VLANs, hostname and config files, applied with automatic rollback; UFW shows a hint when disabled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:40:58 +02:00
MrBlake 90f0907430 Version 0.8.0 2026-09-25 22:21:04 +02:00
MrBlakeandClaude Opus 5.5 128ca98030 Add firewall management over SSH for UFW and iptables/ip6tables: view and edit rules and default policies, persist iptables rules, SSH lockout protection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:21:04 +02:00
MrBlake a608948823 Version 0.7.0 2026-09-25 22:11:26 +02:00
MrBlakeandClaude Opus 5.5 70a15eddbc Add Docker/Podman container management over SSH: container list with status, ports, CPU and memory; shell and live logs in terminal tabs; start, stop, restart and remove
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:11:26 +02:00
MrBlake 039d89b9f7 Version 0.6.0 2026-09-25 19:05:33 +02:00
MrBlakeandClaude Opus 5.5 b38837d4e0 VPN: WireGuard-/OpenVPN-Konfigurationen verwalten, Hosts zuordnen, automatischer Aufbau vor dem Verbinden (Linux: NetworkManager, Windows: WireGuard-Dienst/OpenVPN GUI)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:05:33 +02:00
MrBlake b716ce3415 Version 0.5.0 2026-09-25 18:54:17 +02:00
MrBlakeandClaude Opus 5.5 b33af709aa App-Sperre mit Passwort und/oder FIDO2-Sicherheitsschlüssel (PRF/hmac-secret): Vault zusätzlich verschlüsselt, Sperrbildschirm, Strg+Shift+L, automatische Sperre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:54:17 +02:00
MrBlake 618f5ad010 Version 0.4.3 2026-09-25 18:43:39 +02:00
MrBlakeandClaude Opus 5.5 29fef7cb33 Updater (pacman): pkexec/sudo über systemd-run --user starten, da Electron NO_NEW_PRIVS setzt und Kindprozesse keine Root-Rechte erlangen können
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:43:38 +02:00
MrBlake 74ce2c20be Version 0.4.2 2026-09-25 18:36:51 +02:00
MrBlakeandClaude Opus 5.5 e8fefe4ebe Terminal: schwarzen Rand im Innenabstand entfernt (xterm-viewport transparent, Hintergrund in Terminalfarbe)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:36:51 +02:00
MrBlakeandClaude Opus 5.5 797722c510 Release-Skript: Push mit Gitea-Token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:32:00 +02:00
MrBlake c589033964 Version 0.4.1 2026-09-25 18:31:52 +02:00
MrBlakeandClaude Opus 5.5 0cefd5a21f Updater (pacman): pkexec mit absolutem Pfad; bei fehlendem Polkit-Agent/abgebrochener Abfrage (126/127) Installation per sudo im Terminal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:27:26 +02:00
MrBlakeandClaude Opus 5.5 d53bd1a4c1 Terminal: Innenabstand ans xterm-Element verschoben, damit FitAddon nicht eine Zeile/Spalte zu viel berechnet (Konsole unten/rechts abgeschnitten)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:22:32 +02:00
MrBlakeandClaude Opus 5.5 2890844c81 Mehrsprachigkeit (Englisch/Deutsch, Systemsprache oder Auswahl in den Einstellungen), README auf Englisch für Nutzer, Version 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:18:03 +02:00
108 changed files with 11070 additions and 452 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
mobile
build
docs
.git
.gitea-token
web-data
+7
View File
@@ -2,3 +2,10 @@ node_modules/
dist/ dist/
package.json package.json
.gitea-token .gitea-token
!mobile/package.json
# Android-App: Build-Ausgaben, Plugin-Tarball (lädt build.js), Signaturschlüssel
mobile/vendor/
mobile/www/
mobile/android/keystore.properties
mobile/android/*.jks
web-data/
+29
View File
@@ -0,0 +1,29 @@
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
# docker compose up -d → http://<server>: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"]
+250 -36
View File
@@ -1,51 +1,265 @@
# MrTerm # MrTerm
SSH-, SFTP- und RDP-Client im Stil von Termius – Electron + xterm.js + ssh2. Läuft auf Windows und Arch/CachyOS. A modern SSH, SFTP and RDP client for Windows and Arch Linux / CachyOS. All your servers live in one place and open in tabs: terminals, file transfers and remote desktops.
## Funktionen [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D)
- **Hosts**: Gruppen (verschachtelt), Tags, Farben, Suche, Quick Connect (`user@host:port`, `rdp://host`)
- **SSH-Terminal** in Tabs: Passwort, Schlüssel, SSH-Agent (Windows OpenSSH / `SSH_AUTH_SOCK`), Keyboard-Interactive/2FA, Jump-Hosts (ProxyJump-Ketten), Startbefehl, Env-Variablen, Suche, Zoom, 7 Farbschemata MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
- **SFTP**: Dual-Pane (lokal ↔ remote), Drag & Drop, Ordner rekursiv, Umbenennen, Löschen, chmod, Fortschrittsanzeige
- **RDP als Tab** direkt in MrTerm (Windows: mstsc eingebettet, Linux: xfreerdp3 via /parent-window unter X11/XWayland), alternativ im eigenen Fenster ![MrTerm host overview with groups](docs/screenshots/hosts.png)
- **RDP**: Windows → `mstsc` (Anmeldedaten temporär via `cmdkey`), Linux → FreeRDP (`xfreerdp3`/`sdl-freerdp3`/`wlfreerdp3`, Passwort über stdin)
- **Keychain**: Ed25519/ECDSA/RSA generieren, importieren, Public Key kopieren ## Download & installation
- **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D)
- **Snippets**, **Known Hosts** (TOFU, Warnung bei geändertem Schlüssel), **Verlauf**, `~/.ssh/config`-Import, Backup Export/Import Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
- **Import aus Devolutions Remote Desktop Manager** (Einstellungen → Import): Ordnerstruktur, RDP- und SSH-Einträge aus `.rdm`/XML, JSON oder CSV
- **7 App-Designs** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Hell) + frei wählbare Akzentfarbe; Terminal-Schema optional „passend zum Design“ **Windows**
- **Auto-Update** aus den Gitea-Releases von https://git.mrblake.cc/MrBlake/MrTerm (Setup-EXE, Portable, AppImage, pacman) - `MrTerm-Setup-<version>.exe`: installer (recommended)
- **Vault verschlüsselt** über das OS (Windows DPAPI, Linux libsecret/KWallet) - `MrTerm-<version>-portable.exe`: portable, runs without installation
- Befehlspalette `Strg+Shift+K`, Tab schließen `Strg+Shift+W` (Strg+W/K/T bleiben im Terminal für nano & Co.), `Strg+Tab`, `Strg+1..9`, `Strg+Shift+C/V/F`
**Arch Linux / CachyOS**
- `MrTerm-<version>.pacman`: install with
```sh
sudo pacman -U MrTerm-<version>.pacman
```
- `MrTerm-<version>.AppImage`: make it executable (`chmod +x`) and run it
Optional packages on Linux:
```sh
sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on KDE
```
`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys.
**Android**
- Install with [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D): tap the badge above on your phone, or add `https://git.mrblake.cc/MrBlake/MrTerm` in Obtainium and choose **Forgejo (Codeberg)** as the source. Obtainium then keeps MrTerm up to date.
- Or download `MrTerm-<version>.apk` from the releases page and install it directly.
## Android app
The Android app is made for phones and focuses on what you need on the go:
- **Hosts** with groups, search and Quick Connect (`user@host:port`)
- **SSH terminal** in full screen with an extra key row (Esc, Tab, Ctrl, Alt, arrow keys, `|`, `~` …), pinch-to-zoom and several open sessions
- **Keys**: generate or import SSH keys and copy the public key
- **Snippets** you can send to the terminal with one tap
- **LAN sync** with MrTerm on your computer: pair once, and your hosts, groups and snippets appear on the phone. You choose which passwords and keys are shared
- **App lock** with a password and fingerprint unlock. The vault is encrypted with a key kept in the Android Keystore
RDP, SFTP, port forwarding and VPN are only available in the desktop app.
## Features
- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`)
- **SSH terminal in tabs**: password, key, SSH agent, keyboard-interactive/2FA, jump hosts (ProxyJump chains), startup command, environment variables, search, zoom and several color schemes
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
- **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH
- **Firewall**: view and edit UFW and iptables/ip6tables rules on your servers
- **Network**: configure interfaces, IP addresses, DHCP, gateway, DNS, bonds (LACP), bridges, VLANs and the hostname on Ubuntu and Debian/Proxmox servers, with automatic rollback
- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D)
- **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host
- **Snippets**: save frequently used commands and send them to a terminal with one click
- **Known hosts**: MrTerm warns you if a server's host key changes
- **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)
- **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
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
## Screenshots
| | |
|---|---|
| ![SSH terminal](docs/screenshots/terminal.png) | ![Editing a host](docs/screenshots/edit-host.png) |
| **SSH terminal** with tabs and quick access to SFTP, Docker, Firewall and Network | **Host settings**: authentication, keys, jump hosts and VPN |
| ![Docker containers](docs/screenshots/docker.png) | ![UFW firewall rules](docs/screenshots/firewall.png) |
| **Docker & Podman** containers with CPU and memory usage | **Firewall** rules for UFW and iptables |
| ![Network configuration](docs/screenshots/network.png) | ![VPN configurations](docs/screenshots/vpn.png) |
| **Network**: interfaces, bonds (LACP), bridges, gateway and DNS | **VPN**: WireGuard and OpenVPN, connected automatically per host |
| ![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://<your-server>: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.
### Updating the web version
Run these commands in the folder that contains your `docker-compose.yml`:
```bash
docker compose build --pull --no-cache
docker compose up -d
```
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
To remove images left over from earlier builds: `docker image prune`.
## Getting started
1. Click **New host**, enter the address, username and password or key, and click **Save**.
2. Double-click the host to connect. SSH hosts open a terminal, RDP hosts open a remote desktop tab.
3. Right-click a host for more options, such as opening SFTP, duplicating it or copying its address.
For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `rdp://host`).
## Keyboard shortcuts
| Shortcut | Action |
|---|---|
| `Ctrl+Shift+K` / `Ctrl+Shift+T` | Quick Connect / command palette (outside the terminal also `Ctrl+K`) |
| `Ctrl+Shift+W` | Close tab |
| `Ctrl+Tab` | Next tab |
| `Ctrl+1..9` | Switch to tab |
| `Ctrl+Shift+C` / `Ctrl+Shift+V` | Copy / paste in the terminal |
| `Ctrl+Shift+F` | Search in the terminal |
| `Ctrl` + `+` / `-` / `0` | Font size |
| `Ctrl+Shift+L` | Lock MrTerm |
`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
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.
- **Open shell**: opens a terminal tab inside the container (bash if available, otherwise sh). You can also double-click a running container.
- **Logs**: follows the container's logs live in a terminal tab.
- **Start, stop, restart, delete** from the row buttons or the right-click menu.
Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically.
## Firewall
Right-click an SSH host and choose **Firewall**, or click **Firewall** in the toolbar of an open terminal. MrTerm supports **UFW** and **iptables/ip6tables**. If a server has both, you can switch between them at the top.
- **UFW**: turn the firewall on or off, change the default policies for incoming and outgoing traffic, and add or delete rules (allow, deny, reject, limit, with port, protocol, source and comment).
- **iptables**: all chains with their rules, the policy of INPUT, FORWARD and OUTPUT, and adding or deleting rules. iptables changes are lost on reboot unless you click **Save permanently** (uses `netfilter-persistent` on Debian/Ubuntu or `/etc/iptables/*.rules` on Arch).
- **Lockout protection**: if you enable UFW without a rule that allows SSH, MrTerm warns you and offers to allow SSH first. Switching a default policy to blocking asks for confirmation.
UFW rules can only be viewed and added while UFW is enabled.
This needs root privileges. Either log in as root, or save the password of a user with sudo rights on the host.
## Network
Right-click an SSH host and choose **Network**, or click **Network** in the terminal toolbar. MrTerm shows every interface with its state, MAC address, MTU and IP addresses. Bonds also show their mode, LACP rate and the status of each member. The default gateway, DNS servers and hostname appear at the top.
On **Ubuntu (netplan)** and **Debian/Proxmox (ifupdown)** you can also edit the configuration:
- **Per interface**: DHCP or static IPv4 addresses, gateway, DNS servers and search domains, IPv6 (SLAAC, DHCPv6, static or disabled) and MTU
- **Bonds** with any mode, including **802.3ad (LACP)** with LACP rate, hash policy and MII monitoring
- **Bridges** (e.g. Proxmox `vmbr`) and **VLANs**, which you can also create and delete
- **Hostname** and, if not managed by systemd-resolved, `/etc/resolv.conf`
- **Config files**: edit the netplan files, `/etc/network/interfaces` or `/etc/hosts` directly
**Automatic rollback:** before applying a change, MrTerm backs up the configuration and checks the new one. After applying it, MrTerm opens a new SSH connection to confirm the server is still reachable. If that doesn't work within 90 seconds, the server restores the previous configuration by itself, so a wrong IP address won't lock you out. If your change affects the address MrTerm connects to, enter the new address in the confirmation dialog.
On Ubuntu, MrTerm writes the complete netplan configuration to `/etc/netplan/90-mrterm.yaml` and renames the previous files to `*.yaml.mrterm-off`. On Debian/Proxmox, only the changed interfaces are rewritten, and all other lines (such as `post-up` or `bridge-fd`) are kept.
Root privileges are required, the same as for the firewall.
## VPN
Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host.
When you open an assigned host (terminal, SFTP, RDP or port forwarding), MrTerm connects the VPN first if it isn't already connected. You can also connect and disconnect manually. By default, MrTerm disconnects the VPNs it started when you close it.
- **Linux**: uses NetworkManager, so no root password is needed. For OpenVPN, install the plugin with `sudo pacman -S networkmanager-openvpn`.
- **Windows**: WireGuard requires [WireGuard for Windows](https://www.wireguard.com/install/) and asks for administrator permission when connecting. OpenVPN requires the [OpenVPN GUI](https://openvpn.net/community-downloads/).
- OpenVPN certificates and keys must be embedded in the `.ovpn` file.
## App lock
Under **Settings → App lock** you can protect MrTerm with a password, one or more FIDO2 security keys (e.g. YubiKey), or both. Once a method is set up:
- MrTerm starts locked, and your hosts, keys and passwords stay encrypted until you unlock it.
- Lock it any time with the lock icon in the title bar or `Ctrl+Shift+L`, or let it lock automatically after a period of inactivity.
- Open sessions keep running in the background while MrTerm is locked.
Security keys need to support the *hmac-secret* (PRF) extension, which YubiKey 5 and most current FIDO2 keys do. Touching the key is enough, no PIN is needed. On Linux, the key must be accessible to your user. This is the default on Arch/CachyOS.
**Keep in mind:** if you forget the password and lose all registered security keys, your vault cannot be recovered. Setting up a second unlock method is a good idea.
## Updates
MrTerm looks for updates on startup. You can also check manually under **Settings → Updates**. When a new version is available, click **Install now** and MrTerm will download the right package for your system and restart.
## Troubleshooting
- **"Vault not encrypted"** (Linux): install `gnome-keyring` or `kwallet` and restart MrTerm.
- **RDP doesn't work** (Linux): install FreeRDP with `sudo pacman -S freerdp`. Under **Settings → RDP** you can see which client MrTerm uses and choose another one.
- **RDP tab stays empty**: turn off *Show RDP connections as tabs* under **Settings → RDP** to use a separate window instead.
---
## For developers
## Entwicklung
```bash ```bash
npm install npm install
npm start npm start
``` ```
> In VS Code-Terminals ggf. vorher `ELECTRON_RUN_AS_NODE` entfernen. In VS Code terminals, unset `ELECTRON_RUN_AS_NODE` first.
## Pakete bauen **Building packages**
- Windows (auf Windows): `npm run dist:win` → Installer + Portable in `dist/` - Windows: `npm run dist:win` (on Linux, this needs `wine`)
- Arch/CachyOS (auf Linux): `npm run dist:linux` → AppImage + `.pacman`-Paket; installieren mit `sudo pacman -U dist/mrterm-*.pacman` - Arch/CachyOS: `npm run dist:linux`
Abhängigkeiten unter Arch/CachyOS: `sudo pacman -S freerdp gnome-keyring` (oder `kwallet` unter KDE) für RDP bzw. Vault-Verschlüsselung. If the build fails with `EACCES: permission denied`, some files in `node_modules` belong to root. Run `sudo chown -R $USER: node_modules` and don't run npm with `sudo`.
**Build bricht mit `EACCES: permission denied, unlink 'build/config.gypi'` ab** (z. B. bei `cpu-features`): Dateien in `node_modules` gehören root, meist nach einem `npm install` oder Build mit `sudo`. Besitz zurückholen und erneut bauen: **Android app** (`mobile/`): Capacitor with [capacitor-nodejs](https://github.com/hampoelz/Capacitor-NodeJS). The phone runs the same Node modules as the desktop app (`src/main/ssh.js`, `store.js`, `sync.js` …), bundled with esbuild; the phone UI lives in `mobile/web/`.
```bash
```sh cd mobile
sudo chown -R $USER: node_modules node build.js --setup # once: downloads the Node.js plugin (checksum-verified) and installs dependencies
npm run dist:linux node build.js --apk # builds signed APKs into dist/ (needs JDK 21 + Android SDK, see JAVA_HOME / ANDROID_HOME)
../node_modules/.bin/electron dev/electron-dev.js # try the phone UI on the desktop (after node build.js)
``` ```
APKs are signed with `mobile/android/keystore.properties` and the keystore it points to. Both are not committed. Keep a backup, because Android only installs updates signed with the same key.
npm und die Builds danach nicht mehr mit `sudo` ausführen – nur `pacman -U` zum Installieren braucht root. **Publishing a release**: `./scripts/release-all.sh` asks for the version and release notes, commits and pushes the version, builds Windows + Arch/CachyOS + Android and uploads everything to Gitea. It reads the token from `GITEA_TOKEN` or from `.gitea-token`, which is not committed.
## Release veröffentlichen (für Auto-Update) **Translations** live in [`src/i18n.js`](src/i18n.js). The English text in the code is the key. To add a language, add a dictionary and list it in `LANGUAGES`.
1. Version in `package.json` erhöhen
2. Auf jeder Plattform bauen (`npm run dist:win` bzw. `npm run dist:linux`)
3. `GITEA_TOKEN=<token> npm run release -- --notes "Was ist neu"` – legt das Release `v<version>` an und lädt die Pakete hoch
Oder alles in einem Schritt unter Linux (Windows-Build per `wine`): `./scripts/release-all.sh` – fragt nach Version und Notizen, committet/pusht die Version, baut Windows + Arch/CachyOS und lädt hoch. Token aus `GITEA_TOKEN` oder `.gitea-token` (nicht eingecheckt). To regenerate the icons after changing the logo, run `npx electron scripts/make-icons.js`.
Installierte Clients prüfen beim Start (und über Einstellungen → Updates) und installieren das passende Paket. Für ein privates Repo in den Einstellungen ein Lese-Token hinterlegen.
Icons neu erzeugen nach Logo-Änderung: `npx electron scripts/make-icons.js`
+22
View File
@@ -0,0 +1,22 @@
# MrTerm Web
# docker compose up -d
# Danach http://<server>: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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+82
View File
@@ -0,0 +1,82 @@
apply plugin: 'com.android.application'
android {
namespace = "de.mrterm.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "de.mrterm.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Version kommt aus der package.json des Desktop-Projekts (build.js setzt -PmrtermVersion)
def v = (project.findProperty('mrtermVersion') ?: '0.0.1').toString()
def p = v.tokenize('.-')
versionCode (p[0].toInteger() * 10000 + p[1].toInteger() * 100 + p[2].toInteger())
versionName v
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Signatur: keystore.properties (nicht im Repo) mit storeFile, storePassword, keyAlias, keyPassword
def ksFile = rootProject.file('keystore.properties')
signingConfigs {
release {
if (ksFile.exists()) {
def ks = new Properties()
ksFile.withInputStream { ks.load(it) }
storeFile rootProject.file(ks['storeFile'])
storePassword ks['storePassword']
keyAlias ks['keyAlias']
keyPassword ks['keyPassword']
}
}
}
// Eine APK je Prozessorarchitektur (libnode ist groß); Obtainium wählt die passende automatisch
splits {
abi {
enable true
reset()
include 'arm64-v8a', 'armeabi-v7a'
universalApk false
}
}
buildTypes {
release {
if (ksFile.exists()) signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation "androidx.biometric:biometric:1.1.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+19
View File
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-nodejs')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
</manifest>
@@ -0,0 +1,45 @@
package de.mrterm.app;
import android.os.Bundle;
import android.view.View;
import android.webkit.WebView;
import androidx.activity.OnBackPressedCallback;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(MrTermNative.class);
super.onCreate(savedInstanceState);
WebView web = getBridge().getWebView();
// Zurück-Taste an die Oberfläche geben (schließt Dialoge, verlässt das Terminal …)
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
web.evaluateJavascript("window.mrtermBack && window.mrtermBack()", null);
}
});
// Edge-to-Edge: Ränder für Status- und Navigationsleiste setzt diese Ansicht. Den Platz für die Tastatur
// schafft Android/WebView selbst – hier nur melden, ob sie offen ist (sonst würde doppelt verkleinert).
View parent = (View) web.getParent();
parent.setBackgroundColor(0xFF14161D);
final boolean[] imeOpen = { false };
ViewCompat.setOnApplyWindowInsetsListener(parent, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
boolean open = insets.isVisible(WindowInsetsCompat.Type.ime());
v.setPadding(bars.left, bars.top, bars.right, open ? 0 : bars.bottom);
if (open != imeOpen[0]) {
imeOpen[0] = open;
web.evaluateJavascript("window.mrtermIme && window.mrtermIme(" + open + ")", null);
}
return insets;
});
}
}
@@ -0,0 +1,261 @@
package de.mrterm.app;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.provider.Settings;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyPermanentlyInvalidatedException;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.util.Locale;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* Plattformfunktionen für MrTerm:
* - Tresorschlüssel: zufällige 32 Byte, verpackt mit einem nicht exportierbaren AES-Schlüssel im Android Keystore
* - Fingerabdruck: Geheimnis, das nur nach erfolgreicher biometrischer Bestätigung entschlüsselt werden kann
* - Multicast-Lock (sonst verwirft WLAN die UDP-Broadcasts der LAN-Synchronisation), Zwischenablage, Gerätename
*/
@CapacitorPlugin(name = "MrTermNative")
public class MrTermNative extends Plugin {
private static final String KS = "AndroidKeyStore";
private static final String VAULT_ALIAS = "mrterm_vault";
private static final String BIO_ALIAS = "mrterm_bio";
private WifiManager.MulticastLock multicastLock;
private SharedPreferences prefs() {
return getContext().getSharedPreferences("mrterm", Context.MODE_PRIVATE);
}
private static String b64(byte[] b) { return Base64.encodeToString(b, Base64.NO_WRAP); }
private static byte[] unb64(String s) { return Base64.decode(s, Base64.NO_WRAP); }
private SecretKey keystoreKey(String alias, boolean userAuth) throws Exception {
KeyStore ks = KeyStore.getInstance(KS);
ks.load(null);
if (ks.containsAlias(alias)) return (SecretKey) ks.getKey(alias, null);
KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KS);
KeyGenParameterSpec.Builder spec = new KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256);
if (userAuth) {
spec.setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true);
if (Build.VERSION.SDK_INT >= 30) spec.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG);
}
kg.init(spec.build());
return kg.generateKey();
}
private static void deleteKey(String alias) {
try { KeyStore ks = KeyStore.getInstance(KS); ks.load(null); ks.deleteEntry(alias); } catch (Exception ignored) {}
}
// ---------- Tresorschlüssel ----------
@PluginMethod
public void vaultKey(PluginCall call) {
try {
SecretKey k = keystoreKey(VAULT_ALIAS, false);
String stored = prefs().getString("vaultKey", null);
byte[] plain;
if (stored == null) {
plain = new byte[32];
new SecureRandom().nextBytes(plain);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, k);
prefs().edit().putString("vaultKey", b64(c.getIV()) + ":" + b64(c.doFinal(plain))).apply();
} else {
String[] p = stored.split(":");
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
plain = c.doFinal(unb64(p[1]));
}
JSObject r = new JSObject();
r.put("key", b64(plain));
call.resolve(r);
} catch (Exception e) {
call.reject("Keystore: " + e.getMessage());
}
}
// ---------- Fingerabdruck ----------
@PluginMethod
public void bioAvailable(PluginCall call) {
int s = BiometricManager.from(getContext()).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG);
JSObject r = new JSObject();
r.put("available", s == BiometricManager.BIOMETRIC_SUCCESS);
r.put("enrolled", prefs().getString("bio", null) != null);
call.resolve(r);
}
@PluginMethod
public void bioEnroll(PluginCall call) {
try {
deleteKey(BIO_ALIAS);
SecretKey k = keystoreKey(BIO_ALIAS, true);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.ENCRYPT_MODE, k);
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
byte[] secret = new byte[32];
new SecureRandom().nextBytes(secret);
byte[] ct = cipher.doFinal(secret);
prefs().edit().putString("bio", b64(cipher.getIV()) + ":" + b64(ct)).apply();
JSObject r = new JSObject();
r.put("secret", b64(secret));
call.resolve(r);
});
} catch (Exception e) {
call.reject(e.getMessage());
}
}
@PluginMethod
public void bioUnlock(PluginCall call) {
String stored = prefs().getString("bio", null);
if (stored == null) { call.reject("not-enrolled"); return; }
try {
String[] p = stored.split(":");
SecretKey k = keystoreKey(BIO_ALIAS, true);
Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
c.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, unb64(p[0])));
prompt(call, c, call.getString("title", "MrTerm"), call.getString("cancel", "Cancel"), (cipher) -> {
JSObject r = new JSObject();
r.put("secret", b64(cipher.doFinal(unb64(p[1]))));
call.resolve(r);
});
} catch (KeyPermanentlyInvalidatedException e) {
// Neuer Fingerabdruck registriert → Schlüssel ungültig, Nutzer muss neu einrichten
prefs().edit().remove("bio").apply();
deleteKey(BIO_ALIAS);
call.reject("invalidated");
} catch (Exception e) {
call.reject(e.getMessage());
}
}
@PluginMethod
public void bioDisable(PluginCall call) {
prefs().edit().remove("bio").apply();
deleteKey(BIO_ALIAS);
call.resolve();
}
private interface CipherUse { void run(Cipher c) throws Exception; }
private void prompt(PluginCall call, Cipher cipher, String title, String cancel, CipherUse use) {
FragmentActivity act = getActivity();
act.runOnUiThread(() -> {
BiometricPrompt bp = new BiometricPrompt(act, ContextCompat.getMainExecutor(act), new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
try { use.run(result.getCryptoObject().getCipher()); }
catch (Exception e) { call.reject(e.getMessage()); }
}
@Override
public void onAuthenticationError(int code, CharSequence msg) {
call.reject(code == BiometricPrompt.ERROR_USER_CANCELED || code == BiometricPrompt.ERROR_NEGATIVE_BUTTON ? "cancelled" : msg.toString());
}
});
BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setNegativeButtonText(cancel)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build();
bp.authenticate(info, new BiometricPrompt.CryptoObject(cipher));
});
}
// ---------- Sonstiges ----------
@PluginMethod
public void info(PluginCall call) {
String name = null;
try { name = Settings.Global.getString(getContext().getContentResolver(), "device_name"); } catch (Exception ignored) {}
if (name == null || name.isEmpty()) name = Build.MANUFACTURER + " " + Build.MODEL;
JSObject r = new JSObject();
r.put("device", name);
r.put("locale", Locale.getDefault().toLanguageTag());
try { r.put("version", getContext().getPackageManager().getPackageInfo(getContext().getPackageName(), 0).versionName); }
catch (Exception e) { r.put("version", "0.0.0"); }
call.resolve(r);
}
@PluginMethod
public void multicast(PluginCall call) {
boolean on = Boolean.TRUE.equals(call.getBoolean("on", true));
WifiManager wm = (WifiManager) getContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE);
if (on && multicastLock == null && wm != null) {
multicastLock = wm.createMulticastLock("mrterm-sync");
multicastLock.setReferenceCounted(false);
multicastLock.acquire();
} else if (!on && multicastLock != null) {
multicastLock.release();
multicastLock = null;
}
call.resolve();
}
// Design: Hintergrund hinter Status-/Navigationsleiste und dunkle bzw. helle Symbole
@PluginMethod
public void bars(PluginCall call) {
final boolean light = Boolean.TRUE.equals(call.getBoolean("light", false));
final String color = call.getString("color", "#14161d");
getActivity().runOnUiThread(() -> {
try {
android.view.View parent = (android.view.View) getBridge().getWebView().getParent();
parent.setBackgroundColor(android.graphics.Color.parseColor(color));
androidx.core.view.WindowInsetsControllerCompat c = androidx.core.view.WindowCompat.getInsetsController(getActivity().getWindow(), parent);
c.setAppearanceLightStatusBars(light);
c.setAppearanceLightNavigationBars(light);
} catch (Exception ignored) {}
});
call.resolve();
}
@PluginMethod
public void background(PluginCall call) {
getActivity().runOnUiThread(() -> getActivity().moveTaskToBack(true));
call.resolve();
}
@PluginMethod
public void copy(PluginCall call) {
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
cm.setPrimaryClip(ClipData.newPlainText("MrTerm", call.getString("text", "")));
call.resolve();
}
@PluginMethod
public void paste(PluginCall call) {
ClipboardManager cm = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
String text = "";
if (cm.hasPrimaryClip() && cm.getPrimaryClip().getItemCount() > 0) {
CharSequence t = cm.getPrimaryClip().getItemAt(0).coerceToText(getContext());
if (t != null) text = t.toString();
}
JSObject r = new JSObject();
r.put("text", text);
call.resolve(r);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#111728</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">MrTerm</string>
<string name="title_activity_main">MrTerm</string>
<string name="package_name">de.mrterm.app</string>
<string name="custom_url_scheme">de.mrterm.app</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+6
View File
@@ -0,0 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-nodejs'
project(':capacitor-nodejs').projectDir = new File('../node_modules/capacitor-nodejs/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+105
View File
@@ -0,0 +1,105 @@
// Baut die Android-App.
// node build.js --setup Node.js-Plugin herunterladen (Prüfsumme) und Abhängigkeiten installieren
// node build.js Weboberfläche + Node-Backend nach www/ bauen und ins Android-Projekt synchronisieren
// node build.js --apk zusätzlich signierte Release-APK nach ../dist/MrTerm-<version>.apk bauen
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const WWW = path.join(__dirname, 'www');
const version = require(path.join(ROOT, 'package.json')).version;
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', cwd: __dirname, ...opts });
const PLUGIN = {
url: 'https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.10/capacitor-nodejs.tgz',
file: path.join(__dirname, 'vendor', 'capacitor-nodejs-1.0.0-beta.10.tgz'),
sha256: 'cc47cba4190d5e0ea8e2b33f4e9bd47e159570c01571c51214467e4c21e87bd3',
};
async function setup() {
if (!fs.existsSync(PLUGIN.file)) {
console.log('Lade capacitor-nodejs …');
const res = await fetch(PLUGIN.url);
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
fs.mkdirSync(path.dirname(PLUGIN.file), { recursive: true });
fs.writeFileSync(PLUGIN.file, Buffer.from(await res.arrayBuffer()));
}
const sum = crypto.createHash('sha256').update(fs.readFileSync(PLUGIN.file)).digest('hex');
if (sum !== PLUGIN.sha256) { fs.rmSync(PLUGIN.file); throw new Error(`Prüfsumme von capacitor-nodejs stimmt nicht (${sum})`); }
run('npm', ['install']);
}
function copy(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.cpSync(from, to, { recursive: true });
}
// capacitor-nodejs baut seine Brückenbibliothek mit 4-KB-Seitenausrichtung; neuere Geräte (z. B. Pixel mit
// 16-KB-Speicherseiten) laden sie dann nicht. Linker-Option einmalig ergänzen.
function patchPlugin() {
const f = path.join(__dirname, 'node_modules', 'capacitor-nodejs', 'android', 'CMakeLists.txt');
const s = fs.readFileSync(f, 'utf8');
if (!s.includes('max-page-size=16384')) fs.writeFileSync(f, `${s}\ntarget_link_options(native-lib PRIVATE "-Wl,-z,max-page-size=16384")\n`);
}
function buildWeb() {
patchPlugin();
fs.rmSync(WWW, { recursive: true, force: true });
copy(path.join(__dirname, 'web'), WWW);
const nm = path.join(ROOT, 'node_modules', '@xterm');
copy(path.join(nm, 'xterm', 'lib', 'xterm.js'), path.join(WWW, 'lib', 'xterm.js'));
copy(path.join(nm, 'xterm', 'css', 'xterm.css'), path.join(WWW, 'lib', 'xterm.css'));
copy(path.join(nm, 'addon-fit', 'lib', 'addon-fit.js'), path.join(WWW, 'lib', 'addon-fit.js'));
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
copy(path.join(ROOT, 'src', 'renderer', 'app-themes.css'), path.join(WWW, 'lib', 'app-themes.css'));
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
require('esbuild').buildSync({
entryPoints: [path.join(__dirname, 'nodejs', 'server.js')],
outfile: path.join(WWW, 'nodejs', 'index.js'),
bundle: true, platform: 'node', target: 'node18', minify: true, legalComments: 'none',
external: ['bridge', 'cpu-features'],
alias: { electron: path.join(__dirname, 'nodejs', 'electron-shim.js') },
logLevel: 'warning',
});
fs.writeFileSync(path.join(WWW, 'nodejs', 'package.json'), JSON.stringify({ name: 'mrterm-backend', main: 'index.js' }));
run('npx', ['cap', 'sync', 'android']);
}
function toolchain() {
const base = path.join(os.homedir(), '.local', 'share', 'android-toolchain');
const env = { ...process.env };
env.JAVA_HOME ||= path.join(base, 'jdk');
env.ANDROID_HOME ||= path.join(base, 'sdk');
if (!fs.existsSync(env.JAVA_HOME) || !fs.existsSync(env.ANDROID_HOME)) throw new Error('JDK/Android-SDK nicht gefunden: JAVA_HOME und ANDROID_HOME setzen');
fs.writeFileSync(path.join(__dirname, 'android', 'local.properties'), `sdk.dir=${env.ANDROID_HOME}\n`);
return env;
}
function buildApk() {
if (!fs.existsSync(path.join(__dirname, 'android', 'keystore.properties'))) {
throw new Error('android/keystore.properties fehlt (Signaturschlüssel). Ohne ihn können Updates nicht installiert werden.');
}
run('./gradlew', ['--no-daemon', '-q', 'assembleRelease', `-PmrtermVersion=${version}`], { cwd: path.join(__dirname, 'android'), env: toolchain() });
const dir = path.join(__dirname, 'android', 'app', 'build', 'outputs', 'apk', 'release');
for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
const out = path.join(ROOT, 'dist', `MrTerm-${version}-${abi}.apk`);
copy(path.join(dir, `app-${abi}-release.apk`), out);
console.log(`APK: ${out}`);
}
}
(async () => {
const args = process.argv.slice(2);
if (args.includes('--setup')) await setup();
else {
buildWeb();
if (args.includes('--apk')) buildApk();
}
})().catch((e) => { console.error(e.message || e); process.exit(1); });
+9
View File
@@ -0,0 +1,9 @@
{
"appId": "de.mrterm.app",
"appName": "MrTerm",
"webDir": "www",
"android": { "backgroundColor": "#14161d" },
"plugins": {
"CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
}
}
+63
View File
@@ -0,0 +1,63 @@
// Entwicklung ohne Handy: startet die gebaute Oberfläche (www/) in einem Fenster in Handygröße
// und das Node-Backend im Electron-Hauptprozess, verbunden über eine nachgebaute Capacitor-Bridge.
// cd mobile && node build.js && ../node_modules/.bin/electron dev/electron-dev.js [--user-data-dir=…]
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
const path = require('path');
const Module = require('module');
const EventEmitter = require('events');
const crypto = require('crypto');
const WWW = path.join(__dirname, '..', 'www');
const toWeb = new EventEmitter();
const fromWeb = new EventEmitter();
let win;
// require('bridge') im Backend auf diese Nachbildung umleiten
const bridge = {
channel: { send: (name, ...args) => toWeb.emit('msg', name, args), addListener: (name, fn) => fromWeb.on(name, fn) },
getDataPath: () => path.join(app.getPath('userData'), 'mobile-data'),
};
const resolve = Module._resolveFilename;
Module._resolveFilename = function (req, ...rest) { return req === 'bridge' ? 'bridge' : resolve.call(this, req, ...rest); };
require.cache.bridge = { id: 'bridge', filename: 'bridge', loaded: true, exports: bridge };
toWeb.on('msg', (name, args) => { if (win && !win.isDestroyed()) win.webContents.send('dev:node', name, args); });
ipcMain.on('dev:send', (_e, name, args) => fromWeb.emit(name, ...args));
ipcMain.handle('dev:start', (_e, env) => {
Object.assign(process.env, env, { DATADIR: bridge.getDataPath() });
require('fs').mkdirSync(process.env.DATADIR, { recursive: true });
setTimeout(() => { started = true; require(path.join(WWW, 'nodejs', 'index.js')); }, 500);
});
let started;
ipcMain.handle('dev:ready', async () => { while (!started) await new Promise((r) => setTimeout(r, 100)); });
ipcMain.handle('dev:native', (_e, method, arg) => {
if (method === 'vaultKey') return { key: crypto.createHash('sha256').update(app.getPath('userData')).digest('base64') };
if (method === 'info') return { device: 'Dev Phone', locale: process.env.MRTERM_DEV_LOCALE || 'en-US', version: require('../../package.json').version };
if (method === 'bioAvailable') return { available: true, enrolled: false };
if (method === 'bioEnroll' || method === 'bioUnlock') return { secret: Buffer.alloc(32, 1).toString('base64') };
if (method === 'copy') return clipboard.writeText(arg.text);
if (method === 'paste') return { text: clipboard.readText() };
return {};
});
app.whenReady().then(() => {
win = new BrowserWindow({
width: 412, height: 870, backgroundColor: '#14161d',
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
});
win.loadFile(path.join(WWW, 'index.html'));
if (process.env.MRTERM_SHOT) {
const steps = JSON.parse(require('fs').readFileSync(process.env.MRTERM_SHOT, 'utf8'));
win.webContents.on('console-message', (e) => console.log('[r]', e.message));
win.webContents.once('did-finish-load', async () => {
await new Promise((r) => setTimeout(r, 2500));
for (const s of steps) {
if (s.js) await win.webContents.executeJavaScript(`(async()=>{${s.js}})();0`).catch((e) => console.log('js', e.message));
await new Promise((r) => setTimeout(r, s.wait || 900));
if (s.shot) require('fs').writeFileSync(path.join(process.env.MRTERM_SHOT_OUT, s.shot), (await win.webContents.capturePage()).toPNG());
}
app.exit(0);
});
}
});
app.on('window-all-closed', () => app.exit(0));
+16
View File
@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require('electron');
const listeners = new Map();
ipcRenderer.on('dev:node', (_e, name, args) => listeners.get(name)?.forEach((fn) => fn({ args })));
const native = (m) => (arg) => ipcRenderer.invoke('dev:native', m, arg);
contextBridge.exposeInMainWorld('DevBridge', {
NodeJS: {
// Wie auf Android: start() kehrt erst zurück, wenn Node beendet wird
start: (o) => { ipcRenderer.invoke('dev:start', o.env); return new Promise(() => {}); },
whenReady: () => ipcRenderer.invoke('dev:ready'),
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
},
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
});
+30
View File
@@ -0,0 +1,30 @@
// Ersatz für die von store.js genutzten Electron-APIs unter Android.
// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
const crypto = require('crypto');
const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
const key = () => {
const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
return k.length === 32 ? k : null;
};
const safeStorage = {
isEncryptionAvailable: () => !!key(),
encryptString(plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
},
decryptString(buf) {
const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
},
};
module.exports = {
app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
safeStorage,
};
+48
View File
@@ -0,0 +1,48 @@
// 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 { Store } = require('../../src/main/store');
const { createBackend } = require('../../src/core/backend');
const i18n = require('../../src/i18n');
const send = (ch, ...args) => channel.send('evt', ch, args);
process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); });
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
const store = new Store();
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 { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
});
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
// ---------- 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));
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.')); }
applyLanguage();
be.sync.start().catch(() => {});
return true;
});
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 be.lockStatus();
});
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
be.handle('app:pause', () => be.sync.stop());
be.handle('app:resume', () => be.sync.start().catch(() => {}));
store.load();
applyLanguage();
be.sync.start().catch(() => {});
channel.send('ready');
+1731
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mrterm-mobile",
"private": true,
"version": "0.0.0",
"description": "MrTerm for Android",
"scripts": {
"build": "node build.js",
"apk": "node build.js --apk"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"esbuild": "^0.28.2"
},
"allowScripts": {
"esbuild@0.28.2": true
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
"capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
}
}
+1078
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>MrTerm</title>
<link rel="stylesheet" href="lib/xterm.css" />
<link rel="stylesheet" href="lib/app-themes.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="boot"><img src="logo.png" alt="" /><div class="spinner"></div></div>
<!-- Hauptansicht: Inhalt + untere Navigation -->
<div id="main" hidden>
<main id="view"></main>
<nav id="tabs">
<button data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg><span data-t>Hosts</span></button>
<button data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span data-t>Keys</span></button>
<button data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span data-t>Snippets</span></button>
<button data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg><span data-t>Settings</span></button>
</nav>
</div>
<!-- Terminal im Vollbild -->
<div id="termScreen" hidden>
<header class="term-head">
<button class="icon" id="termBack" aria-label="Back"><svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg></button>
<button class="term-title" id="termTitle"><span class="dot"></span><span class="name"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
<button class="icon" id="termKbd" aria-label="Keyboard"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>
<button class="icon" id="termMenu" aria-label="Menu"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</header>
<div id="terms"></div>
<div id="keybar"></div>
</div>
<div id="lockScreen" hidden></div>
<div id="layer"></div>
<div id="toasts"></div>
<script>
// Fehler beim Laden sichtbar machen statt endlos den Ladekreis zu zeigen
window.addEventListener('error', (e) => {
const b = document.getElementById('boot');
if (b && !b.hidden) b.innerHTML = '<div style="color:#ff5f6d;padding:24px;text-align:center;font:14px system-ui">' + String(e.message || e.error).replace(/</g, '&lt;') + '</div>';
});
</script>
<script src="lib/capacitor.js"></script>
<script src="lib/i18n.js"></script>
<script src="lib/themes.js"></script>
<script src="lib/xterm.js"></script>
<script src="lib/addon-fit.js"></script>
<script src="app.js"></script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
:root {
/* Farben kommen aus lib/app-themes.css (gemeinsam mit dem Desktop, Auswahl über data-theme) */
--surface: var(--card);
--surface2: var(--card-hover);
--line: var(--border);
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--r: calc(var(--r) + 4px);
/* Status-/Navigationsleiste rechnet MainActivity als Rand um den WebView heraus */
--top: 0px;
--bottom: 0px;
color-scheme: dark;
}
:root.light { color-scheme: light; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
body { font: 15px/1.4 var(--font); font-family: var(--font), system-ui, Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
[hidden] { display: none !important; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.mono { font-family: ui-monospace, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; }
/* ---------- Start ---------- */
#boot { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 28px; }
#boot img { width: 84px; height: 84px; }
.spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--surface2); border-top-color: var(--accent); animation: spin .8s linear infinite; }
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Grundlayout ---------- */
#main { position: fixed; inset: 0; display: flex; flex-direction: column; }
#view { flex: 1; overflow-y: auto; padding: calc(var(--top) + 8px) 16px 96px; -webkit-overflow-scrolling: touch; }
#tabs { display: flex; background: var(--surface); border-top: 1px solid var(--line); padding-bottom: var(--bottom); }
#tabs button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 9px 0 8px; color: var(--faint); font-size: 11.5px; font-weight: 600; }
#tabs button.active { color: var(--accent); }
.top { display: flex; align-items: center; gap: 10px; min-height: 52px; }
.top h1 { font-size: 26px; margin: 0; flex: 1; letter-spacing: -.3px; }
.top h1 .brand { color: var(--green); }
.icon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--muted); }
.icon:active { background: var(--surface2); }
.search { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; margin: 6px 0 14px; }
.search svg { color: var(--faint); width: 18px; height: 18px; }
.search input { flex: 1; background: none; border: 0; outline: 0; padding: 12px 0; }
.section { margin: 18px 4px 8px; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
.section svg { width: 16px; height: 16px; }
.list { background: var(--surface); border-radius: var(--r); overflow: hidden; }
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
.item:active { background: var(--surface2); }
.item .meta { flex: 1; min-width: 0; }
.item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .more { color: var(--faint); width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; margin-right: -6px; }
.item.disabled { opacity: .55; }
.avatar { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; font-weight: 800; color: #fff; flex: none; font-size: 16px; }
.avatar svg { width: 20px; height: 20px; }
.avatar.soft { background: var(--accent-soft); color: var(--accent); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.dot.on { background: var(--green); }
.dot.err { background: var(--red); }
.sessions { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; }
.sessions::-webkit-scrollbar { display: none; }
.chip { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 8px 14px; white-space: nowrap; font-weight: 600; font-size: 14px; }
.empty { text-align: center; padding: 60px 24px; color: var(--muted); }
.empty .avatar { margin: 0 auto 16px; width: 60px; height: 60px; border-radius: 18px; }
.empty .avatar svg { width: 28px; height: 28px; }
.empty h3 { color: var(--text); margin: 0 0 6px; }
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: calc(var(--radius) + 8px); background: var(--accent); color: var(--on-accent); display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
.fab svg { width: 26px; height: 26px; }
/* ---------- Karten / Einstellungen ---------- */
.card { background: var(--surface); border-radius: var(--r); padding: 14px 16px; margin-bottom: 14px; }
.card h3 { margin: 0 0 4px; font-size: 16px; }
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--line); }
.row .grow { flex: 1; min-width: 0; }
.row .sub { color: var(--muted); font-size: 13px; }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); font-weight: 600; }
.btn svg { width: 18px; height: 18px; }
.btn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.btn.danger { color: var(--red); }
.btn.block { width: 100%; }
.btn:disabled { opacity: .5; }
.btn:active { filter: brightness(1.15); }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); margin: 0 2px 6px; }
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px 13px; outline: 0; min-height: 48px; }
.field textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field .hint { margin: 6px 2px 0; font-size: 12.5px; }
.pw { position: relative; }
.pw input { padding-right: 48px; }
.pw button { position: absolute; right: 4px; top: 4px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--faint); }
.two { display: grid; grid-template-columns: 1fr 96px; gap: 10px; }
.switch { position: relative; width: 50px; height: 30px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
.switch input:checked + span { background: var(--accent); }
.switch input:checked + span::after { transform: translateX(20px); background: var(--on-accent); }
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
/* ---------- Ebenen: Seiten, Sheets, Dialoge ---------- */
#layer > * { position: fixed; inset: 0; z-index: 20; }
.page { background: var(--bg); display: flex; flex-direction: column; animation: slidein .18s ease-out; }
@keyframes slidein { from { transform: translateX(30px); opacity: 0; } }
.page-head { display: flex; align-items: center; gap: 6px; padding: calc(var(--top) + 6px) 8px 6px; border-bottom: 1px solid var(--line); background: var(--surface); }
.page-head h2 { flex: 1; font-size: 18px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.page-head .btn { min-height: 38px; padding: 0 14px; }
.page-body { flex: 1; overflow-y: auto; padding: 16px 16px calc(24px + var(--bottom)); }
.scrim { background: #0009; display: flex; align-items: flex-end; animation: fade .15s; }
.scrim.center { align-items: center; justify-content: center; padding: 20px; }
@keyframes fade { from { opacity: 0; } }
.sheet { width: 100%; background: var(--surface); border-radius: 20px 20px 0 0; padding: 8px 8px calc(10px + var(--bottom)); max-height: 85vh; overflow-y: auto; animation: up .18s ease-out; }
@keyframes up { from { transform: translateY(40px); } }
.sheet .grab { width: 38px; height: 4px; background: var(--line); border-radius: 4px; margin: 4px auto 8px; }
.sheet .sheet-title { padding: 6px 12px 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.sheet button.opt { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 14px; border-radius: 12px; text-align: left; font-weight: 500; }
.sheet button.opt:active { background: var(--surface2); }
.sheet button.opt.danger { color: var(--red); }
.sheet button.opt svg { color: var(--muted); }
.sheet button.opt.danger svg { color: var(--red); }
.dialog { width: 100%; max-width: 420px; background: var(--surface); border-radius: 20px; padding: 20px; max-height: 88vh; overflow-y: auto; animation: pop .15s ease-out; }
@keyframes pop { from { transform: scale(.96); opacity: 0; } }
.dialog h3 { margin: 0 0 8px; font-size: 18px; }
.dialog p { color: var(--muted); margin: 0 0 14px; }
.dialog .btns { justify-content: flex-end; margin-top: 16px; }
.fp { font-size: 12.5px; background: var(--bg); border-radius: 10px; padding: 10px; word-break: break-all; color: var(--text); -webkit-user-select: text; user-select: text; }
.pair-code { font: 700 38px ui-monospace, monospace; letter-spacing: 6px; text-align: center; padding: 16px; background: var(--bg); border-radius: 14px; color: var(--accent); }
.checks label { display: flex; align-items: center; gap: 12px; padding: 10px 2px; }
.checks input { width: 20px; height: 20px; accent-color: var(--accent); }
.checks h4 { margin: 12px 0 2px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
#toasts { position: fixed; left: 16px; right: 16px; bottom: calc(84px + var(--bottom)); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 12px; padding: 12px 14px; box-shadow: 0 8px 24px #0008; animation: up .2s; font-size: 14px; }
.toast.ok { border-color: var(--green); }
.toast.error { border-color: var(--red); }
/* ---------- Sperrbildschirm ---------- */
#lockScreen { position: fixed; inset: 0; z-index: 40; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 14px; }
#lockScreen img { width: 72px; height: 72px; margin-bottom: 6px; }
#lockScreen h2 { margin: 0; }
#lockScreen p { margin: 0 0 10px; color: var(--muted); text-align: center; }
#lockScreen form { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 10px; }
#lockScreen .field { margin: 0; }
.bio-btn { width: 64px; height: 64px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 10px; }
.bio-btn svg { width: 32px; height: 32px; }
/* ---------- Terminal ---------- */
#termScreen { position: fixed; left: 0; right: 0; top: 0; height: 100%; display: flex; flex-direction: column; background: var(--term-bg, #13151c); z-index: 10; }
.term-head { display: flex; align-items: center; gap: 2px; padding: calc(var(--top) + 2px) 4px 2px; background: var(--surface); border-bottom: 1px solid var(--line); }
.term-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; font-weight: 700; }
.term-title .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.term-title svg { width: 16px; height: 16px; color: var(--faint); }
.term-title .count { background: var(--surface2); color: var(--muted); border-radius: 8px; padding: 0 7px; font-size: 12px; }
#terms { flex: 1; position: relative; overflow: hidden; }
.term-wrap { position: absolute; inset: 0; padding: 4px 2px 0 6px; }
.term-wrap .xterm { height: 100%; }
.term-wrap .xterm-viewport { background-color: transparent !important; }
.term-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--term-bg, #13151c); color: var(--muted); padding: 24px; text-align: center; z-index: 5; }
#keybar { display: flex; gap: 6px; overflow-x: auto; padding: 6px 6px calc(6px + var(--bottom)); background: var(--surface); border-top: 1px solid var(--line); scrollbar-width: none; }
#keybar::-webkit-scrollbar { display: none; }
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
#keybar button svg { width: 18px; height: 18px; }
#keybar button.on { background: var(--accent); color: var(--on-accent); }
#keybar button.lock { box-shadow: inset 0 0 0 2px var(--on-accent); }
body.kbd-open #keybar { padding-bottom: 6px; }
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
/* Design-Extras */
[data-theme='xp'] .page-head, [data-theme='xp'] .term-head { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; border-color: #0a3f9f; }
[data-theme='xp'] .page-head .icon, [data-theme='xp'] .term-head .icon, [data-theme='xp'] .term-title svg { color: #fff; }
[data-theme='xp'] #tabs { background: linear-gradient(#3168d5, #1d4fbf); border-color: #0a3f9f; }
[data-theme='xp'] #tabs button { color: #c7d8ff; }
[data-theme='xp'] #tabs button.active { color: #fff; }
[data-theme='xp'] .btn.primary, [data-theme='xp'] .fab { background: linear-gradient(#3d86e8, #2159b8); }
[data-theme='cyberpunk'] .btn.primary, [data-theme='cyberpunk'] .fab { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 12px) 100%, 0 100%); }
[data-theme='cyberpunk'] .btn.primary { text-transform: uppercase; letter-spacing: .6px; }
[data-theme='cyberpunk'] .top h1 { text-transform: uppercase; letter-spacing: 1px; text-shadow: 2px 0 #ff003c88, -2px 0 #00f0ff88; }
[data-theme='cyberpunk'] .list, [data-theme='cyberpunk'] .card { border-left: 2px solid var(--accent); }
[data-theme='cyberpunk'] #tabs button.active { color: var(--accent); text-shadow: 0 0 8px #fcee0a88; }
.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 14px; }
.theme-prev { border-radius: 12px; padding: 8px; border: 2px solid var(--line); font-size: 12px; font-weight: 600; text-align: left; }
.theme-prev.active { border-color: var(--accent); }
.theme-prev .dp { display: flex; gap: 4px; height: 40px; margin-bottom: 6px; }
.theme-prev .dp i { width: 12px; border-radius: 3px; }
.theme-prev .dp div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.theme-prev .dp b { flex: 1; border-radius: 3px; position: relative; }
.theme-prev .dp s { position: absolute; left: 4px; top: 4px; width: 14px; height: 6px; border-radius: 2px; }
.colors { display: flex; flex-wrap: wrap; gap: 10px; }
.colors button { width: 34px; height: 34px; border-radius: 50%; border: 3px solid transparent; }
.colors button.active { border-color: var(--text); }
/* macOS Liquid Glass */
[data-theme='macos'] #main, [data-theme='macos'] #view { background: transparent; }
[data-theme='macos'] :is(.list, .card, .search, #tabs, .page-head, .term-head, .sheet, .dialog, .toast, .chip, .btn, .fab, .field input, .field select, .field textarea, #keybar) {
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
}
[data-theme='macos'] :is(.list, .card, .search, .sheet, .dialog, .toast, .chip) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
[data-theme='macos'] .page, [data-theme='macos'] #lockScreen { background: var(--wallpaper); }
[data-theme='macos'] .page-body { background: transparent; }
[data-theme='macos'] #tabs, [data-theme='macos'] .page-head { background: rgba(255, 255, 255, .42); border-color: rgba(255, 255, 255, .7); }
[data-theme='macos'] .btn { border-radius: 999px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); }
[data-theme='macos'] .btn.primary, [data-theme='macos'] .fab { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 6px 18px rgba(0, 122, 255, .3); }
[data-theme='macos'] .fab { border-radius: 50%; }
[data-theme='macos'] .scrim { background: rgba(30, 40, 70, .18); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
[data-theme='macos'] .sheet, [data-theme='macos'] .dialog { background: rgba(255, 255, 255, .62); }
+511 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.2.0", "version": "0.13.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.2.0", "version": "0.13.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
@@ -18,7 +18,9 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "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": { "node_modules/@electron-internal/extract-zip": {
@@ -325,6 +327,448 @@
"node": ">=14.14" "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": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2071,6 +2515,48 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3992,6 +4478,28 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+6 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.3.0", "version": "0.13.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",
@@ -68,12 +68,15 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
"electron-builder": "^26.15.3" "electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"ws": "^8.21.3"
}, },
"allowScripts": { "allowScripts": {
"electron@44.4.5": true, "electron@44.4.5": true,
"electron-winstaller@5.4.0": true, "electron-winstaller@5.4.0": true,
"ssh2@1.17.0": true, "ssh2@1.17.0": true,
"koffi@3.3.1": true "koffi@3.3.1": true,
"esbuild@0.28.2": true
} }
} }
+11 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fragt nach der Version, baut Windows (Setup + Portable, per wine) und Arch/CachyOS (pacman + AppImage) # Fragt nach der Version, baut Windows (Setup + Portable, per wine), Arch/CachyOS (pacman + AppImage)
# und lädt alles als Release v<version> nach Gitea hoch. # und Android (APK, signiert mit mobile/android/keystore.properties) und lädt alles als Release v<version> nach Gitea hoch.
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt). # Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -24,10 +24,18 @@ if [[ "$version" != "$current" ]]; then
git add package.json package-lock.json git add package.json package-lock.json
git commit -m "Version ${version}" git commit -m "Version ${version}"
fi fi
git push origin HEAD # Push mit dem Gitea-Token (Basic-Auth, Benutzer aus GITEA_USER, Standard MrBlake)
auth="$(printf '%s:%s' "${GITEA_USER:-MrBlake}" "$GITEA_TOKEN" | base64 -w0)"
git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
rm -rf dist rm -rf dist
npm run dist:linux npm run dist:linux
npm run dist:win npm run dist:win
if [[ -f mobile/android/keystore.properties ]]; then
[[ -d mobile/node_modules/capacitor-nodejs ]] || (cd mobile && node build.js --setup)
(cd mobile && node build.js --apk)
else
echo "mobile/android/keystore.properties fehlt – Android-APK wird übersprungen"
fi
npm run release -- --notes "$notes" "${prerelease[@]}" npm run release -- --notes "$notes" "${prerelease[@]}"
+1 -1
View File
@@ -28,7 +28,7 @@ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.a
rel = await res.json(); rel = await res.json();
console.log('Release angelegt:', rel.html_url); console.log('Release angelegt:', rel.html_url);
} }
const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman)$/.test(f)); const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman|apk)$/.test(f));
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.'); if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
for (const f of files) { for (const f of files) {
const old = (rel.assets || []).find((a) => a.name === f); const old = (rel.assets || []).find((a) => a.name === f);
+223
View File
@@ -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 };
+741
View File
@@ -0,0 +1,741 @@
// Übersetzungen für Renderer (window.I18N) und Main-Prozess (require).
// Quellsprache ist Englisch: der Text im Code ist der Schlüssel, weitere Sprachen liefern ein Wörterbuch.
// Neue Sprache: Wörterbuch unten ergänzen und in LANGUAGES eintragen.
(function (root) {
'use strict';
const de = {
// Allgemein
'Cancel': 'Abbrechen',
'Save': 'Speichern',
'OK': 'OK',
'Delete': 'Löschen',
'Delete?': 'Löschen?',
'Edit': 'Bearbeiten',
'Open': 'Öffnen',
'Close': 'Schließen',
'Close (Ctrl+Shift+W)': 'Schließen (Strg+Shift+W)',
'Close others': 'Andere schließen',
'Rename': 'Umbenennen',
'Duplicate': 'Duplizieren',
'Copy': 'Kopieren',
'Copied': 'Kopiert',
'Connect': 'Verbinden',
'Reconnect': 'Neu verbinden',
'Reconnecting': 'Neu verbinden',
'Remove': 'Entfernen',
'Import': 'Importieren',
'Generate': 'Generieren',
'Automatic': 'Automatisch',
'Color': 'Farbe',
'Name': 'Name',
'Type': 'Typ',
'Title': 'Titel',
'Label': 'Bezeichnung',
'Settings': 'Einstellungen',
'Menu': 'Menü',
'History': 'Verlauf',
'Minimize': 'Minimieren',
'Maximize': 'Maximieren',
'Quick Connect (Ctrl+Shift+K)': 'Quick Connect (Strg+Shift+K)',
'Collapse menu': 'Menü einklappen',
'Expand menu': 'Menü ausklappen',
'Vault encrypted': 'Vault verschlüsselt',
'Vault not encrypted': 'Vault unverschlüsselt',
'Data is encrypted with the system keyring.': 'Daten werden mit dem System-Schlüsselbund verschlüsselt.',
'No system keyring available (e.g. install gnome-keyring/kwallet).': 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).',
'just now': 'gerade eben',
'{n} min ago': 'vor {n} Min.',
'{n} h ago': 'vor {n} Std.',
'{n} days ago': 'vor {n} Tagen',
// Verzeichnisbaum / Hosts
'Connections': 'Verbindungen',
'Collapse all': 'Alle einklappen',
'No hosts yet': 'Noch keine Hosts',
'Add host': 'Host hinzufügen',
'Create subfolder': 'Unterordner anlegen',
'Delete group?': 'Gruppe löschen?',
'“{name}” will be deleted. Hosts are kept.': '„{name}“ wird gelöscht. Hosts bleiben erhalten.',
'Search hosts or enter user@host …': 'Hosts suchen oder user@host eingeben …',
'Group': 'Gruppe',
'Groups': 'Gruppen',
'New host': 'Neuer Host',
'All hosts': 'Alle Hosts',
'Connect to {q}?': 'Mit {q} verbinden?',
'Quick Connect without saving the host.': 'Quick Connect ohne den Host zu speichern.',
'Add your first SSH or RDP host or import ~/.ssh/config.': 'Lege deinen ersten SSH- oder RDP-Host an oder importiere ~/.ssh/config.',
'Import ~/.ssh/config': '~/.ssh/config importieren',
'No results': 'Keine Treffer',
'Adjust the search or filters.': 'Passe die Suche oder Filter an.',
'Connect in tab': 'Im Tab verbinden',
'Open in separate window': 'In eigenem Fenster öffnen',
'Open SFTP': 'SFTP öffnen',
' (copy)': ' (Kopie)',
'Copy address': 'Adresse kopieren',
'Delete host?': 'Host löschen?',
'“{name}” will be permanently removed.': '„{name}“ wird dauerhaft entfernt.',
'RDP in a tab is not possible ({reason}) – opening a separate window.': 'RDP im Tab nicht möglich ({reason}) – öffne eigenes Fenster.',
'RDP session to {name} started ({client})': 'RDP-Sitzung zu {name} gestartet ({client})',
'Invalid address. Format: user@host:port': 'Ungültige Adresse. Format: user@host:port',
'{n} host(s) imported': '{n} Host(s) importiert',
// Host-Editor
'— No group —': '— Keine Gruppe —',
'— No key —': '— Kein Schlüssel —',
'— Direct —': '— Direkt —',
'SSH key': 'SSH-Schlüssel',
'Use SSH agent': 'SSH-Agent verwenden',
'Advanced': 'Erweitert',
'Jump host (ProxyJump)': 'Jump-Host (ProxyJump)',
'Startup command': 'Startbefehl',
'e.g. tmux attach || tmux': 'z. B. tmux attach || tmux',
'Environment variables': 'Umgebungsvariablen',
'One per line, KEY=VALUE (server must allow AcceptEnv).': 'Eine pro Zeile, KEY=VALUE (Server muss AcceptEnv erlauben).',
'Domain': 'Domäne',
'optional': 'optional',
'Width': 'Breite',
'Height': 'Höhe',
'On resize': 'Bei Größenänderung',
'Adjust resolution dynamically': 'Auflösung dynamisch anpassen',
'Scale image (fixed resolution)': 'Bild skalieren (feste Auflösung)',
'Fullscreen': 'Vollbild',
'Share clipboard': 'Zwischenablage teilen',
'Play audio': 'Audio wiedergeben',
'Share local drives/home': 'Lokale Laufwerke/Home freigeben',
'Edit host': 'Host bearbeiten',
'Address': 'Adresse',
'IP or hostname': 'IP oder Hostname',
'My server': 'Mein Server',
'Authentication': 'Anmeldung',
'Username': 'Benutzername',
'Password': 'Passwort',
'stored encrypted': 'wird verschlüsselt gespeichert',
'Appearance': 'Darstellung',
'Please enter an address.': 'Bitte eine Adresse angeben.',
'Edit group': 'Gruppe bearbeiten',
'New group': 'Neue Gruppe',
'Production': 'Produktion',
'Parent group': 'Übergeordnete Gruppe',
'Name is missing.': 'Name fehlt.',
// Keychain
'Search keys …': 'Schlüssel suchen …',
'No keys': 'Keine Schlüssel',
'Generate a new Ed25519 key or import an existing one.': 'Generiere einen neuen Ed25519-Schlüssel oder importiere einen bestehenden.',
'Generate key': 'Schlüssel generieren',
'Keys': 'Schlüssel',
'Copy public key': 'Public Key kopieren',
'Public key copied': 'Public Key kopiert',
'Delete key?': 'Schlüssel löschen?',
'“{name}” will be removed from the vault.': '„{name}“ wird aus dem Vault entfernt.',
'Generate SSH key': 'SSH-Schlüssel generieren',
'recommended': 'empfohlen',
'Key generated – public key copied to clipboard': 'Schlüssel erzeugt – Public Key in Zwischenablage kopiert',
'Edit key': 'Schlüssel bearbeiten',
'Import key': 'Schlüssel importieren',
'Derived from the private key automatically if left empty.': 'Wird bei Bedarf automatisch aus dem Private Key berechnet.',
'Private key is missing.': 'Private Key fehlt.',
'File…': 'Datei…',
// Snippets
'Search snippets …': 'Snippets suchen …',
'New snippet': 'Neues Snippet',
'No snippets': 'Keine Snippets',
'Save frequently used commands and send them to a terminal with one click.': 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.',
'Run in active terminal': 'Im aktiven Terminal ausführen',
'Delete snippet?': 'Snippet löschen?',
'Edit snippet': 'Snippet bearbeiten',
'System update': 'Systemupdate',
'Command(s)': 'Befehl(e)',
'Send Enter automatically': 'Enter automatisch senden',
'Command is missing.': 'Befehl fehlt.',
'No open terminal': 'Kein offenes Terminal',
'No snippets yet.': 'Noch keine Snippets.',
// Port Forwarding
'Search rules …': 'Regeln suchen …',
'New rule': 'Neue Regel',
'No port forwards': 'Keine Port-Weiterleitungen',
'Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).': 'Lokal (-L), Remote (-R) oder dynamisch als SOCKS5-Proxy (-D).',
'Local': 'Lokal',
'active': 'aktiv',
'inactive': 'inaktiv',
'Stop': 'Stoppen',
'Start': 'Starten',
'Forward active': 'Weiterleitung aktiv',
'Delete rule?': 'Regel löschen?',
'Add an SSH host first.': 'Lege zuerst einen SSH-Host an.',
'Edit rule': 'Regel bearbeiten',
'New forward': 'Neue Weiterleitung',
'Local (-L)': 'Lokal (-L)',
'Dynamic / SOCKS5 (-D)': 'Dynamisch / SOCKS5 (-D)',
'Via host': 'Über Host',
'Bind address': 'Bind-Adresse',
'Bind port': 'Bind-Port',
'Target host': 'Ziel-Host',
'Target port': 'Ziel-Port',
'Bind port is missing.': 'Bind-Port fehlt.',
'Target port is missing.': 'Ziel-Port fehlt.',
// Known Hosts & Verlauf
'Search known hosts …': 'Known Hosts suchen …',
'No known hosts': 'Keine bekannten Hosts',
'Host keys are stored here on first connection.': 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.',
'Search history …': 'Verlauf durchsuchen …',
'No connections yet': 'Noch keine Verbindungen',
'Recently used hosts appear here.': 'Zuletzt genutzte Hosts erscheinen hier.',
// Einstellungen
'Language': 'Sprache',
'System default': 'Systemsprache',
'Accent color': 'Akzentfarbe',
'Theme default': 'Standard des Designs',
'Light': 'Hell',
'Terminal color scheme': 'Terminal-Farbschema',
'Match theme': 'Passend zum Design',
'follows the theme': 'folgt dem Design',
'Font': 'Schriftart',
'Size': 'Größe',
'Bar': 'Balken',
'Underline': 'Unterstrich',
'Scrollback lines': 'Scrollback-Zeilen',
'Blinking cursor': 'Cursor blinkt',
'Copy on select': 'Auswahl automatisch kopieren',
'SSH keep-alive (seconds, 0 = off)': 'SSH Keep-Alive (Sekunden, 0 = aus)',
'Checking …': 'Prüfe …',
'Show RDP connections as tabs in MrTerm': 'RDP-Verbindungen als Tab in MrTerm anzeigen',
'Takes effect after restarting MrTerm (X11/XWayland mode).': 'Wird nach einem Neustart von MrTerm wirksam (X11/XWayland-Modus).',
'Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.': 'Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.',
'FreeRDP client (separate window)': 'FreeRDP-Client (eigenes Fenster)',
'Client in use: {client}': 'Verwendeter Client: {client}',
'No RDP client found. Install: {hint}': 'Kein RDP-Client gefunden. Installation: {hint}',
'Data': 'Daten',
'The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).': 'Der Vault ist mit dem Schlüsselbund des Betriebssystems verschlüsselt (Windows DPAPI / libsecret bzw. KWallet).',
'The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.': 'Der Vault ist nicht verschlüsselt, da kein Schlüsselbund verfügbar ist. Unter Arch/CachyOS: <code>gnome-keyring</code> oder <code>kwallet</code> installieren.',
'Export backup': 'Backup exportieren',
'Exported to {path}': 'Exportiert nach {path}',
'Import backup': 'Backup importieren',
'Import complete': 'Import abgeschlossen',
'Import folders and hosts (RDP &amp; SSH) from <b>Devolutions Remote Desktop Manager</b>. In RDM: <i>File → Export → Export entries</i> as <code>.rdm</code> (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.': 'Ordner und Hosts (RDP &amp; SSH) aus <b>Devolutions Remote Desktop Manager</b> übernehmen. In RDM: <i>Datei → Exportieren → Einträge exportieren</i> als <code>.rdm</code> (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.',
'Import from Remote Desktop Manager…': 'Aus Remote Desktop Manager importieren…',
'Installed version: {v}': 'Installierte Version: {v}',
'Gitea/Forgejo repository whose releases contain the installation packages.': 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.',
'Access token (private repos only)': 'Zugriffstoken (nur für private Repos)',
'Check for updates on startup': 'Beim Start automatisch nach Updates suchen',
'Include pre-releases': 'Vorabversionen (Pre-Releases) einbeziehen',
'Check for updates now': 'Jetzt nach Updates suchen',
'The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.': 'Im Repository liegt Version {repo}, aber dafür gibt es noch kein Release mit Installationspaketen. Installiert: {current}.',
'The repository has no releases yet.': 'Im Repository gibt es noch keine Releases.',
'MrTerm is up to date ({v}).': 'MrTerm ist aktuell ({v}).',
'Keyboard shortcuts': 'Tastenkürzel',
'Ctrl': 'Strg',
'Quick Connect / command palette (outside the terminal also {k})': 'Quick Connect / Befehlspalette (außerhalb des Terminals auch {k})',
'Close tab': 'Tab schließen',
'Next tab': 'Nächster Tab',
'Switch to tab': 'Zu Tab wechseln',
'Copy / paste in terminal': 'Kopieren / Einfügen im Terminal',
'Search in terminal': 'Im Terminal suchen',
'Font size': 'Schriftgröße',
// RDM-Import
'Import failed: {msg}': 'Import fehlgeschlagen: {msg}',
'No RDP or SSH entries found in the file.': 'Keine RDP- oder SSH-Einträge in der Datei gefunden.',
'Root level': 'Stammebene',
'Import from Remote Desktop Manager': 'Import aus Remote Desktop Manager',
'{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.': '{file}: {n} Hosts ({rdp} RDP, {ssh} SSH) in {folders} Ordnern gefunden.',
'… and {n} more': '… und {n} weitere',
'{n} hosts imported': '{n} Hosts importiert',
', {n} already existed': ', {n} bereits vorhanden',
'File is not valid XML.': 'Datei ist kein gültiges XML.',
'CSV columns not recognized (expected e.g. Name, Host, ConnectionType, Group).': 'CSV-Spalten nicht erkannt (erwartet u. a. Name, Host, ConnectionType, Group).',
// Updates
'MrTerm {v} is available': 'MrTerm {v} ist verfügbar',
'Installed: {v}': 'Installiert: {v}',
'Package: {name} ({size})': 'Paket: {name} ({size})',
'Development mode: please update via <code>git pull</code>.': 'Entwicklungsmodus: Bitte per <code>git pull</code> aktualisieren.',
'The release contains no package for this system.': 'Im Release ist kein passendes Paket für dieses System.',
'Downloading …': 'Lade herunter …',
'Later': 'Später',
'Release page': 'Release-Seite',
'Install now': 'Jetzt installieren',
'Downloading update …': 'Update wird heruntergeladen …',
'Installing update, MrTerm will restart …': 'Update wird installiert, MrTerm startet neu …',
// Tabs & Sitzungen
'Rename tab': 'Tab umbenennen',
'SFTP for this host': 'SFTP für diesen Host',
'Search (Ctrl+Shift+F)': 'Suchen (Strg+Shift+F)',
'Search …': 'Suchen …',
'Connecting to {host} …': 'Verbinde mit {host} …',
'Connection closed. Press [Enter] to reconnect.': 'Verbindung beendet. [Enter] zum erneuten Verbinden.',
'Continue session in a separate window': 'Sitzung als eigenes Fenster weiterführen',
'Detach as window': 'Als Fenster lösen',
'RDP session hidden while a dialog is open.': 'RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.',
'In separate window': 'Im eigenen Fenster',
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
'RDP session ended.': 'RDP-Sitzung beendet.',
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
// SFTP
'Choose a host for SFTP …': 'Host für SFTP wählen …',
'No SSH hosts': 'Keine SSH-Hosts',
'SFTP requires an SSH host.': 'SFTP benötigt einen SSH-Host.',
'Open host in SFTP browser': 'Host für SFTP-Browser öffnen',
'Parent folder': 'Übergeordneter Ordner',
'Refresh': 'Aktualisieren',
'New folder': 'Neuer Ordner',
'Upload →': 'Hochladen →',
'← Download': '← Herunterladen',
'Upload': 'Hochladen',
'Download': 'Herunterladen',
'Modified': 'Geändert',
'Copy path': 'Pfad kopieren',
'Permissions (chmod)…': 'Rechte (chmod)…',
'Move to trash': 'In Papierkorb',
'New name': 'Neuer Name',
'Change permissions': 'Rechte ändern',
'Octal (e.g. 644)': 'Oktal (z. B. 644)',
'Move {n} item(s) to the trash: {list}': '{n} Element(e) in den Papierkorb verschieben: {list}',
'Permanently delete {n} item(s) from the server: {list}': '{n} Element(e) endgültig vom Server löschen: {list}',
'Nothing selected': 'Nichts ausgewählt',
'Try again': 'Erneut versuchen',
'done': 'fertig',
'Error': 'Fehler',
// Befehlspalette & Anmeldung
'Search hosts or enter user@host:port or rdp://host …': 'Host suchen, user@host:port oder rdp://host eingeben …',
'Actions': 'Aktionen',
'Log in': 'Anmelden',
// App-Sperre
'App lock': 'App-Sperre',
'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.': 'MrTerm mit Passwort und/oder FIDO2-Sicherheitsschlüssel (z. B. YubiKey) sperren. Der Vault wird dann zusätzlich verschlüsselt und lässt sich nur mit einer dieser Methoden öffnen.',
'MrTerm is locked': 'MrTerm ist gesperrt',
'MrTerm is locked.': 'MrTerm ist gesperrt.',
'Unlock': 'Entsperren',
'Unlock with security key': 'Mit Sicherheitsschlüssel entsperren',
'Disable app lock?': 'App-Sperre deaktivieren?',
'This is the last unlock method. MrTerm will no longer be locked.': 'Das ist die letzte Entsperrmethode. MrTerm wird danach nicht mehr gesperrt.',
'Disable': 'Deaktivieren',
'Set': 'Festgelegt',
'Not set': 'Nicht festgelegt',
'Change password': 'Passwort ändern',
'Set password': 'Passwort festlegen',
'New password': 'Neues Passwort',
'Repeat password': 'Passwort wiederholen',
'The passwords do not match.': 'Die Passwörter stimmen nicht überein.',
'Password saved': 'Passwort gespeichert',
'Security key': 'Sicherheitsschlüssel',
'Security key (FIDO2)': 'Sicherheitsschlüssel (FIDO2)',
'Remove security key?': 'Sicherheitsschlüssel entfernen?',
'Add security key': 'Sicherheitsschlüssel hinzufügen',
'Security key added': 'Sicherheitsschlüssel hinzugefügt',
'Lock now': 'Jetzt sperren',
'Lock (Ctrl+Shift+L)': 'Sperren (Strg+Shift+L)',
'Lock automatically after inactivity': 'Automatisch sperren bei Inaktivität',
'Never': 'Nie',
'{n} minutes': '{n} Minuten',
'If you forget the password and lose all security keys, the vault cannot be recovered.': 'Wenn du das Passwort vergisst und alle Sicherheitsschlüssel verlierst, lässt sich der Vault nicht wiederherstellen.',
'No password set.': 'Kein Passwort festgelegt.',
'Wrong password.': 'Falsches Passwort.',
'No security key registered.': 'Kein Sicherheitsschlüssel registriert.',
'Unknown security key.': 'Unbekannter Sicherheitsschlüssel.',
'This security key could not unlock the vault.': 'Dieser Sicherheitsschlüssel konnte den Vault nicht entsperren.',
'The password must be at least 6 characters long.': 'Das Passwort muss mindestens 6 Zeichen lang sein.',
'Touch your security key to register it.': 'Berühre deinen Sicherheitsschlüssel, um ihn zu registrieren.',
'Touch your security key again to finish.': 'Berühre deinen Sicherheitsschlüssel noch einmal zum Abschließen.',
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
'Wrong PIN.': 'Falsche PIN.',
'{n} attempts left.': 'Noch {n} Versuche.',
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
// VPN
'— No VPN —': '— Kein VPN —',
'Connected automatically before connecting to this host.': 'Wird vor dem Verbinden mit diesem Host automatisch aufgebaut.',
'Search VPNs …': 'VPNs suchen …',
'New VPN': 'Neues VPN',
'No VPNs': 'Keine VPNs',
'Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.': 'Füge WireGuard- oder OpenVPN-Konfigurationen hinzu und ordne sie Hosts zu. MrTerm baut das VPN automatisch auf, wenn du einen solchen Host öffnest.',
'connected': 'verbunden',
'disconnected': 'getrennt',
'Disconnect': 'Trennen',
'Connecting …': 'Verbinde …',
'VPN “{name}” connected': 'VPN „{name}“ verbunden',
'Delete VPN?': 'VPN löschen?',
'“{name}” will be removed. Assigned hosts will connect without VPN.': '„{name}“ wird entfernt. Zugeordnete Hosts verbinden sich dann ohne VPN.',
'Configuration': 'Konfiguration',
'Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).': 'Zertifikate und Schlüssel müssen in der .ovpn-Datei eingebettet sein (<ca>, <cert>, <key> …).',
'Contents of a WireGuard .conf file.': 'Inhalt einer WireGuard-.conf-Datei.',
'Edit VPN': 'VPN bearbeiten',
'Office VPN': 'Büro-VPN',
'Disconnect when MrTerm closes': 'Beim Beenden von MrTerm trennen',
'Assigned hosts': 'Zugeordnete Hosts',
'Configuration is missing.': 'Konfiguration fehlt.',
'This does not look like a WireGuard configuration.': 'Das sieht nicht nach einer WireGuard-Konfiguration aus.',
'VPN configuration': 'VPN-Konfiguration',
'NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.': 'NetworkManager (nmcli) nicht gefunden. MrTerm nutzt NetworkManager für VPN-Verbindungen.',
'OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn': 'OpenVPN-Unterstützung für NetworkManager fehlt. Installieren mit: sudo pacman -S networkmanager-openvpn',
'VPN configuration could not be imported: {err}': 'VPN-Konfiguration konnte nicht importiert werden: {err}',
'VPN “{name}” could not be connected: {err}': 'VPN „{name}“ konnte nicht verbunden werden: {err}',
'VPN not found': 'VPN nicht gefunden',
'OpenVPN GUI not found. Install OpenVPN from openvpn.net.': 'OpenVPN GUI nicht gefunden. Installiere OpenVPN von openvpn.net.',
'WireGuard for Windows not found. Install it from wireguard.com.': 'WireGuard für Windows nicht gefunden. Installiere es von wireguard.com.',
'timeout': 'Zeitüberschreitung',
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
// Docker
'Docker containers': 'Docker-Container',
'Search containers …': 'Container suchen …',
'Show stopped': 'Gestoppte anzeigen',
'Connection closed.': 'Verbindung beendet.',
'No containers on this host.': 'Keine Container auf diesem Host.',
'Ports': 'Ports',
'Memory': 'Speicher',
'Open shell': 'Shell öffnen',
'Logs': 'Logs',
'Restart': 'Neu starten',
'Copy ID': 'ID kopieren',
'Delete container?': 'Container löschen?',
'“{name}” will be removed including its writable layer. Volumes are kept.': '„{name}“ wird samt beschreibbarer Ebene entfernt. Volumes bleiben erhalten.',
'Stop container?': 'Container stoppen?',
'Started': 'Gestartet',
'Stopped': 'Gestoppt',
'Restarted': 'Neu gestartet',
'Deleted': 'Gelöscht',
'Docker session not found': 'Docker-Sitzung nicht gefunden',
'Neither Docker nor Podman was found on this host.': 'Auf diesem Host wurde weder Docker noch Podman gefunden.',
'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.',
'{runtime} exited with code {code}': '{runtime} beendet mit Code {code}',
// Firewall
'Firewall': 'Firewall',
'Save the current iptables rules so they survive a reboot': 'Aktuelle iptables-Regeln speichern, damit sie einen Neustart überstehen',
'Save permanently': 'Dauerhaft speichern',
'Add rule': 'Regel hinzufügen',
'Rules saved permanently': 'Regeln dauerhaft gespeichert',
'Enable': 'Aktivieren',
'Disable firewall?': 'Firewall deaktivieren?',
'All UFW rules will stop filtering traffic.': 'Keine UFW-Regel filtert dann mehr den Datenverkehr.',
'Enable firewall?': 'Firewall aktivieren?',
'There is no rule that allows SSH (port {port}). Enabling UFW could lock you out of this server.': 'Es gibt keine Regel, die SSH (Port {port}) erlaubt. Wenn du UFW aktivierst, könntest du dich von diesem Server aussperren.',
'Enable anyway': 'Trotzdem aktivieren',
'Allow SSH and enable': 'SSH erlauben und aktivieren',
'Firewall enabled': 'Firewall aktiviert',
'Incoming': 'Eingehend',
'Outgoing': 'Ausgehend',
'Allow': 'Erlauben',
'Deny': 'Verweigern',
'Reject': 'Abweisen',
'Change default policy?': 'Standardrichtlinie ändern?',
'Incoming connections without a matching rule will be blocked. Make sure SSH is allowed.': 'Eingehende Verbindungen ohne passende Regel werden dann blockiert. Stelle sicher, dass SSH erlaubt ist.',
'Change': 'Ändern',
'No rules yet.': 'Noch keine Regeln.',
'To': 'Ziel',
'Action': 'Aktion',
'From': 'Quelle',
'Comment': 'Kommentar',
'Rule deleted': 'Regel gelöscht',
'Changes apply immediately but are lost after a reboot unless you click “Save permanently”.': 'Änderungen gelten sofort, gehen nach einem Neustart aber verloren, wenn du nicht auf „Dauerhaft speichern“ klickst.',
'rules': 'Regeln',
'Policy': 'Richtlinie',
'Traffic without a matching ACCEPT rule will be dropped. Make sure SSH is allowed, or you may lock yourself out.': 'Datenverkehr ohne passende ACCEPT-Regel wird dann verworfen. Stelle sicher, dass SSH erlaubt ist, sonst sperrst du dich eventuell aus.',
'Add UFW rule': 'UFW-Regel hinzufügen',
'Limit (rate-limit)': 'Begrenzen (Rate-Limit)',
'Direction': 'Richtung',
'Port': 'Port',
'Protocol': 'Protokoll',
'Any': 'Beliebig',
'From (IP or network)': 'Quelle (IP oder Netz)',
'Insert at the top (highest priority)': 'Ganz oben einfügen (höchste Priorität)',
'Add': 'Hinzufügen',
'Rule added': 'Regel hinzugefügt',
'Add {bin} rule': '{bin}-Regel hinzufügen',
'Chain': 'Kette',
'Target': 'Ziel',
'Source (IP or network)': 'Quelle (IP oder Netz)',
'Interface': 'Schnittstelle',
'Connection state': 'Verbindungsstatus',
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
'A port needs the protocol TCP or UDP.': 'Für einen Port ist das Protokoll TCP oder UDP nötig.',
'No known way to save iptables rules on this host (e.g. install iptables-persistent).': 'Keine bekannte Möglichkeit, iptables-Regeln auf diesem Host zu speichern (z. B. iptables-persistent installieren).',
'UFW is disabled': 'UFW ist deaktiviert',
'Enable UFW to view or add rules.': 'Aktiviere UFW, um Regeln einzusehen oder anzulegen.',
// Netzwerk
'Network': 'Netzwerk',
'Show virtual interfaces': 'Virtuelle Schnittstellen anzeigen',
'Config files': 'Konfigurationsdateien',
'New interface': 'Neue Schnittstelle',
'New bond': 'Neuer Bond',
'New bridge': 'Neue Bridge',
'New VLAN': 'Neues VLAN',
'The last network change was rolled back automatically ({time}) because MrTerm could not reconnect.': 'Die letzte Netzwerkänderung wurde automatisch zurückgenommen ({time}), weil MrTerm sich nicht erneut verbinden konnte.',
'Editing is supported for Ubuntu (netplan) and Debian/Proxmox (ifupdown). This host uses {backend}, so interfaces are shown read-only. Config files can still be edited.': 'Bearbeiten wird für Ubuntu (netplan) und Debian/Proxmox (ifupdown) unterstützt. Dieser Host nutzt {backend}, daher werden die Schnittstellen nur angezeigt. Konfigurationsdateien lassen sich trotzdem bearbeiten.',
'none': 'keins',
'Hostname': 'Hostname',
'Default gateway': 'Standard-Gateway',
'DNS servers': 'DNS-Server',
'Search': 'Suche',
'systemd-resolved is used: set DNS servers per interface.': 'systemd-resolved ist aktiv: DNS-Server pro Schnittstelle festlegen.',
'Not configured': 'Nicht konfiguriert',
'Static': 'Statisch',
'No IPv4': 'Kein IPv4',
'Mode': 'Modus',
'Configured, but not present': 'Konfiguriert, aber nicht vorhanden',
'No address': 'Keine Adresse',
'Configured': 'Konfiguriert',
'MrTerm reconnects to confirm the change. Without confirmation the server restores the previous configuration after 90 seconds.': 'MrTerm verbindet sich neu, um die Änderung zu bestätigen. Ohne Bestätigung stellt der Server nach 90 Sekunden die vorherige Konfiguration wieder her.',
'Saved': 'Gespeichert',
'Network change applied and confirmed': 'Netzwerkänderung angewendet und bestätigt',
'MrTerm could not reconnect after the change. The server restores the previous configuration automatically within 90 seconds.': 'MrTerm konnte sich nach der Änderung nicht erneut verbinden. Der Server stellt die vorherige Konfiguration innerhalb von 90 Sekunden automatisch wieder her.',
'Apply network change?': 'Netzwerkänderung anwenden?',
'The change is applied immediately. If MrTerm cannot reconnect within 90 seconds, the server restores the previous configuration automatically.': 'Die Änderung wird sofort angewendet. Kann MrTerm sich nicht innerhalb von 90 Sekunden erneut verbinden, stellt der Server die vorherige Konfiguration automatisch wieder her.',
'Reconnect via': 'Neu verbinden über',
'Change this if the change affects the address MrTerm uses to connect.': 'Ändern, wenn die Änderung die Adresse betrifft, über die MrTerm sich verbindet.',
'Apply': 'Anwenden',
'No suitable interfaces found.': 'Keine passenden Schnittstellen gefunden.',
'Usually <parent>.<VLAN ID>, e.g. eno1.100': 'Üblich: <Eltern>.<VLAN-ID>, z. B. eno1.100',
'Bond': 'Bond',
'Members': 'Mitglieder',
'LACP rate': 'LACP-Rate',
'Hash policy': 'Hash-Richtlinie',
'MII monitoring (ms)': 'MII-Überwachung (ms)',
'Bridge': 'Bridge',
'Spanning Tree (STP)': 'Spanning Tree (STP)',
'VLAN ID': 'VLAN-ID',
'Parent interface': 'Übergeordnete Schnittstelle',
'IPv4 addresses (one per line, CIDR)': 'IPv4-Adressen (eine pro Zeile, CIDR)',
'Gateway': 'Gateway',
'IPv6 addresses (one per line, CIDR)': 'IPv6-Adressen (eine pro Zeile, CIDR)',
'IPv6 gateway': 'IPv6-Gateway',
'Method': 'Methode',
'No IPv4 address': 'Keine IPv4-Adresse',
'Search domains': 'Suchdomänen',
'Automatic (SLAAC)': 'Automatisch (SLAAC)',
'Disabled': 'Deaktiviert',
'New {kind}': 'Neue(r) {kind}',
'Select at least one member.': 'Wähle mindestens ein Mitglied aus.',
'Select the parent interface.': 'Wähle die übergeordnete Schnittstelle aus.',
'An interface with this name already exists.': 'Eine Schnittstelle mit diesem Namen gibt es bereits.',
'Applying network configuration …': 'Netzwerkkonfiguration wird angewendet …',
'Delete {kind} {name}?': '{kind} {name} löschen?',
'The interface is removed from the configuration. Members keep their current settings.': 'Die Schnittstelle wird aus der Konfiguration entfernt. Mitglieder behalten ihre aktuellen Einstellungen.',
'Change hostname': 'Hostname ändern',
'Hostname changed': 'Hostname geändert',
'Written to /etc/resolv.conf.': 'Wird in /etc/resolv.conf geschrieben.',
'Content': 'Inhalt',
'Saving applies the file with automatic rollback.': 'Beim Speichern wird die Datei mit automatischem Rollback angewendet.',
'Saving …': 'Speichere …',
'Network session not found': 'Netzwerk-Sitzung nicht gefunden',
'netplan configuration could not be read ({err}).': 'netplan-Konfiguration konnte nicht gelesen werden ({err}).',
'Editing is not supported for this network configuration ({backend}).': 'Bearbeiten wird für diese Netzwerkkonfiguration ({backend}) nicht unterstützt.',
'The new configuration is invalid and was not applied: {err}': 'Die neue Konfiguration ist ungültig und wurde nicht angewendet: {err}',
'A static configuration needs at least one IPv4 address (e.g. 192.168.1.10/24).': 'Eine statische Konfiguration braucht mindestens eine IPv4-Adresse (z. B. 192.168.1.10/24).',
'A static IPv6 configuration needs at least one IPv6 address.': 'Eine statische IPv6-Konfiguration braucht mindestens eine IPv6-Adresse.',
'Authentication failed for {user}. Check the username, password or key.': 'Anmeldung für {user} fehlgeschlagen. Prüfe Benutzername, Passwort oder Schlüssel.',
// Synchronisation
'Synchronization': 'Synchronisation',
'Synchronize hosts, groups, snippets, port forwards, VPNs and known hosts directly between MrTerm devices in your local network. The connection is end-to-end encrypted; no server or cloud is involved.': 'Hosts, Gruppen, Snippets, Port-Weiterleitungen, VPNs und Known Hosts direkt zwischen MrTerm-Geräten im lokalen Netz abgleichen. Die Verbindung ist Ende-zu-Ende verschlüsselt, ohne Server oder Cloud.',
'Enable LAN synchronization': 'LAN-Synchronisation aktivieren',
'Device name': 'Gerätename',
'No paired devices yet.': 'Noch keine gekoppelten Geräte.',
'online': 'online',
'offline': 'offline',
'last sync {time}': 'zuletzt {time}',
'Unpair': 'Entkoppeln',
'Unpair device?': 'Gerät entkoppeln?',
'“{name}” will no longer synchronize with this device.': '„{name}“ synchronisiert dann nicht mehr mit diesem Gerät.',
'Shared secrets: {keys} SSH keys, {pw} host passwords, {vpn} VPN configurations': 'Geteilte Geheimnisse: {keys} SSH-Schlüssel, {pw} Host-Passwörter, {vpn} VPN-Konfigurationen',
'Pair new device': 'Neues Gerät koppeln',
'Sync now': 'Jetzt synchronisieren',
'Synchronized with {n} device(s)': 'Mit {n} Gerät(en) synchronisiert',
'Choose shared secrets': 'Geteilte Geheimnisse auswählen',
'Devices find each other via UDP port 47811 and synchronize via TCP port 47812. If a firewall is active, allow these ports in your local network (with UFW: sudo ufw allow 47811/udp and sudo ufw allow 47812/tcp).': 'Geräte finden sich über UDP-Port 47811 und synchronisieren über TCP-Port 47812. Ist eine Firewall aktiv, erlaube diese Ports im lokalen Netz (mit UFW: sudo ufw allow 47811/udp und sudo ufw allow 47812/tcp).',
'Open “Pair new device” on the other device too, then select it here. Both devices show a code that you confirm on both sides.': 'Öffne „Neues Gerät koppeln“ auch auf dem anderen Gerät und wähle es dann hier aus. Beide Geräte zeigen einen Code, den du auf beiden Seiten bestätigst.',
'Or enter an IP address, e.g. 192.168.0.25': 'Oder IP-Adresse eingeben, z. B. 192.168.0.25',
'Searching for devices …': 'Suche nach Geräten …',
'not ready for pairing': 'nicht bereit zum Koppeln',
'Pair': 'Koppeln',
'Pairing …': 'Kopple …',
'Device found': 'Gerät gefunden',
'Choose which secrets this device may send to paired devices. Everything else (hosts, groups, snippets …) is synchronized without passwords and private keys.': 'Wähle, welche Geheimnisse dieses Gerät an gekoppelte Geräte senden darf. Alles andere (Hosts, Gruppen, Snippets …) wird ohne Passwörter und private Schlüssel synchronisiert.',
'SSH keys': 'SSH-Schlüssel',
'Host passwords': 'Host-Passwörter',
'VPN configurations': 'VPN-Konfigurationen',
'There are no secrets on this device yet.': 'Auf diesem Gerät gibt es noch keine Geheimnisse.',
'Share nothing': 'Nichts teilen',
'Select all': 'Alle auswählen',
'Paired with {name}': 'Mit {name} gekoppelt',
'Confirm pairing': 'Kopplung bestätigen',
'Pairing with “{name}”. Does the other device show the same code?': 'Kopplung mit „{name}“. Zeigt das andere Gerät denselben Code?',
'Codes differ': 'Codes unterschiedlich',
'Codes match': 'Codes stimmen überein',
'Pairing was rejected on this device.': 'Die Kopplung wurde auf diesem Gerät abgelehnt.',
'Pairing was rejected on the other device.': 'Die Kopplung wurde auf dem anderen Gerät abgelehnt.',
'Pairing verification failed.': 'Die Überprüfung der Kopplung ist fehlgeschlagen.',
'Device is not ready for pairing': 'Das Gerät ist nicht zum Koppeln bereit',
'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',
// Android-App
'Lock': 'Sperren',
'Add your first server or pair this phone with MrTerm on your computer to take over your hosts.': 'Lege deinen ersten Server an oder kopple dieses Handy mit MrTerm auf deinem Computer, um deine Hosts zu übernehmen.',
'No matching hosts. Press Enter to connect to “{q}”.': 'Keine passenden Hosts. Drücke Enter, um dich mit „{q}“ zu verbinden.',
'RDP is not available on Android': 'RDP ist unter Android nicht verfügbar',
'Please enter a name.': 'Bitte einen Namen eingeben.',
'Sessions': 'Sitzungen',
'New connection': 'Neue Verbindung',
'Paste': 'Einfügen',
'Copy selection': 'Auswahl kopieren',
'Nothing selected. Long-press the terminal to select text.': 'Nichts ausgewählt. Halte das Terminal gedrückt, um Text auszuwählen.',
'Larger font': 'Schrift größer',
'Smaller font': 'Schrift kleiner',
'Close session': 'Sitzung schließen',
'Generate a new SSH key or import an existing one.': 'Erzeuge einen neuen SSH-Schlüssel oder importiere einen vorhandenen.',
'Key created': 'Schlüssel erstellt',
'Add the public key to ~/.ssh/authorized_keys on your servers.': 'Trage den öffentlichen Schlüssel auf deinen Servern in ~/.ssh/authorized_keys ein.',
'Save frequently used commands and send them to a terminal with one tap.': 'Speichere häufig genutzte Befehle und sende sie mit einem Tippen an ein Terminal.',
'Run in terminal': 'Im Terminal ausführen',
'Command': 'Befehl',
'End the command with a line break to run it immediately.': 'Endet der Befehl mit einem Zeilenumbruch, wird er sofort ausgeführt.',
'Please enter a command.': 'Bitte einen Befehl eingeben.',
'General': 'Allgemein',
'System language': 'Systemsprache',
'Known hosts': 'Bekannte Hosts',
'{n} saved host keys': '{n} gespeicherte Hostschlüssel',
'Show': 'Anzeigen',
'Version {v}': 'Version {v}',
'Updates are installed with Obtainium.': 'Updates werden mit Obtainium installiert.',
'No known hosts yet.': 'Noch keine bekannten Hosts.',
'Remove host key?': 'Hostschlüssel entfernen?',
'Immediately': 'Sofort',
'After 1 minute': 'Nach 1 Minute',
'After 5 minutes': 'Nach 5 Minuten',
'Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.': 'Schütze MrTerm mit einem Passwort und optional deinem Fingerabdruck. Der Tresor wird dann zusätzlich verschlüsselt.',
'Remove app lock': 'App-Sperre entfernen',
'Remove app lock?': 'App-Sperre entfernen?',
'MrTerm will open without a password.': 'MrTerm öffnet sich dann ohne Passwort.',
'Unlock with fingerprint': 'Mit Fingerabdruck entsperren',
'Uses the fingerprint or face unlock of this phone.': 'Nutzt den Fingerabdruck- oder Gesichtsscanner dieses Handys.',
'Confirm fingerprint': 'Fingerabdruck bestätigen',
'Lock when leaving the app': 'Sperren beim Verlassen der App',
'Enter your password to unlock.': 'Gib dein Passwort ein, um zu entsperren.',
'Unlock MrTerm': 'MrTerm entsperren',
'Use password': 'Passwort verwenden',
'Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.': 'Deine Fingerabdrücke haben sich geändert. Entsperre mit deinem Passwort und richte das Entsperren per Fingerabdruck neu ein.',
'Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.': 'Übernimm Hosts, Gruppen, Snippets und bekannte Hosts von MrTerm auf deinem Computer. Die Verbindung ist Ende-zu-Ende verschlüsselt und bleibt in deinem lokalen Netzwerk.',
'Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.': 'Beide Geräte müssen im selben WLAN sein. Wird nichts gefunden, gib in der Firewall deines Computers UDP-Port 47811 und TCP-Port 47812 frei.',
'Or enter an IP address': 'Oder IP-Adresse eingeben',
'Copy failed': 'Kopieren fehlgeschlagen',
'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',
'WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.': 'WARNUNG: Der Host-Schlüssel von {target} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.',
'The authenticity of {target} cannot be verified.': 'Die Echtheit von {target} kann nicht bestätigt werden.',
'Previously stored:': 'Bisher gespeichert:',
'Connect anyway & replace': 'Trotzdem verbinden & ersetzen',
'Trust & connect': 'Vertrauen & verbinden',
'~/.ssh/config not found': '~/.ssh/config nicht gefunden',
'Host not found': 'Host nicht gefunden',
'Rule not found': 'Regel nicht gefunden',
'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}',
'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}',
'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)',
'All files': 'Alle Dateien',
'No FreeRDP found. Install with: sudo pacman -S freerdp': 'Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp',
'No connection to the X server (MrTerm is not running under X11/XWayland).': 'Keine Verbindung zum X-Server (MrTerm läuft nicht unter X11/XWayland).',
'koffi not available': 'koffi nicht verfügbar',
'Platform not supported': 'Plattform nicht unterstützt',
'Connecting to {name} ({addr}) …': 'Verbinde mit {name} ({addr}) …',
'Password for {user}': 'Passwort für {user}',
'SFTP session not found': 'SFTP-Sitzung nicht gefunden',
'Invalid repository URL: {url}': 'Ungültige Repository-URL: {url}',
'Repository not found (private? Then add an access token in the settings).': 'Repository nicht gefunden (privat? Dann Zugriffstoken in den Einstellungen hinterlegen).',
'Update server responded with {status}': 'Update-Server antwortet mit {status}',
'No matching package for this system found in the release.': 'Kein passendes Paket für dieses System im Release gefunden.',
'Download failed ({status})': 'Download fehlgeschlagen ({status})',
'pacman exited with code {code}': 'pacman beendet mit Code {code}',
'Automatic installation is not possible here. The file is located at: {file}': 'Automatische Installation hier nicht möglich. Datei liegt unter: {file}',
};
const DICTS = { de };
const LANGUAGES = [['en', 'English'], ['de', 'Deutsch']];
let lang = 'en';
// pref: 'auto' oder Sprachcode; system: z. B. navigator.language / app.getLocale()
function setLanguage(pref, system) {
const code = String(pref && pref !== 'auto' ? pref : system || 'en').slice(0, 2).toLowerCase();
lang = DICTS[code] ? code : 'en';
return lang;
}
function t(s, vars) {
let r = (DICTS[lang] && DICTS[lang][s]) || s;
if (vars) r = r.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
return r;
}
const api = { t, setLanguage, LANGUAGES, get lang() { return lang; } };
if (typeof module === 'object' && module.exports) module.exports = api;
else root.I18N = api;
})(this);
+213
View File
@@ -0,0 +1,213 @@
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
function head(major, n) {
if (n < 24) return Buffer.from([(major << 5) | n]);
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
}
function enc(v) {
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
if (v instanceof Map) {
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
return Buffer.concat([head(5, items.length), ...items.flat()]);
}
throw new Error('CBOR: unsupported value');
}
function dec(buf, pos = 0) {
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
let n = ai;
if (ai === 24) n = buf[pos++];
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
switch (major) {
case 0: return [n, pos];
case 1: return [-1 - n, pos];
case 2: return [buf.subarray(pos, pos + n), pos + n];
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
case 6: return dec(buf, pos);
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
}
}
// ---- CTAPHID ----
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
class CtapError extends Error {
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
}
function findDevice() {
const base = '/sys/class/hidraw';
let names = [];
try { names = fs.readdirSync(base); } catch { return null; }
for (const n of names) {
try {
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
} catch { /* ignorieren */ }
}
return null;
}
class Device {
static open() {
const p = findDevice();
if (!p) return null;
const d = new Device();
d.fd = fs.openSync(p, 'r+');
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
return d;
}
close() {
this.closed = true;
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
}
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
readPacket() {
const b = Buffer.alloc(64);
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
}
write(cmd, data) {
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
const first = Buffer.alloc(64);
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
data.copy(first, 7, 0, 57); pkt(first);
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
const c = Buffer.alloc(64);
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
}
}
async transact(cmd, data) {
this.write(cmd, data);
for (;;) {
const r = await this.readPacket();
if (!r.subarray(0, 4).equals(this.cid)) continue;
const rcmd = r[4] & 0x7f;
if (rcmd === CMD.KEEPALIVE) continue;
const len = r.readUInt16BE(5);
const parts = [r.subarray(7)];
let got = r.length - 7;
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
const body = Buffer.concat(parts).subarray(0, len);
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
return body;
}
}
async init() {
const nonce = crypto.randomBytes(8);
for (;;) {
const r = await this.transact(CMD.INIT, nonce);
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
}
}
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
async cbor(cmd, params) {
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
if (r[0] !== 0) throw new CtapError(r[0]);
return r.length > 1 ? dec(r, 1)[0] : new Map();
}
}
// ---- PIN-Protokoll 1 ----
const aes = (mode, key, data) => {
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
c.setAutoPadding(false);
return Buffer.concat([c.update(data), c.final()]);
};
async function sharedSecret(dev) {
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
const pub = ecdh.getPublicKey();
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
}
async function pinToken(dev, pin) {
const { key, platformKey } = await sharedSecret(dev);
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
return aes('dec', key, r.get(2));
}
async function pinRetries(dev) {
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
}
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
const abort = () => dev.cancel();
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
const opts = info.get(4) || new Map();
const params = new Map([
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
[6, new Map([['hmac-secret', true]])],
]);
const cdh = crypto.randomBytes(32);
params.set(1, cdh);
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
let wrong = false;
for (;;) {
const pin = await ui.askPin(await pinRetries(dev), wrong);
if (pin == null) throw new CtapError(0x2d);
try {
const tok = await pinToken(dev, pin);
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
params.set(9, 1);
break;
} catch (e) {
if (e.code === PIN.INVALID) { wrong = true; continue; }
throw e;
}
}
}
ui.touch(abort);
timer = setTimeout(abort, timeoutMs);
const r = await dev.cbor(0x01, params);
const authData = r.get(2);
const idLen = authData.readUInt16BE(53);
return Buffer.from(authData.subarray(55, 55 + idLen));
} finally {
clearTimeout(timer);
dev.close();
}
}
module.exports = { makeCredential, CtapError, CANCEL_CODES, PIN };
+114
View File
@@ -0,0 +1,114 @@
// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec).
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
const i18n = require('../i18n');
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
function execOn(conn, cmd, stdin) {
return new Promise((resolve, reject) => {
conn.exec(cmd, (err, stream) => {
if (err) return reject(err);
let out = '', errOut = '';
stream.on('data', (d) => { out += d; });
stream.stderr.on('data', (d) => { errOut += d; });
stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut }));
stream.end(stdin ?? '');
});
});
}
class DockerManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo }
}
async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id);
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
conn.on('error', () => {});
return this.list(id);
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Docker session not found'));
return s;
}
run(s, args) {
const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
}
async list(id) {
const s = this.get(id);
if (!s.runtime) {
const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127');
if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.'));
s.runtime = r.out.trim();
}
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code && denied(r.err) && !s.sudo) {
s.sudo = true;
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code) s.sudo = false;
}
if (r.code) {
if (denied(r.err) || /sudo/i.test(r.err)) {
throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' }));
}
throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
}
const containers = r.out.split('\n').filter(Boolean).map((l) => {
const [cid, name, image, state, status, ports] = l.split('\t');
return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports };
});
return { runtime: s.runtime, sudo: s.sudo, containers };
}
// CPU/RAM der laufenden Container (dauert ~2 s)
async stats(id) {
const s = this.get(id);
const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`);
if (r.code) return {};
return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => {
const [cid, cpu, mem] = l.split('\t');
return [cid.slice(0, 12), { cpu, mem }];
}));
}
async action(id, action, cid) {
const s = this.get(id);
if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action');
const r = await this.run(s, `${ACTIONS[action]} ${cid}`);
if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
return true;
}
// Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort)
command(id, kind, cid) {
const s = this.get(id);
if (!SAFE_ID.test(cid)) throw new Error('Invalid container');
const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`;
if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`;
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
}
close(id) {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
try { s.conn.end(); } catch {}
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
}
}
module.exports = { DockerManager, execOn };
+141
View File
@@ -0,0 +1,141 @@
// FIDO2/WebAuthn (YubiKey & Co.) für die App-Sperre.
// Chromium erlaubt WebAuthn nur auf HTTPS oder http://localhost – nicht unter file://. Deshalb läuft die
// Abfrage in einem kleinen eigenen Fenster, dessen http://localhost-Seite über eine eigene Session
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
const { BrowserWindow, session } = require('electron');
const i18n = require('../i18n');
const ctap2 = require('./ctap2');
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;border:1px solid #2c3142;box-sizing:border-box;text-align:center;padding:16px}
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
button:hover{background:#343a4f}
form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
.row{display:flex;gap:8px}
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
let fidoSession;
function getSession() {
if (fidoSession) return fidoSession;
fidoSession = session.fromPartition('mrterm-fido');
fidoSession.protocol.handle('http', () => new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } }));
return fidoSession;
}
// Gemeinsame Hilfsfunktionen im Fenster (base64url <-> Bytes)
const HELPERS = `
const b64 = (u) => btoa(String.fromCharCode(...new Uint8Array(u))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
`;
async function openWindow(parent, text) {
const w = new BrowserWindow({
parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
});
await w.loadURL('http://localhost/');
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
w.show();
w.focus();
return w;
}
async function ceremony(parent, text, script) {
const w = await openWindow(parent, text);
try {
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (r?.error) throw new Error(r.error);
return r;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
async function registerNative(parent) {
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
const ui = {
async askPin(retries, wrong) {
let t = i18n.t('Enter the PIN of your security key.');
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
await setText(t);
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
return Promise.race([closed, js(`new Promise((resolve) => {
const f = document.getElementById('f'), p = document.getElementById('p');
f.hidden = false; p.value = ''; p.focus();
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
})`)]);
},
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
};
try {
const id = await ctap2.makeCredential(ui);
return id && b64url(id);
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
async function register(parent) {
if (process.platform === 'linux') {
const id = await registerNative(parent);
if (id) return id;
}
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
const cred = await navigator.credentials.create({ publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: 'MrTerm', id: 'localhost' },
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'MrTerm', displayName: 'MrTerm' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -8 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { userVerification: 'discouraged', residentKey: 'discouraged' },
timeout: 60000, extensions: { prf: {} },
} });
return { credId: b64(cred.rawId), prf: cred.getClientExtensionResults().prf?.enabled };`);
if (r.prf === false) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
return r.credId;
}
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
// Liefert { credId, secret: Buffer(32) }
async function derive(parent, creds, text) {
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
const creds = ${JSON.stringify(creds)};
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
const a = await navigator.credentials.get({ publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: 'localhost', timeout: 60000, userVerification: 'discouraged',
allowCredentials: creds.map((c) => ({ type: 'public-key', id: unb64(c.credId) })),
extensions: { prf: { evalByCredential } },
} });
const first = a.getClientExtensionResults().prf?.results?.first;
return { credId: b64(a.rawId), secret: first ? b64(first) : null };`);
if (!r.secret) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
return { credId: r.credId, secret: Buffer.from(r.secret, 'base64url') };
}
module.exports = { register, derive };
+180
View File
@@ -0,0 +1,180 @@
// Firewall entfernter Hosts über SSH einsehen und bearbeiten: UFW sowie iptables/ip6tables (Tabelle filter).
// Braucht root: entweder als root angemeldet oder sudo mit dem gespeicherten Host-Passwort (über stdin).
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
const i18n = require('../i18n');
const { execOn } = require('./docker');
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const RX = {
port: /^\d{1,5}([:,]\d{1,5})*$/,
addr: /^(any|[0-9a-fA-F.:]+(\/\d{1,3})?)$/,
comment: /^[^'"\\`$\n]{0,80}$/,
chain: /^[A-Za-z0-9_.-]{1,40}$/,
iface: /^[A-Za-z0-9_.@-]{1,15}\+?$/,
};
const check = (v, rx, what) => { if (!rx.test(String(v))) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: what, value: v })); return String(v); };
// UFW-Regeln aus `ufw status numbered`
function parseUfw(out) {
const status = /Status:\s*active/i.test(out) ? 'active' : 'inactive';
const rules = [];
for (const line of out.split('\n')) {
const m = line.match(/^\[\s*(\d+)\]\s+(.+?)\s{2,}(ALLOW|DENY|REJECT|LIMIT)(?:\s+(IN|OUT|FWD))?\s+(.+?)\s*$/);
if (!m) continue;
let [, num, to, action, dir, from] = m;
let comment = '';
const c = from.match(/^(.*?)\s+#\s*(.*)$/);
if (c) { from = c[1].trim(); comment = c[2]; }
rules.push({ num: Number(num), to: to.trim(), action, dir: dir || 'IN', from: from.trim(), comment, v6: /\(v6\)/.test(to + from) });
}
return { status, rules };
}
// iptables -S: Richtlinien, Ketten und Regeln (Nummer = Position in der Kette)
function parseIptables(out) {
const chains = new Map();
const get = (n) => { if (!chains.has(n)) chains.set(n, { name: n, policy: null, rules: [] }); return chains.get(n); };
for (const line of out.split('\n')) {
let m;
if ((m = line.match(/^-P (\S+) (\S+)/))) get(m[1]).policy = m[2];
else if ((m = line.match(/^-N (\S+)/))) get(m[1]);
else if ((m = line.match(/^-A (\S+) (.*)$/))) { const c = get(m[1]); c.rules.push({ num: c.rules.length + 1, spec: m[2] }); }
}
return [...chains.values()];
}
class FirewallManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, tools }
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Firewall session not found'));
return s;
}
// Befehl mit root-Rechten ausführen
run(s, cmd) {
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
}
async runOk(s, cmd) {
const r = await this.run(s, cmd);
if (r.code) throw new Error(lastLine(r.err) || lastLine(r.out) || i18n.t('Command failed with code {code}', { code: r.code }));
return r.out;
}
async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id);
const s = { conn, jumps, host, sudo: false, tools: [] };
this.sessions.set(id, s);
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
conn.on('error', () => {});
const uid = (await execOn(conn, 'id -u')).out.trim();
if (uid !== '0') {
s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.'));
}
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
if (!s.tools.includes('ufw') && !s.tools.includes('iptables')) {
throw new Error(s.tools.includes('firewall-cmd') ? i18n.t('This host uses firewalld, which is not supported yet.') : i18n.t('Neither UFW nor iptables was found on this host.'));
}
return { tools: s.tools, sudo: s.sudo };
}
async list(id, backend) {
const s = this.get(id);
if (backend === 'ufw') {
const out = await this.runOk(s, 'ufw status numbered');
const verbose = await this.runOk(s, 'ufw status verbose');
const def = verbose.match(/Default:\s*(\w+)\s*\(incoming\),\s*(\w+)\s*\(outgoing\)(?:,\s*(\w+)\s*\(routed\))?/i);
return { ...parseUfw(out), defaults: def ? { incoming: def[1], outgoing: def[2], routed: def[3] || '' } : null };
}
const bin = backend === 'ip6tables' ? 'ip6tables' : 'iptables';
return { chains: parseIptables(await this.runOk(s, `${bin} -S`)) };
}
async ufw(id, op, a = {}) {
const s = this.get(id);
switch (op) {
case 'enable': return this.runOk(s, 'ufw --force enable');
case 'disable': return this.runOk(s, 'ufw disable');
case 'delete': return this.runOk(s, `ufw --force delete ${Number(a.num)}`);
case 'default': {
const pol = check(a.policy, /^(allow|deny|reject)$/, 'policy');
const dir = check(a.dir, /^(incoming|outgoing|routed)$/, 'direction');
return this.runOk(s, `ufw default ${pol} ${dir}`);
}
case 'add': {
const action = check(a.action, /^(allow|deny|reject|limit)$/, 'action');
const dir = check(a.dir || 'in', /^(in|out)$/, 'direction');
const from = check(a.from || 'any', RX.addr, 'from');
const to = check(a.to || 'any', RX.addr, 'to');
const parts = ['ufw', a.top ? 'insert 1' : '', action, dir];
if (a.port) {
check(a.port, RX.port, 'port');
const proto = check(a.proto || 'any', /^(any|tcp|udp)$/, 'protocol');
// Portbereiche/-listen verlangen bei UFW ein Protokoll
if (/[:,]/.test(a.port) && proto === 'any') throw new Error(i18n.t('Port ranges and lists need a protocol (TCP or UDP).'));
if (proto !== 'any') parts.push('proto', proto);
parts.push('from', from, 'to', to, 'port', a.port);
} else parts.push('from', from, 'to', to);
if (a.comment) parts.push('comment', `'${check(a.comment, RX.comment, 'comment')}'`);
return this.runOk(s, parts.filter(Boolean).join(' '));
}
default: throw new Error('Invalid operation');
}
}
async ipt(id, op, a = {}) {
const s = this.get(id);
const bin = a.family === 6 ? 'ip6tables' : 'iptables';
switch (op) {
case 'delete': return this.runOk(s, `${bin} -D ${check(a.chain, RX.chain, 'chain')} ${Number(a.num)}`);
case 'policy': return this.runOk(s, `${bin} -P ${check(a.chain, /^(INPUT|OUTPUT|FORWARD)$/, 'chain')} ${check(a.policy, /^(ACCEPT|DROP)$/, 'policy')}`);
case 'add': {
const parts = [bin, a.top ? '-I' : '-A', check(a.chain, RX.chain, 'chain')];
const proto = check(a.proto || 'all', /^(all|tcp|udp|icmp|icmpv6)$/, 'protocol');
if (proto !== 'all') parts.push('-p', proto);
if (a.source && a.source !== 'any') parts.push('-s', check(a.source, RX.addr, 'source'));
if (a.iface) parts.push(a.chain === 'OUTPUT' ? '-o' : '-i', check(a.iface, RX.iface, 'interface'));
if (a.port) {
check(a.port, RX.port, 'port');
if (proto !== 'tcp' && proto !== 'udp') throw new Error(i18n.t('A port needs the protocol TCP or UDP.'));
if (a.port.includes(',')) parts.push('-m', 'multiport', '--dports', a.port);
else parts.push('--dport', a.port);
}
if (a.state) parts.push('-m', 'conntrack', '--ctstate', check(a.state, /^[A-Z,]+$/, 'state'));
if (a.comment) parts.push('-m', 'comment', '--comment', `'${check(a.comment, RX.comment, 'comment')}'`);
parts.push('-j', check(a.target, /^(ACCEPT|DROP|REJECT|LOG|RETURN)$/, 'target'));
return this.runOk(s, parts.join(' '));
}
case 'save': {
// Dauerhaft speichern: Debian/Ubuntu (netfilter-persistent bzw. rules.v4/v6) oder Arch (iptables.rules)
const script = 'if command -v netfilter-persistent >/dev/null 2>&1; then netfilter-persistent save; '
+ 'elif [ -f /etc/debian_version ] && [ -d /etc/iptables ]; then iptables-save > /etc/iptables/rules.v4 && ip6tables-save > /etc/iptables/rules.v6; '
+ 'elif [ -d /etc/iptables ]; then iptables-save > /etc/iptables/iptables.rules && ip6tables-save > /etc/iptables/ip6tables.rules; '
+ 'else exit 3; fi';
const r = await this.run(s, `sh -c '${script}'`);
if (r.code === 3) throw new Error(i18n.t('No known way to save iptables rules on this host (e.g. install iptables-persistent).'));
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
return true;
}
default: throw new Error('Invalid operation');
}
}
close(id) {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
try { s.conn.end(); } catch {}
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
}
}
module.exports = { FirewallManager, parseUfw, parseIptables };
+219 -27
View File
@@ -9,6 +9,14 @@ const { SshManager } = require('./ssh');
const rdp = require('./rdp'); const rdp = require('./rdp');
const { Updater } = require('./updater'); const { Updater } = require('./updater');
const { createEmbed, embedSupported } = require('./rdp-embed'); const { createEmbed, embedSupported } = require('./rdp-embed');
const i18n = require('../i18n');
const fido = require('./fido');
const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network');
const { SyncService } = require('./sync');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
const store = new Store(); const store = new Store();
@@ -25,6 +33,13 @@ if (process.platform === 'linux' && readLaunch().x11 !== false && !process.argv.
} }
const pendingSecrets = new Map(); const pendingSecrets = new Map();
// Sicherheitsnetz: unerwartete Fehler (z. B. aus Netzwerkbibliotheken) als Meldung anzeigen statt den Hauptprozess abstürzen zu lassen
process.on('uncaughtException', (e) => {
console.error('Uncaught exception:', e);
send('toast', e?.message || String(e), 'error');
});
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
function send(channel, ...args) { function send(channel, ...args) {
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args); if (win && !win.isDestroyed()) win.webContents.send(channel, ...args);
} }
@@ -34,12 +49,12 @@ async function confirmHostKey(target, fingerprint, known) {
const changed = !!known; const changed = !!known;
const r = await dialog.showMessageBox(win, { const r = await dialog.showMessageBox(win, {
type: changed ? 'warning' : 'question', type: changed ? 'warning' : 'question',
title: changed ? 'Host-Schlüssel hat sich geändert!' : 'Unbekannter Host', title: changed ? i18n.t('Host key has changed!') : i18n.t('Unknown host'),
message: changed message: changed
? `WARNUNG: Der Host-Schlüssel von ${target.host}:${target.port} hat sich geändert. Das kann auf einen Man-in-the-Middle-Angriff hindeuten.` ? i18n.t('WARNING: The host key of {target} has changed. This may indicate a man-in-the-middle attack.', { target: `${target.host}:${target.port}` })
: `Die Echtheit von ${target.host}:${target.port} kann nicht bestätigt werden.`, : i18n.t('The authenticity of {target} cannot be verified.', { target: `${target.host}:${target.port}` }),
detail: `Fingerprint:\n${fingerprint}${changed ? `\n\nBisher gespeichert:\n${known.fingerprint}` : ''}`, detail: `Fingerprint:\n${fingerprint}${changed ? `\n\n${i18n.t('Previously stored:')}\n${known.fingerprint}` : ''}`,
buttons: [changed ? 'Trotzdem verbinden & ersetzen' : 'Vertrauen & verbinden', 'Abbrechen'], buttons: [changed ? i18n.t('Connect anyway & replace') : i18n.t('Trust & connect'), i18n.t('Cancel')],
defaultId: changed ? 1 : 0, defaultId: changed ? 1 : 0,
cancelId: 1, cancelId: 1,
}); });
@@ -56,6 +71,21 @@ function askSecret(sessionId, req) {
const ssh = new SshManager(store, confirmHostKey, askSecret); const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send); const updater = new Updater(store, send);
const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
const pairReplies = new Map();
const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pairReplies.set(reqId, resolve);
send('sync:pairPrompt', { reqId, ...req });
setTimeout(() => { if (pairReplies.delete(reqId)) resolve(false); }, 115000);
}));
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
store.onChange = () => sync.schedule();
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
@@ -86,9 +116,14 @@ function createWindow() {
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
} }
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
const OPEN_WHILE_LOCKED = new Set(['app:version']);
function handle(channel, fn) { function handle(channel, fn) {
ipcMain.handle(channel, async (_e, ...args) => { ipcMain.handle(channel, async (_e, ...args) => {
try { return { ok: true, value: await fn(...args) }; } try {
if (store.locked && !channel.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(channel)) throw new Error(i18n.t('MrTerm is locked.'));
return { ok: true, value: await fn(...args) };
}
catch (e) { return { ok: false, error: e.message || String(e) }; } catch (e) { return { ok: false, error: e.message || String(e) }; }
}); });
} }
@@ -98,26 +133,164 @@ ipcMain.on('win:min', () => win.minimize());
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
ipcMain.on('win:close', () => win.close()); ipcMain.on('win:close', () => win.close());
// ---------- App-Sperre (Passwort / FIDO2) ----------
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 fidoKek = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), 'mrterm-fido-kek', 32));
function lockStatus() {
const { language, appTheme, accent } = store.get().settings;
return {
enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password,
fido: (store.lock?.fido || []).map(({ id, label }) => ({ id, label })), meta: { language, appTheme, accent },
};
}
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); 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:unlockFido', async () => {
const list = store.lock?.fido || [];
if (!list.length) throw new Error(i18n.t('No security key registered.'));
const r = await fido.derive(win, list.map(({ credId, prfSalt }) => ({ credId, prfSalt })));
const entry = list.find((f) => f.credId === r.credId);
if (!entry) throw new Error(i18n.t('Unknown security key.'));
try { store.unlockWith(fidoKek(r.secret), entry.wrap); } catch { throw new Error(i18n.t('This security key could not unlock the vault.')); }
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);
ensureLock().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.dropLockIfEmpty();
store.save();
return lockStatus();
});
handle('lock:addFido', async (label) => {
requireUnlocked();
const credId = await fido.register(win);
const prfSalt = crypto.randomBytes(32).toString('base64url');
const r = await fido.derive(win, [{ credId, prfSalt }], i18n.t('Touch your security key again to finish.'));
ensureLock().fido.push({ id: crypto.randomUUID(), label: label || i18n.t('Security key'), credId, prfSalt, wrap: store.wrapDek(fidoKek(r.secret)) });
store.save();
return lockStatus();
});
handle('lock:removeFido', (id) => {
requireUnlocked();
if (store.lock) store.lock.fido = store.lock.fido.filter((f) => f.id !== id);
store.dropLockIfEmpty();
store.save();
return lockStatus();
});
// ---------- Vault ---------- // ---------- Vault ----------
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform })); // Kopplungsschlüssel und Löschvermerke bleiben im Hauptprozess
handle('vault:upsert', (col, item) => store.upsert(col, item)); const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:remove', (col, id) => store.remove(col, id)); handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform: process.platform }));
handle('vault:upsert', async (col, item) => {
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
if (col === 'vpns' && item.id) {
const old = store.get().vpns.find((v) => v.id === item.id);
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
}
return store.upsert(col, item);
});
handle('vault:remove', async (col, id) => {
if (col === 'vpns') {
await vpn.forget(store.get().vpns.find((v) => v.id === id));
store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
}
return store.remove(col, id);
});
// ---------- Docker ----------
handle('docker:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return docker.open(id, host, () => 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));
// ---------- Firewall ----------
handle('firewall:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return firewall.open(id, host, () => 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));
// ---------- Netzwerk ----------
handle('network:open', async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return network.open(id, host, () => 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 ----------
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 ----------
handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id));
handle('vpn:down', (id) => vpn.down(id));
handle('vault:settings', (s) => { handle('vault:settings', (s) => {
store.setSettings(s); store.setSettings(s);
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;
}); });
@@ -125,7 +298,7 @@ handle('vault:import', async () => {
// Import aus ~/.ssh/config // Import aus ~/.ssh/config
handle('vault:importSshConfig', () => { handle('vault:importSshConfig', () => {
const file = path.join(os.homedir(), '.ssh', 'config'); const file = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(file)) throw new Error('~/.ssh/config nicht gefunden'); if (!fs.existsSync(file)) throw new Error(i18n.t('~/.ssh/config not found'));
const entries = []; const entries = [];
let cur = null; let cur = null;
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
@@ -188,15 +361,19 @@ handle('key:pickFile', async () => {
function hostWithOverrides(hostOrId) { function hostWithOverrides(hostOrId) {
if (typeof hostOrId === 'string') { if (typeof hostOrId === 'string') {
const h = store.resolveHost(hostOrId); const h = store.resolveHost(hostOrId);
if (!h) throw new Error('Host nicht gefunden'); if (!h) throw new Error(i18n.t('Host not found'));
return h; return h;
} }
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
return hostOrId; // Quick-Connect: temporärer Host return hostOrId; // Quick-Connect: temporärer Host
} }
handle('ssh:open', async (sessionId, hostRef, size) => { handle('ssh:open', async (sessionId, hostRef, size) => {
const host = hostWithOverrides(hostRef); const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload)); await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true; return true;
}); });
@@ -209,7 +386,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
}); });
// ---------- SFTP ---------- // ---------- SFTP ----------
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef))); handle('sftp:open', async (sessionId, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return ssh.openSftp(sessionId, host);
});
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir)); handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b)); handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => { handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
@@ -243,9 +424,10 @@ handle('local:op', (op, a, b) => {
}); });
// ---------- Port-Forwarding ---------- // ---------- Port-Forwarding ----------
handle('fw:start', (id) => { handle('fw:start', async (id) => {
const fw = store.get().forwards.find((f) => f.id === id); const fw = store.get().forwards.find((f) => f.id === id);
if (!fw) throw new Error('Regel nicht gefunden'); if (!fw) throw new Error(i18n.t('Rule not found'));
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
return ssh.startForward(fw, (ev) => send('fw:event', id, ev)); return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
}); });
handle('fw:stop', (id) => ssh.stopForward(id)); handle('fw:stop', (id) => ssh.stopForward(id));
@@ -253,13 +435,14 @@ handle('fw:active', () => ssh.activeForwards());
// ---------- RDP ---------- // ---------- RDP ----------
handle('rdp:detect', () => rdp.detect(store.get().settings)); handle('rdp:detect', () => rdp.detect(store.get().settings));
handle('rdp:launch', (hostId) => { handle('rdp:launch', async (hostId) => {
const host = hostWithOverrides(hostId); const host = hostWithOverrides(hostId);
if (await vpn.ensureForHost(host)) send('vpn:changed');
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
const r = rdp.launch(host, store.get().settings); const r = rdp.launch(host, store.get().settings);
if (r.process) { if (r.process) {
r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', `RDP beendet (Code ${code}): ${r.getErr().split('\n').filter(Boolean).slice(-1)[0] || ''}`, 'error'); }); r.process.on('exit', (code) => { if (code && code !== 0 && code !== 12) send('toast', i18n.t('RDP exited (code {code}): {err}', { code, err: r.getErr().split('\n').filter(Boolean).slice(-1)[0] || '' }), 'error'); });
r.process.on('error', (e) => send('toast', 'RDP-Start fehlgeschlagen: ' + e.message, 'error')); r.process.on('error', (e) => send('toast', i18n.t('RDP launch failed: {msg}', { msg: e.message }), 'error'));
} }
return r.client; return r.client;
}); });
@@ -268,12 +451,13 @@ handle('rdp:launch', (hostId) => {
const rdpSessions = new Map(); const rdpSessions = new Map();
handle('rdp:embedSupported', () => { handle('rdp:embedSupported', () => {
const s = embedSupported(); const s = embedSupported();
if (s.ok && process.platform === 'linux' && !rdp.linuxClient(null, true)) return { ok: false, reason: 'xfreerdp3 fehlt (sudo pacman -S freerdp)' }; if (s.ok && process.platform === 'linux' && !rdp.linuxClient(null, true)) return { ok: false, reason: i18n.t('xfreerdp3 missing (sudo pacman -S freerdp)') };
return s; return s;
}); });
handle('rdp:open', async (id, hostRef, bounds) => { handle('rdp:open', async (id, hostRef, bounds) => {
const host = hostWithOverrides(hostRef); const host = hostWithOverrides(hostRef);
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
if (await vpn.ensureForHost(host)) send('vpn:changed');
const emb = createEmbed(win.getNativeWindowHandle()); const emb = createEmbed(win.getNativeWindowHandle());
const sess = { emb, cancelled: false }; const sess = { emb, cancelled: false };
rdpSessions.set(id, sess); rdpSessions.set(id, sess);
@@ -320,7 +504,7 @@ handle('rdp:detach', (id) => {
// ---------- Import aus anderen Programmen (z. B. Devolutions RDM) ---------- // ---------- Import aus anderen Programmen (z. B. Devolutions RDM) ----------
handle('import:pickFile', async (title, extensions) => { handle('import:pickFile', async (title, extensions) => {
const r = await dialog.showOpenDialog(win, { title, filters: [{ name: title, extensions }, { name: 'Alle Dateien', extensions: ['*'] }], properties: ['openFile'] }); const r = await dialog.showOpenDialog(win, { title, filters: [{ name: title, extensions }, { name: i18n.t('All files'), extensions: ['*'] }], properties: ['openFile'] });
if (r.canceled) return null; if (r.canceled) return null;
const buf = fs.readFileSync(r.filePaths[0]); const buf = fs.readFileSync(r.filePaths[0]);
// UTF-16-Exporte (BOM) erkennen // UTF-16-Exporte (BOM) erkennen
@@ -364,12 +548,20 @@ handle('shell:open', (url) => shell.openExternal(url));
app.whenReady().then(() => { app.whenReady().then(() => {
store.load(); store.load();
applyLanguage();
createWindow(); createWindow();
if (store.get().settings.updateAutoCheck && app.isPackaged) { sync.start().catch(() => {});
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000); scheduleUpdateCheck();
}
}); });
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
// Automatische Update-Prüfung – bei gesperrtem Vault erst nach dem Entsperren (Einstellungen sind verschlüsselt)
let updateScheduled = false;
function scheduleUpdateCheck() {
if (updateScheduled || store.sealed || !store.get().settings.updateAutoCheck || !app.isPackaged) return;
updateScheduled = true;
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
}
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) {
+346
View File
@@ -0,0 +1,346 @@
// Netzwerk-Konfiguration als einheitliches Modell – Lesen/Schreiben für
// Ubuntu: netplan (YAML, hier als JSON verarbeitet; JSON ist gültiges YAML)
// Debian/Proxmox: ifupdown / ifupdown2 (/etc/network/interfaces + interfaces.d)
// Modell pro Schnittstelle:
// { name, kind: ethernet|bond|bridge|vlan, method4: dhcp|static|none, addresses[], gateway, dns[], search[], mtu,
// method6: auto|dhcp|static|none, addresses6[], gateway6,
// bond: { members[], mode, lacpRate, hashPolicy, miimon }, bridge: { members[], stp }, vlan: { id, link }, file }
// Reine Funktionen ohne Seiteneffekte (lokal testbar).
const RX = {
name: /^[a-zA-Z0-9_.:-]{1,15}$/,
cidr4: /^(\d{1,3})(\.\d{1,3}){3}\/\d{1,2}$/,
cidr6: /^[0-9a-fA-F:]+(%\w+)?\/\d{1,3}$/,
ip4: /^\d{1,3}(\.\d{1,3}){3}$/,
ip6: /^[0-9a-fA-F:]+$/,
domain: /^[a-zA-Z0-9.-]{1,253}$/,
};
const BOND_MODES = ['balance-rr', 'active-backup', 'balance-xor', 'broadcast', '802.3ad', 'balance-tlb', 'balance-alb'];
const HASH = ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4'];
function fail(msg) { const e = new Error(msg); e.validation = true; throw e; }
// Eingaben prüfen, bevor daraus Konfigurationsdateien entstehen
function validate(m, t = (s, v) => (v ? s.replace(/\{(\w+)\}/g, (x, k) => v[k]) : s)) {
const chk = (ok, field, value) => { if (!ok) fail(t('Invalid value for {field}: {value}', { field, value })); };
chk(RX.name.test(m.name || ''), 'name', m.name);
chk(['ethernet', 'bond', 'bridge', 'vlan'].includes(m.kind), 'kind', m.kind);
chk(['dhcp', 'static', 'none'].includes(m.method4), 'IPv4', m.method4);
chk(['auto', 'dhcp', 'static', 'none'].includes(m.method6), 'IPv6', m.method6);
for (const a of m.addresses || []) chk(RX.cidr4.test(a), 'IPv4 address', a);
for (const a of m.addresses6 || []) chk(RX.cidr6.test(a), 'IPv6 address', a);
if (m.method4 === 'static' && !(m.addresses || []).length) fail(t('A static configuration needs at least one IPv4 address (e.g. 192.168.1.10/24).'));
if (m.method6 === 'static' && !(m.addresses6 || []).length) fail(t('A static IPv6 configuration needs at least one IPv6 address.'));
if (m.gateway) chk(RX.ip4.test(m.gateway), 'gateway', m.gateway);
if (m.gateway6) chk(RX.ip6.test(m.gateway6), 'IPv6 gateway', m.gateway6);
for (const d of m.dns || []) chk(RX.ip4.test(d) || RX.ip6.test(d), 'DNS', d);
for (const d of m.search || []) chk(RX.domain.test(d), 'search domain', d);
if (m.mtu !== '' && m.mtu != null) chk(Number(m.mtu) >= 68 && Number(m.mtu) <= 65535, 'MTU', m.mtu);
if (m.kind === 'bond') {
chk(BOND_MODES.includes(m.bond?.mode), 'bond mode', m.bond?.mode);
for (const x of m.bond.members || []) chk(RX.name.test(x), 'member', x);
if (m.bond.lacpRate) chk(['slow', 'fast'].includes(m.bond.lacpRate), 'LACP rate', m.bond.lacpRate);
if (m.bond.hashPolicy) chk(HASH.includes(m.bond.hashPolicy), 'hash policy', m.bond.hashPolicy);
if (m.bond.miimon !== '' && m.bond.miimon != null) chk(/^\d{1,5}$/.test(String(m.bond.miimon)), 'miimon', m.bond.miimon);
}
if (m.kind === 'bridge') for (const x of m.bridge?.members || []) chk(RX.name.test(x), 'port', x);
if (m.kind === 'vlan') {
chk(Number(m.vlan?.id) >= 1 && Number(m.vlan?.id) <= 4094, 'VLAN ID', m.vlan?.id);
chk(RX.name.test(m.vlan?.link || ''), 'VLAN parent', m.vlan?.link);
}
return m;
}
const blank = (name, kind = 'ethernet') => ({
name, kind, method4: 'none', addresses: [], gateway: '', dns: [], search: [], mtu: '',
method6: 'auto', addresses6: [], gateway6: '',
bond: kind === 'bond' ? { members: [], mode: '802.3ad', lacpRate: 'fast', hashPolicy: 'layer3+4', miimon: 100 } : undefined,
bridge: kind === 'bridge' ? { members: [], stp: false } : undefined,
vlan: kind === 'vlan' ? { id: '', link: '' } : undefined,
});
// ======================================================================= netplan
const NP_SECT = { ethernet: 'ethernets', bond: 'bonds', bridge: 'bridges', vlan: 'vlans' };
const DEFAULT_ROUTE = (r) => ['default', '0.0.0.0/0', '::/0'].includes(r?.to);
const addrOf = (a) => (typeof a === 'string' ? a : Object.keys(a || {})[0] || '');
function fromNetplan(cfg) {
const net = cfg?.network || {};
const out = [];
for (const [kind, sect] of Object.entries(NP_SECT)) {
for (const [name, c] of Object.entries(net[sect] || {})) {
const m = blank(name, kind);
const addrs = (c.addresses || []).map(addrOf);
m.addresses = addrs.filter((a) => !a.includes(':'));
m.addresses6 = addrs.filter((a) => a.includes(':'));
const routes = c.routes || [];
m.gateway = c.gateway4 || routes.find((r) => DEFAULT_ROUTE(r) && !String(r.via).includes(':'))?.via || '';
m.gateway6 = c.gateway6 || routes.find((r) => DEFAULT_ROUTE(r) && String(r.via).includes(':'))?.via || '';
m.method4 = c.dhcp4 === true || c.dhcp4 === 'yes' || c.dhcp4 === 'true' ? 'dhcp' : m.addresses.length ? 'static' : 'none';
m.method6 = c.dhcp6 === true || c.dhcp6 === 'yes' ? 'dhcp' : m.addresses6.length ? 'static' : c['accept-ra'] === false ? 'none' : 'auto';
m.dns = c.nameservers?.addresses || [];
m.search = c.nameservers?.search || [];
m.mtu = c.mtu ?? '';
const p = c.parameters || {};
if (kind === 'bond') m.bond = { members: c.interfaces || [], mode: p.mode || 'balance-rr', lacpRate: p['lacp-rate'] || '', hashPolicy: p['transmit-hash-policy'] || '', miimon: p['mii-monitor-interval'] ?? '' };
if (kind === 'bridge') m.bridge = { members: c.interfaces || [], stp: p.stp === true };
if (kind === 'vlan') m.vlan = { id: c.id ?? '', link: c.link || '' };
out.push(m);
}
}
return out;
}
// Mitglieder aus allen Bonds/Bridges außer "keep" entfernen
function npDetach(net, members, keepSect, keepName) {
for (const sect of ['bonds', 'bridges']) {
for (const [n, c] of Object.entries(net[sect] || {})) {
if (sect === keepSect && n === keepName) continue;
if (Array.isArray(c.interfaces)) c.interfaces = c.interfaces.filter((x) => !members.includes(x));
}
}
}
// Modell m in die netplan-Konfiguration übernehmen (liefert neue Konfiguration)
function applyNetplan(cfg, m, { remove = false } = {}) {
const out = structuredClone(cfg || {});
const net = (out.network ||= { version: 2 });
net.version ||= 2;
const sect = NP_SECT[m.kind];
if (remove) {
if (net[sect]) delete net[sect][m.name];
npDetach(net, [m.name]);
return out;
}
const c = ((net[sect] ||= {})[m.name] ||= {});
delete c.gateway4; delete c.gateway6;
c.dhcp4 = m.method4 === 'dhcp';
if (m.method6 === 'dhcp') c.dhcp6 = true; else delete c.dhcp6;
if (m.method6 === 'none') { c['accept-ra'] = false; c['link-local'] = ['ipv4']; }
else { if (c['accept-ra'] === false) delete c['accept-ra']; if (Array.isArray(c['link-local']) && !c['link-local'].includes('ipv6')) delete c['link-local']; }
const addrs = [...(m.method4 === 'static' ? m.addresses : []), ...(m.method6 === 'static' ? m.addresses6 : [])];
if (addrs.length) c.addresses = addrs; else delete c.addresses;
const routes = (c.routes || []).filter((r) => !DEFAULT_ROUTE(r));
if (m.method4 === 'static' && m.gateway) routes.push({ to: 'default', via: m.gateway });
if (m.method6 === 'static' && m.gateway6) routes.push({ to: '::/0', via: m.gateway6 });
if (routes.length) c.routes = routes; else delete c.routes;
if ((m.dns || []).length || (m.search || []).length) c.nameservers = { ...((m.dns || []).length ? { addresses: m.dns } : {}), ...((m.search || []).length ? { search: m.search } : {}) };
else delete c.nameservers;
if (m.mtu !== '' && m.mtu != null) c.mtu = Number(m.mtu); else delete c.mtu;
const members = m.kind === 'bond' ? m.bond.members : m.kind === 'bridge' ? m.bridge.members : [];
if (m.kind === 'bond' || m.kind === 'bridge') {
c.interfaces = [...members];
npDetach(net, members, sect, m.name);
// Mitglieder dürfen selbst keine IP-Konfiguration haben
for (const x of members) {
const sub = Object.values(NP_SECT).map((s) => net[s]?.[x]).find(Boolean) || ((net.ethernets ||= {})[x] = {});
for (const k of ['addresses', 'routes', 'nameservers', 'gateway4', 'gateway6', 'dhcp6']) delete sub[k];
sub.dhcp4 = false;
}
}
if (m.kind === 'bond') {
const p = { ...(c.parameters || {}), mode: m.bond.mode };
if (m.bond.mode === '802.3ad' && m.bond.lacpRate) p['lacp-rate'] = m.bond.lacpRate; else delete p['lacp-rate'];
if (m.bond.hashPolicy && ['802.3ad', 'balance-xor', 'balance-tlb', 'balance-alb'].includes(m.bond.mode)) p['transmit-hash-policy'] = m.bond.hashPolicy; else delete p['transmit-hash-policy'];
if (m.bond.miimon !== '' && m.bond.miimon != null) p['mii-monitor-interval'] = Number(m.bond.miimon); else delete p['mii-monitor-interval'];
c.parameters = p;
}
if (m.kind === 'bridge') c.parameters = { ...(c.parameters || {}), stp: !!m.bridge.stp };
if (m.kind === 'vlan') { c.id = Number(m.vlan.id); c.link = m.vlan.link; }
return out;
}
// ======================================================================= ifupdown
const KEYWORDS = /^(iface|auto|allow-[\w-]+|mapping|source|source-directory|rename|no-auto-down|no-scripts)\b/;
// Optionen, die MrTerm selbst schreibt; alle anderen bleiben unverändert erhalten
const MANAGED = new Set(['address', 'netmask', 'gateway', 'dns-nameservers', 'dns-search', 'mtu',
'bond-slaves', 'bond_slaves', 'slaves', 'bond-mode', 'bond_mode', 'bond-miimon', 'bond_miimon', 'bond-lacp-rate', 'bond_lacp_rate',
'bond-xmit-hash-policy', 'bond_xmit_hash_policy', 'bridge-ports', 'bridge_ports', 'bridge-stp', 'bridge_stp', 'vlan-raw-device', 'vlan-id', 'bond-master']);
// IP-Optionen, die ein Bond-/Bridge-Mitglied nicht haben darf
const IPKEYS = new Set(['address', 'netmask', 'gateway', 'dns-nameservers', 'dns-search']);
function parseInterfaces(text, file) {
const blocks = [];
let cur = null;
for (const line of String(text).split('\n')) {
const t = line.trim();
if (KEYWORDS.test(t)) {
const [kw, ...rest] = t.split(/\s+/);
if (kw === 'iface') {
cur = { type: 'iface', name: rest[0], family: rest[1] || 'inet', method: rest[2] || 'manual', options: [], file };
blocks.push(cur);
continue;
}
cur = null;
if (kw === 'auto' || kw.startsWith('allow-')) { blocks.push({ type: 'auto', kw, names: rest, file }); continue; }
blocks.push({ type: 'raw', line, file });
continue;
}
if (cur && t && !t.startsWith('#')) {
const [key, ...v] = t.split(/\s+/);
cur.options.push({ key, value: v.join(' ') });
continue;
}
if (cur && t.startsWith('#')) { cur.options.push({ comment: line }); continue; }
if (!t) cur = null;
blocks.push({ type: 'raw', line, file });
}
return blocks;
}
const maskToPrefix = (mask) => String(mask).split('.').reduce((n, o) => n + (Number(o) >>> 0).toString(2).split('').filter((b) => b === '1').length, 0);
function fromIfupdown(blocks) {
const byName = new Map();
const opt = (b, ...keys) => b?.options.filter((o) => keys.includes(o.key)).map((o) => o.value) || [];
for (const b of blocks.filter((x) => x.type === 'iface')) {
if (b.method === 'loopback' || b.name === 'lo') continue;
const e = byName.get(b.name) || { inet: null, inet6: null };
e[b.family === 'inet6' ? 'inet6' : 'inet'] = b;
byName.set(b.name, e);
}
// Mitglieder, die per bond-master auf einen Bond zeigen
const bondMasters = {};
for (const [name, e] of byName) { const bm = opt(e.inet, 'bond-master')[0]; if (bm) (bondMasters[bm] ||= []).push(name); }
const out = [];
for (const [name, { inet, inet6 }] of byName) {
const b = inet || inet6;
const slaves = opt(inet, 'bond-slaves', 'bond_slaves', 'slaves')[0];
const bridgePorts = opt(inet, 'bridge-ports', 'bridge_ports')[0];
const vlanDev = opt(inet, 'vlan-raw-device')[0] || opt(inet6, 'vlan-raw-device')[0];
const vm = name.match(/^(.+)\.(\d+)$/);
const kind = slaves !== undefined || opt(inet, 'bond-mode', 'bond_mode').length ? 'bond' : bridgePorts !== undefined ? 'bridge' : vlanDev || vm || /^vlan\d+$/.test(name) ? 'vlan' : 'ethernet';
const m = blank(name, kind);
m.file = b.file;
if (inet) {
m.method4 = inet.method === 'dhcp' ? 'dhcp' : inet.method === 'static' ? 'static' : 'none';
const mask = opt(inet, 'netmask')[0];
m.addresses = opt(inet, 'address').map((a) => (a.includes('/') ? a : `${a}/${mask ? maskToPrefix(mask) : 24}`));
m.gateway = opt(inet, 'gateway')[0] || '';
m.dns = (opt(inet, 'dns-nameservers')[0] || '').split(/\s+/).filter(Boolean);
m.search = (opt(inet, 'dns-search')[0] || '').split(/\s+/).filter(Boolean);
m.mtu = opt(inet, 'mtu')[0] || '';
}
if (inet6) {
m.method6 = inet6.method === 'dhcp' ? 'dhcp' : inet6.method === 'static' ? 'static' : inet6.method === 'auto' ? 'auto' : 'none';
m.addresses6 = opt(inet6, 'address').map((a) => (a.includes('/') ? a : `${a}/${opt(inet6, 'netmask')[0] || 64}`));
m.gateway6 = opt(inet6, 'gateway')[0] || '';
} else m.method6 = 'auto';
if (kind === 'bond') {
const members = slaves && slaves !== 'none' ? slaves.split(/\s+/) : bondMasters[name] || [];
m.bond = { members, mode: opt(inet, 'bond-mode', 'bond_mode')[0] || 'balance-rr', lacpRate: opt(inet, 'bond-lacp-rate', 'bond_lacp_rate')[0] || '',
hashPolicy: opt(inet, 'bond-xmit-hash-policy', 'bond_xmit_hash_policy')[0] || '', miimon: opt(inet, 'bond-miimon', 'bond_miimon')[0] || '' };
if (m.bond.lacpRate === '1') m.bond.lacpRate = 'fast';
if (m.bond.lacpRate === '0') m.bond.lacpRate = 'slow';
if (m.bond.mode === '4') m.bond.mode = '802.3ad';
}
if (kind === 'bridge') m.bridge = { members: bridgePorts && bridgePorts !== 'none' ? bridgePorts.split(/\s+/) : [], stp: /^(on|yes)$/.test(opt(inet, 'bridge-stp', 'bridge_stp')[0] || '') };
if (kind === 'vlan') m.vlan = { id: opt(inet, 'vlan-id')[0] || vm?.[2] || (name.match(/^vlan(\d+)$/) || [])[1] || '', link: vlanDev || vm?.[1] || '' };
out.push(m);
}
return out;
}
function ifaceLines(m, keep4 = [], keep6 = [], had6 = false) {
const L = [];
const o = (k, v) => L.push(` ${k} ${v}`);
L.push(`iface ${m.name} inet ${m.method4 === 'dhcp' ? 'dhcp' : m.method4 === 'static' ? 'static' : 'manual'}`);
if (m.method4 === 'static') { m.addresses.forEach((a) => o('address', a)); if (m.gateway) o('gateway', m.gateway); }
if ((m.dns || []).length) o('dns-nameservers', m.dns.join(' '));
if ((m.search || []).length) o('dns-search', m.search.join(' '));
if (m.mtu !== '' && m.mtu != null) o('mtu', m.mtu);
if (m.kind === 'bond') {
o('bond-slaves', m.bond.members.length ? m.bond.members.join(' ') : 'none');
o('bond-mode', m.bond.mode);
if (m.bond.miimon !== '' && m.bond.miimon != null) o('bond-miimon', m.bond.miimon);
if (m.bond.mode === '802.3ad' && m.bond.lacpRate) o('bond-lacp-rate', m.bond.lacpRate);
if (m.bond.hashPolicy && ['802.3ad', 'balance-xor', 'balance-tlb', 'balance-alb'].includes(m.bond.mode)) o('bond-xmit-hash-policy', m.bond.hashPolicy);
}
if (m.kind === 'bridge') { o('bridge-ports', m.bridge.members.length ? m.bridge.members.join(' ') : 'none'); o('bridge-stp', m.bridge.stp ? 'on' : 'off'); }
if (m.kind === 'vlan' && !/^.+\.\d+$/.test(m.name)) { o('vlan-raw-device', m.vlan.link); o('vlan-id', m.vlan.id); }
keep4.forEach((x) => L.push(x.comment ?? ` ${x.key} ${x.value}`));
// inet6: dhcp/static immer; "auto" nur, wenn der Block vorher schon existierte (sonst Kernel-Standard SLAAC)
if (m.method6 === 'dhcp' || m.method6 === 'static' || (m.method6 === 'auto' && had6)) {
L.push('');
L.push(`iface ${m.name} inet6 ${m.method6}`);
if (m.method6 === 'static') { m.addresses6.forEach((a) => o('address', a)); if (m.gateway6) o('gateway', m.gateway6); }
keep6.forEach((x) => L.push(x.comment ?? ` ${x.key} ${x.value}`));
}
return L;
}
// Modell in die Blöcke übernehmen; liefert { files: { pfad: inhalt } } für alle geänderten Dateien
function applyIfupdown(blocks, m, { remove = false, mainFile = '/etc/network/interfaces' } = {}) {
let bl = blocks.map((b) => ({ ...b, options: b.options ? [...b.options] : undefined, names: b.names ? [...b.names] : undefined }));
const changed = new Set();
const file = bl.find((b) => b.type === 'iface' && b.name === m.name)?.file || m.file || mainFile;
const members = m.kind === 'bond' ? m.bond.members : m.kind === 'bridge' ? m.bridge.members : [];
// bestehende Blöcke der Schnittstelle entfernen (Position merken)
const old = bl.filter((b) => b.type === 'iface' && b.name === m.name);
old.forEach((b) => changed.add(b.file));
const keep4 = (old.find((b) => b.family !== 'inet6')?.options || []).filter((x) => x.comment || !MANAGED.has(x.key));
const keep6 = (old.find((b) => b.family === 'inet6')?.options || []).filter((x) => x.comment || !MANAGED.has(x.key));
let pos = bl.findIndex((b) => b.type === 'iface' && b.name === m.name);
bl = bl.filter((b) => !(b.type === 'iface' && b.name === m.name));
if (remove) {
bl.forEach((b) => { if (b.type === 'auto' && b.names.includes(m.name)) { b.names = b.names.filter((n) => n !== m.name); changed.add(b.file); } });
bl = bl.filter((b) => !(b.type === 'auto' && !b.names.length));
} else {
changed.add(file);
if (pos < 0) { bl.push({ type: 'raw', line: '', file }); pos = bl.length; }
const hasAuto = bl.some((b) => b.type === 'auto' && b.names.includes(m.name));
const nb = [];
if (!hasAuto) nb.push({ type: 'auto', kw: 'auto', names: [m.name], file });
nb.push({ type: 'text', lines: ifaceLines(m, keep4, keep6, old.some((b) => b.family === 'inet6')), file });
bl.splice(pos, 0, ...nb);
}
// Mitglieder: aus anderen Bonds/Bridges lösen, selbst "inet manual" ohne IP
if (!remove && members.length) {
for (const b of bl) {
if (b.type !== 'iface' || b.name === m.name || b.family === 'inet6') continue;
for (const o of b.options) {
if (['bond-slaves', 'bond_slaves', 'slaves', 'bridge-ports', 'bridge_ports'].includes(o.key)) {
const v = o.value.split(/\s+/).filter((x) => x !== 'none' && !members.includes(x));
if (v.join(' ') !== o.value) { o.value = v.length ? v.join(' ') : 'none'; changed.add(b.file); }
}
}
}
for (const x of members) {
const idx = bl.findIndex((b) => b.type === 'iface' && b.name === x && b.family !== 'inet6');
const cur = bl[idx];
// Bereits "manual" ohne IP (z. B. Bond als Bridge-Port): unverändert lassen
if (cur && cur.method === 'manual' && !cur.options.some((o) => IPKEYS.has(o.key))) continue;
const others = (cur?.options || []).filter((o) => o.comment || !IPKEYS.has(o.key));
bl = bl.filter((b) => !(b.type === 'iface' && b.name === x));
const f = cur?.file || file;
changed.add(f);
const lines = [`iface ${x} inet manual`, ...others.map((o) => o.comment ?? ` ${o.key} ${o.value}`)];
if (cur) { bl.splice(Math.min(idx, bl.length), 0, { type: 'text', lines, file: f }); continue; }
// Neues Mitglied vor dem Block der Schnittstelle (inkl. deren auto-Zeile) einfügen
let at = bl.findIndex((b) => b.type === 'text' && b.lines[0].startsWith(`iface ${m.name} `));
if (at > 0 && bl[at - 1].type === 'auto' && bl[at - 1].names.includes(m.name)) at--;
bl.splice(at < 0 ? bl.length : at, 0, { type: 'auto', kw: 'auto', names: [x], file: f }, { type: 'text', lines, file: f }, { type: 'raw', line: '', file: f });
}
}
const files = {};
for (const f of changed) {
const lines = [];
for (const b of bl.filter((x) => x.file === f)) {
if (b.type === 'raw') lines.push(b.line);
else if (b.type === 'auto') lines.push(`${b.kw} ${b.names.join(' ')}`);
else if (b.type === 'text') lines.push(...b.lines);
else if (b.type === 'iface') {
lines.push(`iface ${b.name} ${b.family} ${b.method}`);
b.options.forEach((o) => lines.push(o.comment ?? ` ${o.key} ${o.value}`));
}
}
files[f] = lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '\n');
}
return { files };
}
module.exports = { validate, blank, fromNetplan, applyNetplan, parseInterfaces, fromIfupdown, applyIfupdown, BOND_MODES, HASH };
+326
View File
@@ -0,0 +1,326 @@
// Netzwerk-Tab: Schnittstellen, IPs, Bonds (LACP), Bridges, VLANs, Gateway, DNS und Hostname entfernter Hosts.
// Bearbeiten für Ubuntu (netplan) und Debian/Proxmox (ifupdown/ifupdown2); sonst Übersicht + Datei-Editor.
// Änderungen werden mit automatischem Rollback angewendet: Der Server stellt die vorherige Konfiguration
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
const i18n = require('../i18n');
const { execOn } = require('./docker');
const nc = require('./netconf');
const STATE = '/var/lib/mrterm-net';
const ROLLBACK_SECONDS = 90;
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const b64 = (s) => Buffer.from(String(s)).toString('base64');
const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const EDITABLE_PATH = /^(\/etc\/netplan\/[\w.-]+\.yaml|\/etc\/network\/interfaces(\.d\/[\w.-]+)?|\/etc\/systemd\/network\/[\w.-]+|\/etc\/resolv\.conf|\/etc\/hosts)$/;
const BACKENDS = {
netplan: {
paths: ['etc/netplan'],
restore: `rm -rf /etc/netplan && mkdir -p /etc/netplan && tar xzf ${STATE}/backup.tgz -C /`,
validate: 'netplan generate',
apply: 'netplan apply',
},
ifupdown: {
paths: ['etc/network/interfaces', 'etc/network/interfaces.d'],
restore: `tar xzf ${STATE}/backup.tgz -C /`,
validate: 'if command -v ifreload >/dev/null 2>&1; then ifquery -a >/dev/null; else ifup --no-act -a >/dev/null; fi',
apply: 'if command -v ifreload >/dev/null 2>&1; then ifreload -a; else systemctl restart networking; fi',
},
networkd: {
paths: ['etc/systemd/network'],
restore: `rm -rf /etc/systemd/network && mkdir -p /etc/systemd/network && tar xzf ${STATE}/backup.tgz -C /`,
validate: 'true',
apply: 'networkctl reload 2>/dev/null || systemctl restart systemd-networkd',
},
};
// Merged netplan-Konfiguration (alle /etc/netplan/*.yaml) als JSON – netplan bringt python3-yaml mit
const NETPLAN_PY = `import sys,json,glob,os
try:
import yaml
except Exception:
print(json.dumps({"error":"python3-yaml missing"})); sys.exit(0)
def merge(a,b):
for k,v in (b or {}).items():
if isinstance(v,dict) and isinstance(a.get(k),dict): merge(a[k],v)
else: a[k]=v
return a
files=sorted(glob.glob("/etc/netplan/*.yaml"), key=os.path.basename)
out={}
for f in files:
merge(out, yaml.safe_load(open(f)) or {})
print(json.dumps({"files":files,"config":out}))`;
const GATHER = `export PATH=$PATH:/usr/sbin:/sbin
echo "@@HOSTNAME"; hostname
echo "@@OS"; (. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")
echo "@@BACKEND"
if ls /etc/netplan/*.yaml >/dev/null 2>&1 && command -v netplan >/dev/null 2>&1; then echo netplan
elif [ -f /etc/network/interfaces ] && { command -v ifreload >/dev/null 2>&1 || command -v ifup >/dev/null 2>&1; }; then echo ifupdown
elif systemctl is-active -q NetworkManager 2>/dev/null; then echo networkmanager
elif systemctl is-active -q systemd-networkd 2>/dev/null; then echo networkd
else echo unknown; fi
echo "@@LINK"; ip -j -d link show
echo "@@ADDR"; ip -j addr show
echo "@@ROUTE"; ip -j route show default
echo "@@ROUTE6"; ip -j -6 route show default
echo "@@RESOLVTYPE"; if [ -L /etc/resolv.conf ]; then echo symlink; else echo file; fi
echo "@@RESOLV"; cat /etc/resolv.conf 2>/dev/null
echo "@@RESOLVECTL"; resolvectl dns 2>/dev/null
echo "@@BONDING"; for f in /proc/net/bonding/*; do [ -f "$f" ] && { echo "== \${f##*/}"; cat "$f"; }; done
echo "@@NETPLAN"; if command -v netplan >/dev/null 2>&1; then python3 -c '${NETPLAN_PY}' 2>&1; fi
echo "@@IFUPDOWN"; for f in /etc/network/interfaces /etc/network/interfaces.d/*; do [ -f "$f" ] && { echo "==FILE $f"; cat "$f"; echo; }; done
echo "@@FILES"; ls -1d /etc/netplan/*.yaml /etc/network/interfaces /etc/network/interfaces.d/* /etc/systemd/network/* 2>/dev/null
echo "@@PENDING"; [ -f ${STATE}/pending ] && echo pending; [ -f ${STATE}/rolled-back ] && cat ${STATE}/rolled-back
echo "@@END"`;
function sections(out) {
const res = {};
let cur = null;
for (const line of out.split('\n')) {
const m = line.match(/^@@(\w+)$/);
if (m) { cur = m[1]; res[cur] = []; continue; }
if (cur) res[cur].push(line);
}
return Object.fromEntries(Object.entries(res).map(([k, v]) => [k, v.join('\n').trim()]));
}
const json = (s, def) => { try { return JSON.parse(s); } catch { return def; } };
function parseBonding(text) {
const out = {};
for (const part of text.split(/^== /m).filter(Boolean)) {
const [name, ...lines] = part.split('\n');
const b = { slaves: [] };
let slave = null;
for (const l of lines) {
const [k, ...v] = l.split(':');
const val = v.join(':').trim();
if (k === 'Bonding Mode') b.mode = val;
else if (k === 'Transmit Hash Policy') b.hashPolicy = val.replace(/\s*\(\d+\)$/, '');
else if (k === 'LACP rate') b.lacpRate = val;
else if (k === 'MII Polling Interval (ms)') b.miimon = val;
else if (k === 'Slave Interface') { slave = { name: val }; b.slaves.push(slave); }
else if (slave && k === 'MII Status') slave.mii = val;
else if (slave && k === 'Speed') slave.speed = val;
else if (slave && k === 'Aggregator ID') slave.aggregator = val;
else if (!slave && k === 'MII Status') b.mii = val;
else if (k.trim() === 'Partner Mac Address' && !b.partnerMac) b.partnerMac = val;
}
out[name.trim()] = b;
}
return out;
}
class NetworkConfigManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, backend, blocks, netplan }
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Network session not found'));
return s;
}
// Shell-Skript als root ausführen (Skript base64-kodiert, damit kein Quoting-Problem entsteht)
root(s, script, conn = s.conn) {
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
if (!s.sudo) return execOn(conn, cmd);
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
}
async rootOk(s, script) {
const r = await this.root(s, script);
if (r.code) throw new Error(lastLine(r.err) || lastLine(r.out) || i18n.t('Command failed with code {code}', { code: r.code }));
return r.out;
}
async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id);
const s = { conn, jumps, host, sudo: false, onClose };
this.sessions.set(id, s);
this.watch(id, s);
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.'));
}
return this.read(id);
}
watch(id, s) {
const conn = s.conn;
// 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) {
const s = this.get(id);
const sec = sections(await this.rootOk(s, GATHER));
s.backend = sec.BACKEND || 'unknown';
const links = json(sec.LINK, []);
const addrs = json(sec.ADDR, []);
const bonding = parseBonding(sec.BONDING || '');
let config = [];
let note = '';
if (s.backend === 'netplan') {
const np = json(sec.NETPLAN, {});
if (np.error || !np.config) { note = i18n.t('netplan configuration could not be read ({err}).', { err: np.error || lastLine(sec.NETPLAN) }); s.netplan = null; }
else { s.netplan = np; config = nc.fromNetplan(np.config); }
} else if (s.backend === 'ifupdown') {
s.blocks = [];
for (const part of (sec.IFUPDOWN || '').split(/^==FILE /m).filter(Boolean)) {
const nl = part.indexOf('\n');
s.blocks.push(...nc.parseInterfaces(part.slice(nl + 1), part.slice(0, nl).trim()));
}
config = nc.fromIfupdown(s.blocks);
}
const interfaces = links.filter((l) => l.ifname !== 'lo').map((l) => {
const a = addrs.find((x) => x.ifname === l.ifname) || {};
return {
name: l.ifname, mac: l.address, mtu: l.mtu, state: l.operstate, master: l.master || '',
kind: l.linkinfo?.info_kind || (l.link_type === 'ether' ? 'ethernet' : l.link_type), slaveKind: l.linkinfo?.info_slave_kind || '',
vlanId: l.linkinfo?.info_data?.id, link: l.link || '',
addrs: (a.addr_info || []).filter((x) => x.scope !== 'link').map((x) => ({ addr: `${x.local}/${x.prefixlen}`, family: x.family, dynamic: !!x.dynamic })),
bonding: bonding[l.ifname] || null,
};
});
const resolv = sec.RESOLV || '';
return {
hostname: sec.HOSTNAME, os: sec.OS, backend: s.backend, editable: ['netplan', 'ifupdown'].includes(s.backend) && !note, note,
interfaces, config,
routes: [...json(sec.ROUTE, []), ...json(sec.ROUTE6, [])].map((r) => ({ via: r.gateway, dev: r.dev, metric: r.metric })),
dns: { servers: (resolv.match(/^nameserver\s+(\S+)/gm) || []).map((l) => l.split(/\s+/)[1]), search: ((resolv.match(/^search\s+(.+)$/m) || [])[1] || '').split(/\s+/).filter(Boolean), resolved: sec.RESOLVECTL || '', editable: sec.RESOLVTYPE === 'file' },
files: (sec.FILES || '').split('\n').filter(Boolean),
rolledBack: /\d/.test(sec.PENDING || '') ? sec.PENDING.split('\n').filter((x) => /\d/.test(x)).pop() : '',
};
}
// Schreibbefehle für eine Modelländerung erzeugen
plan(s, model, remove) {
const m = nc.validate(model, i18n.t);
if (s.backend === 'netplan') {
const cfg = nc.applyNetplan(s.netplan.config, m, { remove });
// Gesamte Konfiguration in eine Datei; bisherige Dateien werden deaktiviert (im Backup enthalten)
const others = s.netplan.files.filter((f) => f !== '/etc/netplan/90-mrterm.yaml');
return [
...others.map((f) => `mv '${f}' '${f}.mrterm-off'`),
`printf %s ${b64(`# Managed by MrTerm – previous files were renamed to *.yaml.mrterm-off\n${JSON.stringify(cfg, null, 2)}\n`)} | base64 -d > /etc/netplan/90-mrterm.yaml`,
'chmod 600 /etc/netplan/90-mrterm.yaml',
// cloud-init soll die Netzwerkkonfiguration beim nächsten Start nicht neu erzeugen
...(others.some((f) => /cloud-init/.test(f)) ? ["[ -d /etc/cloud/cloud.cfg.d ] && echo 'network: {config: disabled}' > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg || true"] : []),
];
}
if (s.backend === 'ifupdown') {
const { files } = nc.applyIfupdown(s.blocks, m, { remove });
return Object.entries(files).map(([f, content]) => {
if (!/^\/etc\/network\/interfaces(\.d\/[\w.-]+)?$/.test(f)) throw new Error('Invalid file');
return `printf %s ${b64(content)} | base64 -d > '${f}'`;
});
}
throw new Error(i18n.t('Editing is not supported for this network configuration ({backend}).', { backend: s.backend }));
}
// Änderung anwenden: Backup, schreiben, prüfen, im Hintergrund anwenden, Rollback-Wächter starten, neu verbinden und bestätigen
async apply(id, writes, verifyAddress, backendName) {
const s = this.get(id);
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 }));
const script = `export PATH=$PATH:/usr/sbin:/sbin
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
# Schreiben in einer Subshell: bei einem Fehler sofort den alten Stand wiederherstellen
if ! ( set -e
${writes.join('\n')}
) >${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
touch ${STATE}/pending
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`;
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) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
const deadline = Date.now() + (ROLLBACK_SECONDS - 12) * 1000;
await new Promise((res) => setTimeout(res, 6000));
const target = { ...s.host, address: verifyAddress || s.host.address };
while (Date.now() < deadline) {
try {
const { conn, jumps } = await this.ssh.connect(target, id);
// Erst bestätigen, wenn das Anwenden fertig ist: sonst reißt z. B. ein Bond-Neustart die neue Verbindung wieder ab
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
const old = { conn: s.conn, jumps: s.jumps };
Object.assign(s, { conn, jumps });
// Alte Verbindung ist nach dem IP-Wechsel meist tot: Fehler (z. B. Keepalive-Timeout) still verwerfen
for (const c of [old.conn, ...(old.jumps || [])]) { c.on('error', () => {}); try { c.end(); } catch {} }
if (verifyAddress) s.host = target;
this.watch(id, s);
return { confirmed: true };
} catch {
await new Promise((res) => setTimeout(res, 4000));
}
}
return { confirmed: false, rollbackSeconds: ROLLBACK_SECONDS };
}
saveInterface(id, model, verifyAddress) {
const s = this.get(id);
return this.apply(id, this.plan(s, model, false), verifyAddress);
}
removeInterface(id, model) {
const s = this.get(id);
return this.apply(id, this.plan(s, model, true));
}
async setHostname(id, name) {
const s = this.get(id);
if (!HOSTNAME.test(name)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'hostname', value: name }));
const old = (await this.rootOk(s, 'hostname')).trim();
const esc = (x) => x.replace(/\./g, '\\.');
const short = name.split('.')[0];
await this.rootOk(s, `export PATH=$PATH:/usr/sbin:/sbin
hostnamectl set-hostname '${name}' 2>/dev/null || { echo '${name}' > /etc/hostname; hostname '${name}'; }
${HOSTNAME.test(old) ? `sed -i -E 's/(^|[[:space:]])${esc(old)}([[:space:]]|$)/\\1${name}\\2/g; s/(^|[[:space:]])${esc(old.split('.')[0])}([[:space:]]|$)/\\1${short}\\2/g' /etc/hosts` : ''}
grep -qE '[[:space:]]${esc(short)}([[:space:]]|$)' /etc/hosts || echo '127.0.1.1 ${name}${name !== short ? ` ${short}` : ''}' >> /etc/hosts`);
return true;
}
async setResolv(id, servers, search) {
const s = this.get(id);
const ip = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-fA-F:]+)$/;
servers.forEach((x) => { if (!ip.test(x)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'DNS', value: x })); });
search.forEach((x) => { if (!/^[a-zA-Z0-9.-]+$/.test(x)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'search', value: x })); });
const content = `# Written by MrTerm\n${search.length ? `search ${search.join(' ')}\n` : ''}${servers.map((x) => `nameserver ${x}`).join('\n')}\n`;
await this.rootOk(s, `[ -L /etc/resolv.conf ] && exit 5; printf %s ${b64(content)} | base64 -d > /etc/resolv.conf`);
return true;
}
async readFile(id, path) {
const s = this.get(id);
if (!EDITABLE_PATH.test(path)) throw new Error('Invalid file');
return this.rootOk(s, `cat '${path}'`);
}
// Datei direkt bearbeiten; Netzwerkdateien mit Rollback, /etc/hosts und resolv.conf direkt
async writeFile(id, path, content, verifyAddress) {
const s = this.get(id);
if (!EDITABLE_PATH.test(path)) throw new Error('Invalid file');
const write = `printf %s ${b64(content)} | base64 -d > '${path}'`;
if (/^\/etc\/(hosts|resolv\.conf)$/.test(path)) { await this.rootOk(s, write); return { confirmed: true, direct: true }; }
const backend = path.startsWith('/etc/netplan/') ? 'netplan' : path.startsWith('/etc/network/') ? 'ifupdown' : 'networkd';
return this.apply(id, [write], verifyAddress, backend);
}
close(id) {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
try { s.conn.end(); } catch {}
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
}
}
module.exports = { NetworkConfigManager, parseBonding, sections };
+4 -3
View File
@@ -6,6 +6,7 @@ const { spawn } = require('child_process');
let koffi = null; let koffi = null;
try { koffi = require('koffi'); } catch { /* ohne koffi nur externe Fenster */ } try { koffi = require('koffi'); } catch { /* ohne koffi nur externe Fenster */ }
const i18n = require('../i18n');
// ------------------------------------------------------------------ Windows // ------------------------------------------------------------------ Windows
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes // Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
@@ -135,7 +136,7 @@ function x11() {
XSync: l.func('int XSync(void *d, int discard)'), XSync: l.func('int XSync(void *d, int discard)'),
}; };
x11.api.display = x11.api.XOpenDisplay(null); x11.api.display = x11.api.XOpenDisplay(null);
if (!x11.api.display) throw new Error('Keine Verbindung zum X-Server (MrTerm läuft nicht unter X11/XWayland).'); if (!x11.api.display) throw new Error(i18n.t('No connection to the X server (MrTerm is not running under X11/XWayland).'));
return x11.api; return x11.api;
} }
@@ -195,12 +196,12 @@ class LinuxEmbed {
} }
function embedSupported() { function embedSupported() {
if (!koffi) return { ok: false, reason: 'koffi nicht verfügbar' }; if (!koffi) return { ok: false, reason: i18n.t('koffi not available') };
if (process.platform === 'win32') return { ok: true }; if (process.platform === 'win32') return { ok: true };
if (process.platform === 'linux') { if (process.platform === 'linux') {
try { x11(); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; } try { x11(); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
} }
return { ok: false, reason: 'Plattform nicht unterstützt' }; return { ok: false, reason: i18n.t('Platform not supported') };
} }
function createEmbed(parentHandle) { function createEmbed(parentHandle) {
+2 -1
View File
@@ -4,6 +4,7 @@ const { spawn, execFile, execFileSync } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const i18n = require('../i18n');
function which(bin) { function which(bin) {
try { try {
@@ -100,7 +101,7 @@ function launch(host, settings) {
return { client: 'mstsc' }; return { client: 'mstsc' };
} }
const client = linuxClient(settings.rdpClientLinux); const client = linuxClient(settings.rdpClientLinux);
if (!client) throw new Error('Kein FreeRDP gefunden. Installieren mit: sudo pacman -S freerdp'); if (!client) throw new Error(i18n.t('No FreeRDP found. Install with: sudo pacman -S freerdp'));
const p = spawn(client, freerdpArgs(host), { detached: true, stdio: [host.password ? 'pipe' : 'ignore', 'ignore', 'pipe'] }); const p = spawn(client, freerdpArgs(host), { detached: true, stdio: [host.password ? 'pipe' : 'ignore', 'ignore', 'pipe'] });
if (host.password) { p.stdin.write(host.password + '\n'); p.stdin.end(); } if (host.password) { p.stdin.write(host.password + '\n'); p.stdin.end(); }
let err = ''; let err = '';
+31 -14
View File
@@ -5,10 +5,15 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const i18n = require('../i18n');
function defaultAgent() { function defaultAgent() {
if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK; if (process.env.SSH_AUTH_SOCK) return process.env.SSH_AUTH_SOCK;
if (process.platform === 'win32') return '\\\\.\\pipe\\openssh-ssh-agent'; if (process.platform === 'win32') {
// Nur verwenden, wenn der Windows-OpenSSH-Agent läuft (sonst schlägt die Agent-Anmeldung unnötig fehl)
const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
try { return fs.existsSync(pipe) ? pipe : undefined; } catch { return undefined; }
}
return undefined; return undefined;
} }
@@ -30,7 +35,7 @@ class SshManager {
const cfg = { const cfg = {
host: host.address, host: host.address,
port: Number(host.port) || 22, port: Number(host.port) || 22,
username: host.username || os.userInfo().username, username: host.username || (() => { try { return os.userInfo().username; } catch { return 'root'; } })(),
readyTimeout: 20000, readyTimeout: 20000,
keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000, keepaliveInterval: (this.store.get().settings.keepAlive || 0) * 1000,
tryKeyboard: true, tryKeyboard: true,
@@ -77,7 +82,7 @@ class SshManager {
let sock; let sock;
try { try {
for (const hop of [...chain, host]) { for (const hop of [...chain, host]) {
onStatus(`Verbinde mit ${hop.label || hop.address} (${hop.address}:${hop.port || 22}) …`); onStatus(i18n.t('Connecting to {name} ({addr}) …', { name: hop.label || hop.address, addr: `${hop.address}:${hop.port || 22}` }));
const conn = await this.openClient(hop, sessionId, sock); const conn = await this.openClient(hop, sessionId, sock);
opened.push(conn); opened.push(conn);
if (hop !== host) { if (hop !== host) {
@@ -104,25 +109,33 @@ class SshManager {
const answers = []; const answers = [];
for (const p of prompts) { for (const p of prompts) {
if (!p.echo && host.password && !askedPassword) { answers.push(host.password); askedPassword = true; continue; } if (!p.echo && host.password && !askedPassword) { answers.push(host.password); askedPassword = true; continue; }
const a = await this.askSecret(sessionId, { title: name || 'Anmeldung', prompt: p.prompt, echo: p.echo, host: host.label || host.address }); const a = await this.askSecret(sessionId, { title: name || i18n.t('Authentication'), prompt: p.prompt, echo: p.echo, host: host.label || host.address });
if (a == null) return finish([]); if (a == null) return finish([]);
answers.push(a); answers.push(a);
} }
finish(answers); finish(answers);
}); });
conn.once('ready', () => resolve(conn)); // ssh2 kann mehrere 'error'-Ereignisse senden (z. B. Anmeldefehler und danach Handshake-Timeout).
conn.once('error', async (err) => { // Ein dauerhafter Listener verhindert, dass ein späterer Fehler den Hauptprozess abstürzen lässt;
// Fehler nach dem Verbindungsaufbau melden die Sitzungen selbst (close/error).
let settled = false;
conn.on('error', () => {});
conn.once('ready', () => { settled = true; resolve(conn); });
conn.on('error', async (err) => {
if (settled) return;
settled = true;
try { conn.end(); } catch {}
// Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen // Kein Passwort hinterlegt und alle Methoden schlugen fehl -> nach Passwort fragen und neu versuchen
if (err.level === 'client-authentication' && !host.password && !host._retried) { if (err.level === 'client-authentication' && !host.password && !host._retried) {
const pw = await this.askSecret(sessionId, { title: 'Passwort', prompt: `Passwort für ${cfg.username}@${host.address}`, echo: false, host: host.label || host.address }); const pw = await this.askSecret(sessionId, { title: i18n.t('Password'), prompt: i18n.t('Password for {user}', { user: `${cfg.username}@${host.address}` }), echo: false, host: host.label || host.address });
if (pw != null) { if (pw != null) {
this.openClient({ ...host, password: pw, _retried: true }, sessionId, sock).then(resolve, reject); this.openClient({ ...host, password: pw, _retried: true }, sessionId, sock).then(resolve, reject);
return; return;
} }
} }
reject(err); reject(err.level === 'client-authentication' ? new Error(i18n.t('Authentication failed for {user}. Check the username, password or key.', { user: `${cfg.username}@${host.address}` })) : err);
}); });
try { conn.connect(cfg); } catch (e) { reject(e); } try { conn.connect(cfg); } catch (e) { settled = true; reject(e); }
}); });
} }
@@ -136,15 +149,19 @@ class SshManager {
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined; const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
await new Promise((res, rej) => { await new Promise((res, rej) => {
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => { const pty = { term: 'xterm-256color', cols, rows };
const onStream = (err, stream) => {
if (err) return rej(err); if (err) return rej(err);
sess.stream = stream; sess.stream = stream;
stream.on('data', (d) => send('data', d.toString('utf8'))); stream.on('data', (d) => send('data', d.toString('utf8')));
stream.stderr.on('data', (d) => send('data', d.toString('utf8'))); stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
stream.on('close', () => conn.end()); stream.on('close', () => conn.end());
if (host.startupCommand) stream.write(host.startupCommand + '\n'); if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
res(); res();
}); };
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
else conn.shell(pty, { env }, onStream);
}); });
} }
@@ -178,7 +195,7 @@ class SshManager {
sftpOf(id) { sftpOf(id) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s?.sftp) throw new Error('SFTP-Sitzung nicht gefunden'); if (!s?.sftp) throw new Error(i18n.t('SFTP session not found'));
return s.sftp; return s.sftp;
} }
@@ -250,7 +267,7 @@ class SshManager {
// ---------- Port-Forwarding ---------- // ---------- Port-Forwarding ----------
async startForward(fw, onEvent) { async startForward(fw, onEvent) {
const host = this.store.resolveHost(fw.hostId); const host = this.store.resolveHost(fw.hostId);
if (!host) throw new Error('Host nicht gefunden'); if (!host) throw new Error(i18n.t('Host not found'));
const { conn, jumps } = await this.connect(host, 'fw-' + fw.id); const { conn, jumps } = await this.connect(host, 'fw-' + fw.id);
const entry = { conn, jumps }; const entry = { conn, jumps };
this.forwards.set(fw.id, entry); this.forwards.set(fw.id, entry);
+91 -10
View File
@@ -1,6 +1,7 @@
// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings. // Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings.
// Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt. // 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 fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
@@ -12,8 +13,13 @@ const DEFAULTS = {
keys: [], keys: [],
snippets: [], snippets: [],
forwards: [], forwards: [],
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',
@@ -31,22 +37,72 @@ const DEFAULTS = {
updateToken: '', updateToken: '',
updateAutoCheck: true, updateAutoCheck: true,
updatePrerelease: false, updatePrerelease: false,
autoLock: 0,
language: 'auto',
}, },
}; };
const merge = (parsed) => ({ ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } });
// AES-256-GCM; Ergebnis als base64-Felder für JSON
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()]);
}
class Store { class Store {
constructor() { // opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage
this.dir = app.getPath('userData'); 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.file = path.join(this.dir, 'vault.dat');
this.data = structuredClone(DEFAULTS); this.data = structuredClone(DEFAULTS);
this.encrypted = false; this.encrypted = false;
// App-Sperre: Inhalt zusätzlich mit zufälligem Datenschlüssel (DEK, AES-256-GCM) verschlüsselt.
// Der DEK liegt je Entsperrmethode verpackt vor: Passwort (scrypt) und/oder FIDO2-Schlüssel (PRF/hmac-secret).
this.lock = null; // { password: { salt, N, wrap } | null, fido: [{ id, label, credId, prfSalt, wrap }] }
this.dek = null;
this.sealed = null; // verschlüsselter Inhalt, solange nach dem Start noch nicht entsperrt
this.locked = false;
}
get lockEnabled() { return !!(this.lock && (this.lock.password || this.lock.fido.length)); }
// Entsperren mit einem Schlüssel, der den DEK verpackt hat (wirft bei falschem Schlüssel)
unlockWith(kek, wrap) {
const dek = unbox(kek, wrap);
if (this.sealed) {
const parsed = JSON.parse(unbox(dek, this.sealed).toString('utf8'));
this.data = merge(parsed);
this.sealed = null;
}
this.dek = dek;
this.locked = false;
}
// Neue Entsperrmethode: verpackt den (ggf. neu erzeugten) DEK mit kek
wrapDek(kek) {
if (!this.dek) this.dek = crypto.randomBytes(32);
return box(kek, this.dek);
}
// Letzte Methode entfernt → Sperre aus, Inhalt wieder nur per Betriebssystem verschlüsselt
dropLockIfEmpty() {
if (!this.lockEnabled) { this.lock = null; this.dek = null; }
} }
canEncrypt() { canEncrypt() {
try { 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. // 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; return true;
} catch { return false; } } catch { return false; }
} }
@@ -57,21 +113,33 @@ class Store {
const raw = fs.readFileSync(this.file); const raw = fs.readFileSync(this.file);
let json; let json;
if (raw.slice(0, 4).toString() === 'ENC1') { if (raw.slice(0, 4).toString() === 'ENC1') {
json = safeStorage.decryptString(raw.slice(4)); json = this.crypto.decryptString(raw.slice(4));
this.encrypted = true; this.encrypted = true;
} else { } else {
json = raw.toString('utf8'); json = raw.toString('utf8');
} }
const parsed = JSON.parse(json); const parsed = JSON.parse(json);
this.data = { ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } }; if (parsed.mrtermLock) {
// Gesperrt: nur Darstellungs-Einstellungen (meta) sind bis zum Entsperren bekannt
this.lock = parsed.lock;
this.sealed = parsed.data;
this.locked = true;
this.data = merge({ settings: parsed.meta || {} });
} else this.data = merge(parsed);
return this.data; return this.data;
} }
save() { save() {
const json = JSON.stringify(this.data, null, 2); if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
if (!this.muted) this.onChange?.();
let json = JSON.stringify(this.data, null, 2);
if (this.lockEnabled && this.dek) {
const { language, appTheme, accent } = this.data.settings;
json = JSON.stringify({ mrtermLock: 1, meta: { language, appTheme, accent }, lock: this.lock, data: box(this.dek, Buffer.from(json)) });
}
const tmp = this.file + '.tmp'; const tmp = this.file + '.tmp';
if (this.canEncrypt()) { 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; this.encrypted = true;
} else { } else {
fs.writeFileSync(tmp, json, { mode: 0o600 }); fs.writeFileSync(tmp, json, { mode: 0o600 });
@@ -96,7 +164,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();
} }
+504
View File
@@ -0,0 +1,504 @@
// 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 = process.env.MRTERM_DEVICE || 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']);
let ifaces = {};
try { ifaces = os.networkInterfaces(); } catch {} // Android: je nach Version eingeschränkt
for (const list of Object.values(ifaces)) {
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 };
+60 -12
View File
@@ -5,13 +5,60 @@ const { spawn } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const i18n = require('../i18n');
const DEFAULT_REPO = 'https://git.mrblake.cc/MrBlake/MrTerm'; // Startet ein Programm und liefert den Exit-Code ('ENOENT', falls es nicht existiert).
// Electron/Chromium setzt unter Linux PR_SET_NO_NEW_PRIVS, das alle Kindprozesse erben – dann
// funktionieren weder pkexec noch sudo. Deshalb über systemd-run --user starten: Elternprozess ist
// dann der systemd-User-Manager, ohne diesen Schalter.
const GUI_ENV = ['DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS'];
function spawnCode(cmd, args) {
return new Promise((resolve) => {
const p = spawn(cmd, args, { stdio: 'ignore' });
p.on('error', (e) => resolve(e.code === 'ENOENT' ? 'ENOENT' : e.message));
p.on('exit', (c) => resolve(c));
});
}
let hasSystemdRun;
async function run(cmd, args) {
if (hasSystemdRun === undefined) hasSystemdRun = (await spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', 'true'])) === 0;
if (!hasSystemdRun) return spawnCode(cmd, args);
const inPath = cmd.includes('/') ? fs.existsSync(cmd) : (process.env.PATH || '').split(':').some((d) => d && fs.existsSync(path.join(d, cmd)));
if (!inPath) return 'ENOENT';
const env = GUI_ENV.filter((k) => process.env[k]).flatMap((k) => ['-E', `${k}=${process.env[k]}`]);
return spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', ...env, '--', cmd, ...args]);
}
// Fallback ohne Polkit-Agent: pacman -U per sudo in einem Terminalfenster; wartet, bis das Fenster geschlossen ist.
// Der Erfolg wird über eine Marker-Datei erkannt, da nicht jedes Terminal den Exit-Code durchreicht.
async function installInTerminal(file) {
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
const marker = file + '.ok';
try { fs.unlinkSync(marker); } catch {}
const script = `sudo /usr/bin/pacman -U --noconfirm ${q(file)} && touch ${q(marker)} || { echo; read -rp "[Enter]"; }`;
const terms = [
['konsole', ['--nofork', '-e', 'bash', '-c', script]],
['gnome-terminal', ['--wait', '--', 'bash', '-c', script]],
['kitty', ['bash', '-c', script]],
['alacritty', ['-e', 'bash', '-c', script]],
['foot', ['bash', '-c', script]],
['xfce4-terminal', ['--disable-server', '-x', 'bash', '-c', script]],
['xterm', ['-e', 'bash', '-c', script]],
];
for (const [cmd, args] of terms) {
const c = await run(cmd, args);
if (c === 'ENOENT') continue;
return fs.existsSync(marker) ? 0 : c || 1;
}
return 'ENOENT';
}
const DEFAULT_REPO ='https://git.mrblake.cc/MrBlake/MrTerm';
function parseRepo(url) { function parseRepo(url) {
const u = new URL((url || DEFAULT_REPO).replace(/\.git$/, '').replace(/\/+$/, '')); const u = new URL((url || DEFAULT_REPO).replace(/\.git$/, '').replace(/\/+$/, ''));
const [owner, repo] = u.pathname.split('/').filter(Boolean); const [owner, repo] = u.pathname.split('/').filter(Boolean);
if (!owner || !repo) throw new Error('Ungültige Repository-URL: ' + url); if (!owner || !repo) throw new Error(i18n.t('Invalid repository URL: {url}', { url }));
return { base: u.origin, owner, repo }; return { base: u.origin, owner, repo };
} }
@@ -59,8 +106,8 @@ class Updater {
async check() { async check() {
const { base, owner, repo } = parseRepo(this.store.get().settings.updateUrl); const { base, owner, repo } = parseRepo(this.store.get().settings.updateUrl);
const res = await fetch(`${base}/api/v1/repos/${owner}/${repo}/releases?limit=20`, { headers: this.headers() }); const res = await fetch(`${base}/api/v1/repos/${owner}/${repo}/releases?limit=20`, { headers: this.headers() });
if (res.status === 404) throw new Error('Repository nicht gefunden (privat? Dann Zugriffstoken in den Einstellungen hinterlegen).'); if (res.status === 404) throw new Error(i18n.t('Repository not found (private? Then add an access token in the settings).'));
if (!res.ok) throw new Error(`Update-Server antwortet mit ${res.status}`); if (!res.ok) throw new Error(i18n.t('Update server responded with {status}', { status: res.status }));
const allowPre = this.store.get().settings.updatePrerelease; const allowPre = this.store.get().settings.updatePrerelease;
const rel = (await res.json()).find((r) => !r.draft && (allowPre || !r.prerelease)); const rel = (await res.json()).find((r) => !r.draft && (allowPre || !r.prerelease));
const current = app.getVersion(); const current = app.getVersion();
@@ -90,12 +137,12 @@ class Updater {
async download() { async download() {
if (!this.latest) await this.check(); if (!this.latest) await this.check();
const { asset } = this.latest; const { asset } = this.latest;
if (!asset) throw new Error('Kein passendes Paket für dieses System im Release gefunden.'); if (!asset) throw new Error(i18n.t('No matching package for this system found in the release.'));
const dir = path.join(os.tmpdir(), 'mrterm-update'); const dir = path.join(os.tmpdir(), 'mrterm-update');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, asset.name); const file = path.join(dir, asset.name);
const res = await fetch(asset.browser_download_url, { headers: { ...this.headers(), Accept: 'application/octet-stream' } }); const res = await fetch(asset.browser_download_url, { headers: { ...this.headers(), Accept: 'application/octet-stream' } });
if (!res.ok) throw new Error(`Download fehlgeschlagen (${res.status})`); if (!res.ok) throw new Error(i18n.t('Download failed ({status})', { status: res.status }));
const total = Number(res.headers.get('content-length')) || asset.size || 0; const total = Number(res.headers.get('content-length')) || asset.size || 0;
const out = fs.createWriteStream(file + '.part'); const out = fs.createWriteStream(file + '.part');
let done = 0, last = 0; let done = 0, last = 0;
@@ -133,15 +180,16 @@ class Updater {
app.relaunch({ execPath: target }); app.relaunch({ execPath: target });
app.exit(0); app.exit(0);
} else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) { } else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) {
await new Promise((resolve, reject) => { // 1. pkexec (grafische Passwortabfrage über den Polkit-Agent)
const p = spawn('pkexec', ['pacman', '-U', '--noconfirm', file], { stdio: 'ignore' }); let code = await run('pkexec', ['/usr/bin/pacman', '-U', '--noconfirm', file]);
p.on('error', reject); // 126/127 = Abfrage abgebrochen bzw. kein Polkit-Agent → Terminal mit sudo öffnen
p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(`pacman beendet mit Code ${c}`)))); if (code === 126 || code === 127 || code === 'ENOENT') code = await installInTerminal(file);
}); if (code === 'ENOENT') throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
if (code !== 0) throw new Error(i18n.t('pacman exited with code {code}', { code }));
app.relaunch(); app.relaunch();
app.exit(0); app.exit(0);
} else { } else {
throw new Error('Automatische Installation hier nicht möglich. Datei liegt unter: ' + file); throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
} }
} }
} }
+183
View File
@@ -0,0 +1,183 @@
// VPN-Verbindungen (WireGuard / OpenVPN), die vor dem Verbinden zu zugeordneten Hosts automatisch aufgebaut werden.
// Linux: NetworkManager (nmcli) – kein root nötig. MrTerm legt pro VPN eine Verbindung "mrterm-<id>" an
// (autoconnect aus). OpenVPN braucht das Plugin networkmanager-openvpn.
// Windows: WireGuard als Tunnel-Dienst über wireguard.exe (UAC-Abfrage), OpenVPN über die OpenVPN GUI.
const { execFile, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const i18n = require('../i18n');
function exec(cmd, args) {
return new Promise((resolve) => {
execFile(cmd, args, { windowsHide: true, timeout: 90000 }, (err, stdout, stderr) => {
resolve({ code: err ? (typeof err.code === 'number' ? err.code : err.code === 'ENOENT' ? 'ENOENT' : 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || err?.message || '') });
});
});
}
const lastLine = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class VpnManager {
constructor(store, userDir) {
this.store = store;
this.dir = path.join(userDir, 'vpn');
this.busy = new Map(); // id -> laufendes up() (parallele Verbindungen zum selben VPN nur einmal aufbauen)
this.started = new Set(); // von MrTerm aufgebaute VPNs
}
get(id) { return this.store.get().vpns.find((v) => v.id === id); }
nmName(v) { return 'mrterm-' + v.id.slice(0, 8); }
// Interface-/Tunnelname: max. 15 Zeichen (Linux), nur [a-z0-9]
ifName(v) { return 'mt' + v.id.replace(/-/g, '').slice(0, 10); }
// ---------------------------------------------------------------- Linux (NetworkManager)
async nm(args) {
const r = await exec('nmcli', args);
if (r.code === 'ENOENT') throw new Error(i18n.t('NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.'));
return r;
}
async nmExists(v) {
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show']);
return r.stdout.split('\n').includes(this.nmName(v));
}
async nmImport(v) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mrterm-vpn-'));
const file = path.join(tmp, this.ifName(v) + (v.type === 'openvpn' ? '.ovpn' : '.conf'));
try {
fs.writeFileSync(file, v.config || '', { mode: 0o600 });
const r = await this.nm(['connection', 'import', 'type', v.type === 'openvpn' ? 'openvpn' : 'wireguard', 'file', file]);
if (r.code) {
const err = lastLine(r.stderr);
if (v.type === 'openvpn' && /plugin|openvpn/i.test(err)) throw new Error(i18n.t('OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn'));
throw new Error(i18n.t('VPN configuration could not be imported: {err}', { err }));
}
const uuid = (r.stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0];
const mod = ['connection', 'modify', uuid || this.ifName(v), 'connection.id', this.nmName(v), 'connection.autoconnect', 'no'];
if (v.type === 'openvpn' && v.username) mod.push('+vpn.data', `username=${v.username}`);
if (v.type === 'openvpn' && v.password) mod.push('+vpn.data', 'password-flags=0', 'vpn.secrets', `password=${v.password}`);
const m = await this.nm(mod);
if (m.code) throw new Error(lastLine(m.stderr));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
// ---------------------------------------------------------------- Windows
get wgExe() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'WireGuard', 'wireguard.exe'); }
get ovpnGui() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'OpenVPN', 'bin', 'openvpn-gui.exe'); }
// Programm mit Administratorrechten starten (UAC) und warten
async elevate(exe, args) {
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
const cmd = `$p = Start-Process -FilePath ${q(exe)} -ArgumentList @(${args.map((a) => q(`"${a}"`)).join(',')}) -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode`;
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', cmd]);
if (r.code) throw new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: path.basename(exe), err: lastLine(r.stderr) || r.code }));
}
winOvpnName(v) { return this.ifName(v) + '.ovpn'; }
async winOpenvpnUp() {
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
"@(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -match 'TAP-Windows|Wintun|OpenVPN|ovpn-dco' }).Count"]);
return Number(r.stdout.trim()) > 0;
}
// ---------------------------------------------------------------- Gemeinsame API
async isUp(v) {
if (process.platform === 'win32') {
if (v.type === 'openvpn') return this.winOpenvpnUp();
const r = await exec('sc', ['query', `WireGuardTunnel$${this.ifName(v)}`]);
return /RUNNING/.test(r.stdout);
}
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show', '--active']);
return r.stdout.split('\n').includes(this.nmName(v));
}
async status() {
const out = {};
for (const v of this.store.get().vpns) { try { out[v.id] = await this.isUp(v); } catch { out[v.id] = false; } }
return out;
}
up(id) {
if (this.busy.has(id)) return this.busy.get(id);
const p = this._up(id).finally(() => this.busy.delete(id));
this.busy.set(id, p);
return p;
}
async _up(id) {
const v = this.get(id);
if (!v) throw new Error(i18n.t('VPN not found'));
if (await this.isUp(v)) return;
const fail = (err) => new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: v.label, err }));
if (process.platform === 'win32') {
fs.mkdirSync(this.dir, { recursive: true });
if (v.type === 'openvpn') {
if (!fs.existsSync(this.ovpnGui)) throw new Error(i18n.t('OpenVPN GUI not found. Install OpenVPN from openvpn.net.'));
const dir = path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, this.winOvpnName(v)), v.config || '');
spawn(this.ovpnGui, ['--connect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
} else {
if (!fs.existsSync(this.wgExe)) throw new Error(i18n.t('WireGuard for Windows not found. Install it from wireguard.com.'));
const file = path.join(this.dir, this.ifName(v) + '.conf');
fs.writeFileSync(file, v.config || '');
await this.elevate(this.wgExe, ['/installtunnelservice', file]);
}
for (let i = 0; i < 60; i++) { if (await this.isUp(v)) { this.started.add(id); return; } await sleep(500); }
throw fail(i18n.t('timeout'));
}
if (!(await this.nmExists(v))) await this.nmImport(v);
const r = await this.nm(['--wait', '45', 'connection', 'up', 'id', this.nmName(v)]);
if (r.code) throw fail(lastLine(r.stderr));
this.started.add(id);
}
// Beim Beenden: von MrTerm aufgebaute VPNs mit Option "beim Beenden trennen" wieder abbauen
async downOnQuit() {
for (const id of this.started) {
const v = this.get(id);
if (v && v.disconnectOnQuit !== false) await this.down(id).catch(() => {});
}
}
async down(id) {
const v = this.get(id);
if (!v) return;
if (process.platform === 'win32') {
if (v.type === 'openvpn') spawn(this.ovpnGui, ['--command', 'disconnect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
else if (await this.isUp(v)) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
return;
}
await this.nm(['connection', 'down', 'id', this.nmName(v)]);
}
// Nach Änderung/Löschen: im System hinterlegte Verbindung entfernen, beim nächsten Verbinden wird neu importiert
async forget(v) {
if (!v) return;
try {
if (process.platform === 'win32') {
if (v.type !== 'openvpn' && (await this.isUp(v))) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
fs.rmSync(path.join(this.dir, this.ifName(v) + '.conf'), { force: true });
fs.rmSync(path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v)), { recursive: true, force: true });
} else if (await this.nmExists(v)) await this.nm(['connection', 'delete', 'id', this.nmName(v)]);
} catch { /* VPN-Werkzeuge fehlen – nichts aufzuräumen */ }
}
// Alle VPNs eines Hosts (inkl. Jump-Host-Kette) aufbauen, bevor verbunden wird
async ensureForHost(host, onStatus = () => {}) {
const ids = [];
for (let h = host, n = 0; h && n < 10; h = h.jumpHostId ? this.store.resolveHost(h.jumpHostId) : null, n++) {
if (h.vpnId && !ids.includes(h.vpnId)) ids.push(h.vpnId);
}
for (const id of ids.reverse()) {
const v = this.get(id);
if (!v) continue;
if (await this.isUp(v)) continue;
onStatus(i18n.t('Connecting VPN “{name}” …', { name: v.label }));
await this.up(id);
}
return ids.length > 0;
}
}
module.exports = { VpnManager };
+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),
}); });
+110
View File
@@ -0,0 +1,110 @@
/* App-Designs: gemeinsame Farbvariablen für Desktop (src/renderer) und Android-App (mobile/web) */
:root {
--bg: #13151c;
--bg-2: #181b24;
--panel: #1c1f2a;
--card: #212533;
--card-hover: #282d3d;
--border: #2b3040;
--text: #e6e8ef;
--muted: #8b91a5;
--faint: #5d6377;
--accent: #6e7bff;
--accent-2: #8b95ff;
--green: #3ecf8e;
--red: #ff5f6d;
--orange: #ffb454;
--accent-3: #b36bff;
--chrome: #0f1117;
--chrome-line: #1f2330;
--hover: #1a1d27;
--nav-active: #1f2233;
--icon-bg: #2a2f45;
--folder: #6ea8ff;
--row-line: #1c1f29;
--overlay: #13151ce6;
--tint: 255 255 255;
--radius: 10px;
--on-accent: #ffffff;
--font: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
}
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
[data-theme='navy'] {
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
}
[data-theme='nord'] {
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
}
[data-theme='dracula'] {
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
}
[data-theme='mocha'] {
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
}
[data-theme='forest'] {
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
}
[data-theme='light'] {
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
--tint: 0 0 0;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* Windows XP (Luna) */
[data-theme='xp'] {
--bg: #ece9d8; --bg-2: #e3dfcb; --panel: #ffffff; --card: #ffffff; --card-hover: #f1efe2; --border: #aca899;
--text: #000000; --muted: #4a4a4a; --faint: #7f7f7f; --accent: #316ac5; --accent-3: #3a9a3a; --green: #2b8a2b; --red: #c81e1e; --orange: #d87400;
--chrome: #d6dff7; --chrome-line: #7a96df; --hover: #e8eefc; --nav-active: #c1d2ee; --icon-bg: #dde7f8; --folder: #e0a92a; --row-line: #ece9d8; --overlay: #ece9d8e6;
--tint: 0 0 0; --radius: 3px; --font: Tahoma, Verdana, 'Segoe UI', sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* Windows 11 (hell, Mica) */
[data-theme='win11'] {
--bg: #f3f3f3; --bg-2: #eeeeee; --panel: #fbfbfb; --card: #ffffff; --card-hover: #f6f6f6; --border: #e5e5e5;
--text: #1b1b1b; --muted: #5f5f5f; --faint: #8a8a8a; --accent: #0067c0; --accent-3: #8764b8; --green: #0f7b0f; --red: #c42b1c; --orange: #9d5d00;
--chrome: #ebebeb; --chrome-line: #e0e0e0; --hover: #eaeaea; --nav-active: #e0e0e0; --icon-bg: #e6eef8; --folder: #e8a33d; --row-line: #efefef; --overlay: #f3f3f3e6;
--tint: 0 0 0; --radius: 8px; --font: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
/* macOS 26 „Liquid Glass“: milchige, durchscheinende Flächen über einem farbigen Hintergrund */
[data-theme='macos'] {
--bg: rgba(255, 255, 255, .34); --bg-2: rgba(255, 255, 255, .28); --panel: rgba(255, 255, 255, .55); --card: rgba(255, 255, 255, .48);
--card-hover: rgba(255, 255, 255, .68); --border: rgba(255, 255, 255, .7);
--text: #1d1d1f; --muted: #55555c; --faint: #8a8a92; --accent: #007aff; --accent-3: #af52de; --green: #248a3d; --red: #e0352b; --orange: #c96b00;
--chrome: rgba(255, 255, 255, .3); --chrome-line: rgba(255, 255, 255, .55); --hover: rgba(255, 255, 255, .45); --nav-active: rgba(255, 255, 255, .72);
--icon-bg: rgba(0, 122, 255, .12); --folder: #1a8cff; --row-line: rgba(255, 255, 255, .5); --overlay: rgba(240, 242, 250, .55);
--tint: 0 0 0; --radius: 14px; --font: -apple-system, 'SF Pro Text', 'Helvetica Neue', 'Inter', system-ui, sans-serif;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
--glass-blur: blur(26px) saturate(185%);
--glass-edge: inset 0 1px 0 rgba(255, 255, 255, .95), inset 0 -1px 0 rgba(255, 255, 255, .3), 0 10px 30px rgba(30, 40, 80, .10);
}
/* Hintergrund, durch den das Glas schimmert */
[data-theme='macos'] {
--wallpaper:
radial-gradient(at 12% 18%, #9cc2ff 0, transparent 46%),
radial-gradient(at 88% 12%, #f7b6dd 0, transparent 42%),
radial-gradient(at 72% 88%, #98e2d2 0, transparent 46%),
radial-gradient(at 18% 92%, #ffd6a0 0, transparent 42%),
#eef1f8;
}
html[data-theme='macos'], html[data-theme='macos'] body { background: var(--wallpaper); background-attachment: fixed; }
/* Cyberpunk 2077 */
[data-theme='cyberpunk'] {
--bg: #0a0a0f; --bg-2: #0e0e15; --panel: #111119; --card: #15151f; --card-hover: #1e1c10; --border: #3d3a14;
--text: #eafcff; --muted: #8fb8c8; --faint: #5a7680; --accent: #fcee0a; --accent-3: #00f0ff; --green: #00ff9f; --red: #ff003c; --orange: #ff9e00;
--chrome: #07070b; --chrome-line: #2a2808; --hover: #17161b; --nav-active: #232008; --icon-bg: #26240b; --folder: #00f0ff; --row-line: #17161f; --overlay: #0a0a0fe6;
--radius: 2px; --on-accent: #0a0a0f; --font: 'Rajdhani', 'Orbitron', 'Bahnschrift', 'DIN Alternate', 'Segoe UI', system-ui, sans-serif;
--accent-2: #fff45c;
}
+1351 -261
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,10 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" />
<title>MrTerm</title> <title>MrTerm</title>
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" /> <link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css" />
<link rel="stylesheet" href="app-themes.css" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
@@ -16,32 +17,34 @@
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span> <svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
</button> </button>
</div> </div>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Strg+Shift+K)">+</button> <button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
<div class="drag-fill"></div> <div class="drag-fill"></div>
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
<div class="win-ctrls"> <div class="win-ctrls">
<button id="winMin" title="Minimieren"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button> <button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
<button id="winMax" title="Maximieren"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button> <button id="winMax" title="Maximize" data-i18n-title="Maximize"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button>
<button id="winClose" class="close" title="Schließen"><svg viewBox="0 0 12 12"><path d="M2 2l8 8M10 2l-8 8"/></svg></button> <button id="winClose" class="close" title="Close" data-i18n-title="Close"><svg viewBox="0 0 12 12"><path d="M2 2l8 8M10 2l-8 8"/></svg></button>
</div> </div>
</header> </header>
<div id="body"> <div id="body">
<nav id="sidebar"> <nav id="sidebar">
<div id="navHead" class="tree-head nav-head"><span>Menü</span><button id="navToggle" title="Menü einklappen"><svg viewBox="0 0 24 24"><path d="M6 15l6-6 6 6"/></svg></button></div> <div id="navHead" class="tree-head nav-head"><span data-i18n="Menu">Menu</span><button id="navToggle" title="Collapse menu"><svg viewBox="0 0 24 24"><path d="M6 15l6-6 6 6"/></svg></button></div>
<div id="navMenu" class="nav-menu"> <div id="navMenu" class="nav-menu">
<button class="nav active" data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg><span>Hosts</span></button> <button class="nav active" data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg><span>Hosts</span></button>
<button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button> <button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button> <button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button> <button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
<button class="nav" data-view="vpns"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12h6M12 9v6"/></svg><span>VPN</span></button>
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button> <button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button> <button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>Verlauf</span></button> <button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</span></button>
</div> </div>
<div class="nav-sep"></div> <div class="nav-sep"></div>
<div id="tree" class="tree"></div> <div id="tree" class="tree"></div>
<button id="updateBadge" class="nav update-badge" style="display:none"><svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg><span>Update</span></button> <button id="updateBadge" class="nav update-badge" style="display:none"><svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg><span>Update</span></button>
<div id="vaultBadge" class="vault-badge"></div> <div id="vaultBadge" class="vault-badge"></div>
<button class="nav" data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Einstellungen</span></button> <button class="nav" data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span data-i18n="Settings">Settings</span></button>
</nav> </nav>
<main id="main"> <main id="main">
@@ -60,6 +63,7 @@
<script src="../../node_modules/@xterm/addon-fit/lib/addon-fit.js"></script> <script src="../../node_modules/@xterm/addon-fit/lib/addon-fit.js"></script>
<script src="../../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script> <script src="../../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
<script src="../../node_modules/@xterm/addon-search/lib/addon-search.js"></script> <script src="../../node_modules/@xterm/addon-search/lib/addon-search.js"></script>
<script src="../i18n.js"></script>
<script src="themes.js"></script> <script src="themes.js"></script>
<script src="rdm-import.js"></script> <script src="rdm-import.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

Some files were not shown because too many files have changed in this diff Show More