Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a608948823 | ||
|
|
70a15eddbc | ||
|
|
039d89b9f7 | ||
|
|
b38837d4e0 | ||
|
|
b716ce3415 | ||
|
|
b33af709aa | ||
|
|
618f5ad010 | ||
|
|
29fef7cb33 | ||
|
|
74ce2c20be | ||
|
|
e8fefe4ebe | ||
|
|
797722c510 | ||
|
|
c589033964 | ||
|
|
0cefd5a21f | ||
|
|
d53bd1a4c1 | ||
|
|
2890844c81 |
@@ -1,51 +1,133 @@
|
|||||||
# 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
|
MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
|
||||||
- **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
|
## Download & installation
|
||||||
- **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
|
Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
|
||||||
- **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
|
**Windows**
|
||||||
- **Port-Forwarding**: lokal (-L), remote (-R), dynamisch/SOCKS5 (-D)
|
- `MrTerm-Setup-<version>.exe`: installer (recommended)
|
||||||
- **Snippets**, **Known Hosts** (TOFU, Warnung bei geändertem Schlüssel), **Verlauf**, `~/.ssh/config`-Import, Backup Export/Import
|
- `MrTerm-<version>-portable.exe`: portable, runs without installation
|
||||||
- **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“
|
**Arch Linux / CachyOS**
|
||||||
- **Auto-Update** aus den Gitea-Releases von https://git.mrblake.cc/MrBlake/MrTerm (Setup-EXE, Portable, AppImage, pacman)
|
- `MrTerm-<version>.pacman`: install with
|
||||||
- **Vault verschlüsselt** über das OS (Windows DPAPI, Linux libsecret/KWallet)
|
```sh
|
||||||
- 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`
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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:
|
**Publishing a release**: `./scripts/release-all.sh` asks for the version and release notes, commits and pushes the version, builds Windows + Arch/CachyOS and uploads everything to Gitea. It reads the token from `GITEA_TOKEN` or from `.gitea-token`, which is not committed.
|
||||||
|
|
||||||
```sh
|
**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`.
|
||||||
sudo chown -R $USER: node_modules
|
|
||||||
npm run dist:linux
|
|
||||||
```
|
|
||||||
|
|
||||||
npm und die Builds danach nicht mehr mit `sudo` ausführen – nur `pacman -U` zum Installieren braucht root.
|
To regenerate the icons after changing the logo, run `npx electron scripts/make-icons.js`.
|
||||||
|
|
||||||
## Release veröffentlichen (für Auto-Update)
|
|
||||||
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).
|
|
||||||
|
|
||||||
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`
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.2.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.2.0",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.3.0",
|
"version": "0.7.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ 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
|
||||||
|
|||||||
+464
@@ -0,0 +1,464 @@
|
|||||||
|
// Ü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 & 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 & 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.',
|
||||||
|
|
||||||
|
// 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}',
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// 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).
|
||||||
|
const { BrowserWindow, session } = require('electron');
|
||||||
|
const i18n = require('../i18n');
|
||||||
|
|
||||||
|
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}
|
||||||
|
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></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 ceremony(parent, text, script) {
|
||||||
|
const w = new BrowserWindow({
|
||||||
|
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
||||||
|
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await w.loadURL('http://localhost/');
|
||||||
|
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||||
|
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||||
|
w.show();
|
||||||
|
w.focus();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||||
|
async function register(parent) {
|
||||||
|
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 };
|
||||||
+152
-23
@@ -9,6 +9,11 @@ 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 applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
@@ -34,12 +39,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 +61,8 @@ 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);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -86,9 +93,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,12 +110,111 @@ 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(); }
|
||||||
|
|
||||||
|
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 }));
|
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
||||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
handle('vault:upsert', async (col, item) => {
|
||||||
handle('vault:remove', (col, id) => store.remove(col, id));
|
// 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; });
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
|
||||||
|
// ---------- 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) => { delete store.get().knownHosts[id]; store.save(); });
|
||||||
@@ -125,7 +236,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 +299,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 +324,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 +362,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 +373,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 +389,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 +442,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 +486,19 @@ 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) {
|
scheduleUpdateCheck();
|
||||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
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(); 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) {
|
||||||
|
|||||||
@@ -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
@@ -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 = '';
|
||||||
|
|||||||
+13
-8
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -77,7 +78,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,7 +105,7 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ class SshManager {
|
|||||||
conn.once('error', async (err) => {
|
conn.once('error', async (err) => {
|
||||||
// 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;
|
||||||
@@ -136,15 +137,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 +183,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 +255,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);
|
||||||
|
|||||||
+62
-2
@@ -12,6 +12,7 @@ const DEFAULTS = {
|
|||||||
keys: [],
|
keys: [],
|
||||||
snippets: [],
|
snippets: [],
|
||||||
forwards: [],
|
forwards: [],
|
||||||
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
settings: {
|
settings: {
|
||||||
@@ -31,15 +32,63 @@ 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() {
|
constructor() {
|
||||||
this.dir = app.getPath('userData');
|
this.dir = app.getPath('userData');
|
||||||
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() {
|
||||||
@@ -63,12 +112,23 @@ class Store {
|
|||||||
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
|
||||||
|
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'), safeStorage.encryptString(json)]));
|
||||||
|
|||||||
+60
-12
@@ -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
@@ -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 };
|
||||||
+613
-251
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -1,5 +1,5 @@
|
|||||||
<!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:" />
|
||||||
@@ -16,32 +16,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 +62,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>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
function parseXml(text) {
|
function parseXml(text) {
|
||||||
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||||
if (doc.querySelector('parsererror')) throw new Error('Datei ist kein gültiges XML.');
|
if (doc.querySelector('parsererror')) throw new Error(I18N.t('File is not valid XML.'));
|
||||||
const conns = [...doc.getElementsByTagName('Connection')].filter((c) => child(c, 'ConnectionType'));
|
const conns = [...doc.getElementsByTagName('Connection')].filter((c) => child(c, 'ConnectionType'));
|
||||||
return conns.map((c) => makeEntry({
|
return conns.map((c) => makeEntry({
|
||||||
type: child(c, 'ConnectionType'),
|
type: child(c, 'ConnectionType'),
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
host: idx('host', 'hostname', 'url', 'computername', 'address'), port: idx('port', 'hostport'),
|
host: idx('host', 'hostname', 'url', 'computername', 'address'), port: idx('port', 'hostport'),
|
||||||
username: idx('username', 'user', 'login'), domain: idx('domain'), password: idx('password'), description: idx('description'),
|
username: idx('username', 'user', 'login'), domain: idx('domain'), password: idx('password'), description: idx('description'),
|
||||||
};
|
};
|
||||||
if (col.host < 0 && col.type < 0) throw new Error('CSV-Spalten nicht erkannt (erwartet u. a. Name, Host, ConnectionType, Group).');
|
if (col.host < 0 && col.type < 0) throw new Error(I18N.t('CSV columns not recognized (expected e.g. Name, Host, ConnectionType, Group).'));
|
||||||
return rows.map((r) => {
|
return rows.map((r) => {
|
||||||
const v = (k) => (col[k] >= 0 ? (r[col[k]] || '').trim() : '');
|
const v = (k) => (col[k] >= 0 ? (r[col[k]] || '').trim() : '');
|
||||||
return makeEntry({ type: v('type') || 'RDPConfigured', name: v('name'), group: v('group'), url: v('host'), port: v('port'), username: v('username'), domain: v('domain'), password: v('password'), description: v('description') });
|
return makeEntry({ type: v('type') || 'RDPConfigured', name: v('name'), group: v('group'), url: v('host'), port: v('port'), username: v('username'), domain: v('domain'), password: v('password'), description: v('description') });
|
||||||
|
|||||||
+47
-2
@@ -235,8 +235,10 @@ body.in-session #sidebar { display: none; }
|
|||||||
.session .sbar { height: 38px; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-2); border-bottom: 1px solid var(--border); flex: none; }
|
.session .sbar { height: 38px; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-2); border-bottom: 1px solid var(--border); flex: none; }
|
||||||
.session .sbar .info { color: var(--muted); font-size: 12px; margin-right: auto; display: flex; gap: 8px; align-items: center; overflow: hidden; white-space: nowrap; }
|
.session .sbar .info { color: var(--muted); font-size: 12px; margin-right: auto; display: flex; gap: 8px; align-items: center; overflow: hidden; white-space: nowrap; }
|
||||||
.session .term-wrap { flex: 1; position: relative; min-height: 0; display: flex; }
|
.session .term-wrap { flex: 1; position: relative; min-height: 0; display: flex; }
|
||||||
.session .term { flex: 1; padding: 8px 0 4px 10px; min-width: 0; }
|
.session .term { flex: 1; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
.session .term .xterm { height: 100%; }
|
/* Innenabstand am .xterm-Element: FitAddon zieht nur dessen Padding ab (am Elternelement würde es mitgezählt) */
|
||||||
|
.session .term .xterm { height: 100%; padding: 8px 0 4px 10px; }
|
||||||
|
.session .term .xterm-viewport { background-color: transparent !important; } /* sonst schwarzer Rand im Innenabstand */
|
||||||
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
|
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
|
||||||
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
||||||
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
|
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
|
||||||
@@ -341,3 +343,46 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
||||||
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
||||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||||
|
|
||||||
|
/* App-Sperre */
|
||||||
|
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
||||||
|
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
|
||||||
|
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
||||||
|
.lock-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||||
|
.lock-logo { width: 64px; height: 64px; }
|
||||||
|
.lock-pw { display: flex; gap: 8px; width: 100%; }
|
||||||
|
.lock-pw input { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; color: var(--text); outline: none; }
|
||||||
|
.lock-pw input:focus { border-color: var(--accent); }
|
||||||
|
.lock-fido { width: 100%; justify-content: center; }
|
||||||
|
.lock-err { color: var(--red); min-height: 18px; font-size: 13px; }
|
||||||
|
.lock-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.lock-row .grow { flex: 1; min-width: 0; }
|
||||||
|
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.lock-row b svg { width: 14px; height: 14px; }
|
||||||
|
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* VPN */
|
||||||
|
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||||
|
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
||||||
|
|
||||||
|
/* Docker */
|
||||||
|
.session.docker .sbar .search.sm { flex: 0 1 260px; height: 28px; margin: 0; }
|
||||||
|
.session.docker .rt { color: var(--faint); }
|
||||||
|
.docker-body { flex: 1; overflow: auto; position: relative; }
|
||||||
|
.docker-body .pane-empty { height: 100%; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
|
||||||
|
.docker-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.docker-table th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 8px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); z-index: 1; }
|
||||||
|
.docker-table td { padding: 7px 10px; border-bottom: 1px solid var(--row-line); white-space: nowrap; vertical-align: middle; }
|
||||||
|
.docker-table tr:hover td { background: rgb(var(--tint) / 0.024); }
|
||||||
|
.docker-table td.name { width: 30%; max-width: 0; }
|
||||||
|
.docker-table td.name > div { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.docker-table td.name b, .docker-table td.name .img { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table td.name .img { color: var(--faint); font-size: 11px; }
|
||||||
|
.docker-table td.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.docker-table td.ports { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.docker-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
|
.docker-table td.acts { text-align: right; }
|
||||||
|
.docker-table td.acts .btn { padding: 4px 6px; }
|
||||||
|
.docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||||
|
.docker-table .dot.on { background: var(--green); }
|
||||||
|
.docker-table .dot.wait { background: var(--orange); }
|
||||||
|
|||||||
@@ -37,5 +37,5 @@ window.APP_THEMES = {
|
|||||||
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
|
||||||
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
|
||||||
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
|
||||||
light: { name: 'Hell', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
|
light: { name: 'Light', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user