Compare commits

..
50 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 809227e8f1 Version 0.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:24 +02:00
MrBlakeandClaude Opus 5.5 fd155fe2db Add web server, certificates and Samba/AD management tabs, a web server config generator, toggleable feature menus and an animated start page background
- Web server tab (nginx, Apache, Caddy, PHP-FPM): service control with autostart, config test and live logs, sites list with enable/disable/edit/delete, new site wizard (static, reverse proxy, PHP) and a code editor for all config files; every change is checked (nginx -t, apachectl configtest, caddy validate, php-fpm -t) before reloading and rolled back if the check fails
- Config generator: all options grouped and searchable (reverse proxy with load balancing and WebSockets, HTTPS/TLS, HSTS, security headers, IP allow list, basic auth, rate limit, gzip, caching, upload size, PHP, redirects, logs, custom directives), presets, live preview for nginx/Apache/Caddy; save as a site (needed Apache modules are enabled) or open in the editor first
- Certificates tab: Let's Encrypt via certbot (request with nginx/Apache/webroot/standalone/DNS challenge, renew, dry run, delete) in a terminal tab, acme.sh certificates, expiry of all found certificate files, auto-renew timer, self-signed certificates and a TLS endpoint check from this computer
- Samba/AD tab: shares (smb.conf edited section by section, checked with testparm), Samba users, services and connected clients; create a new AD domain (samba-tool domain provision), join or leave a domain (realmd/sssd) and manage domain users, groups, members and computers; passwords are passed via stdin
- Settings: feature menus (Docker, firewall, network, web server, certificates, Samba/AD) can be enabled or disabled for the host menu, terminal toolbar and quick search
- Start page: slowly drifting, blurred colour glows in the theme colours behind the vault (can be turned off in the design settings, respects reduced motion)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:18 +02:00
MrBlakeandClaude Opus 5.5 29d3747859 App icon: cut out the dark square background behind the MrTerm emblem (transparent, edge pixels un-blended so no dark fringe remains); make-icons.js now does the cut-out itself
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:58:17 +02:00
MrBlake ea8d73dd60 Version 0.22.0 2026-10-14 21:54:01 +02:00
MrBlakeandClaude Opus 5.5 d87feb63af Tabs: reorder by dragging; detach tabs into their own window (drag out of the window or "Open in new window") and move them back (drag into another window's tab bar or "Back to main window"); terminal tabs keep their SSH connection and output, SFTP/Docker/firewall/network reconnect; events go to all windows, password prompts to the window showing the session; closing a detached window ends its sessions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:00 +02:00
MrBlake ec5ca37356 Version 0.21.1 2026-10-14 21:45:08 +02:00
MrBlakeandClaude Opus 5.5 5c974f31ce Title bar: center tabs vertically as rounded pills (were bottom-aligned while logo, + and buttons were centered), frame the active tab, draw + as an icon and center all title bar icons at the same size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:45:05 +02:00
MrBlake 25115f8747 Version 0.21.0 2026-10-14 21:36:00 +02:00
MrBlakeandClaude Opus 5.5 c3a4d06310 sudo for firewall, network and Docker: try passwordless sudo first, then the saved host password, otherwise ask for the sudo password (up to 3 attempts, kept only for the open tab) instead of failing with "root privileges required"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:35:59 +02:00
MrBlake b4eedc77bb Version 0.20.0 2026-10-14 21:29:50 +02:00
MrBlakeandClaude Opus 5.5 93123c5c60 SFTP: upload button on the server pane (desktop: system file dialog with multi-select, uploads into the current folder); fix the backend switch (UFW/iptables) sitting too high in the firewall tab bar (.seg bottom margin was only reset for Docker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:29:49 +02:00
MrBlake 3714205dab Version 0.19.1 2026-10-13 21:53:46 +02:00
MrBlake 4818523b29 Version 0.18.1 2026-10-13 21:50:56 +02:00
MrBlakeandClaude Opus 5.5 6f52873b69 Support assistant: fix the delete-chat confirmation opening twice (panel click handler was added again on every reload)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:50:55 +02:00
MrBlakeandClaude Opus 5.5 51bd769d6e Version 0.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:34 +02:00
MrBlakeandClaude Opus 5.5 136e4b4e2c RDP tabs on Windows: no visible mstsc windows and resolution follows the tab size. mstsc is started with /v: instead of an unsigned .rdp file (avoids the mandatory "unknown publisher" security warning; a file is still used when the host needs drive redirection, clipboard/audio off or scaling); a WinEvent hook adopts the session window while it is still hidden (borderless, parked off-screen until logged in), keeps the "connecting" progress dialog invisible and shows real prompts (certificate, errors) centered over the tab; servers without dynamic resolution (mstsc clamps the window to the session size) are detected and reconnected with the new size after resizing, re-confirming a certificate the user already accepted for this session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:35:30 +02:00
MrBlakeandClaude Opus 5.5 6b02dc078a Version 0.18.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:10 +02:00
MrBlake ecaf579ccd Version 0.18.0 2026-10-01 22:44:35 +02:00
MrBlakeandClaude Opus 5.5 4176cc0a01 Support assistant across all tabs: global panel (title bar button and in terminal, Docker, firewall and network tabs) that uses the active tab as context (terminal output, container/stack view, compose editor with masked .env values, firewall rules, network config); chats are stored in the vault and can be switched, started anew and deleted; stop button while answering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:44:35 +02:00
MrBlake 20506ee889 Version 0.17.1 2026-10-01 22:12:38 +02:00
MrBlakeandClaude Opus 5.5 6ebdc5126f Docker stacks: show .env variables when editing a stack whose .env was written via sudo (root-owned, mode 600); reading now retries with sudo instead of silently showing an empty .env, which also deleted the file on the next save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:12:37 +02:00
MrBlake 08c6d65e29 Version 0.17.0 2026-10-01 21:42:02 +02:00
MrBlakeandClaude Opus 5.5 8c8dc0d4c0 Add support assistant via Ollama: chat panel in terminal tabs with the latest terminal output as context, 'explain last error', copy/insert suggested commands (never auto-executed), Ollama server/model in settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:41:56 +02:00
MrBlake a279ef88b2 Version 0.16.0 2026-09-27 14:58:50 +02:00
MrBlakeandClaude Opus 5.5 6efa74aa7e Docker: add a Compose stack manager (Containers | Stacks toggle): list stacks in a stacks folder (default /opt/stacks) plus projects found via compose ls, create/edit compose.yaml and .env in an editor (validated with compose config), deploy/stop/restart/down/pull/logs/delete in terminal tabs, clone stacks from Git and git pull + redeploy, app templates in Portainer format (default Lissy93/portainer-templates: container templates are converted to compose, stack templates loaded from GitHub), "Set up folder" makes the stacks folder writable for the SSH user
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:58:50 +02:00
MrBlake 0b43182019 Version 0.15.0 2026-09-27 14:45:31 +02:00
MrBlakeandClaude Opus 5.5 93d7130bfa Backup: export everything (hosts, groups, keys, passwords, snippets, forwards, VPNs, known hosts, history, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import asks for the password, can merge or replace existing data and optionally restore settings; old backups still import; shared module for desktop, web and Android (src/core/backup.js)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:45:31 +02:00
MrBlakeandClaude Opus 5.5 408b4bd9d0 Snippets: add built-in templates (collapsed "Templates" section in the snippets view and the terminal snippet panel): authorize an SSH key from the keychain for a user, install the QEMU guest agent, set/remove an APT cache server (apt-cacher-ng), system update; parameters are asked for before running
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:37:31 +02:00
MrBlakeandClaude Opus 5.5 7d4991c99c App lock: unlock with security keys via the built-in CTAP2 client on Linux too (Chromium returned no PRF result, so adding a YubiKey failed with "does not support hmac-secret"); updates: show only one update dialog at a time (startup and manual check stacked two)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:22:34 +02:00
MrBlake 10acdaeb0b Version 0.13.2 2026-09-27 14:09:14 +02:00
MrBlakeandClaude Opus 5.5 daff240aa6 App lock: fix registering security keys with a FIDO2 PIN on Linux (Electron has no WebAuthn PIN prompt, so Chromium failed with NotAllowedError after touching the key); register via a built-in CTAP2 client over hidraw with PIN entry in the key window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:53:38 +02:00
MrBlakeandClaude Opus 5.5 66d4350221 README: add update instructions for the Docker web version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:06:14 +02:00
MrBlake 6c6e25df36 Version 0.13.1 2026-09-26 15:03:55 +02:00
MrBlakeandClaude Opus 5.5 32bf502ffa Web: fix SSH, SFTP and all other sessions failing over plain http:// (crypto.randomUUID only exists in secure contexts), add a clipboard fallback without HTTPS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:03:55 +02:00
MrBlake a2a0893b8c Version 0.13.0 2026-09-26 14:52:26 +02:00
MrBlakeandClaude Opus 5.5 45b0be8557 Add MrTerm Web: self-hosted browser version (Docker Compose) with user accounts, per-user vaults encrypted by the login password, the desktop UI over WebSocket, SFTP upload/download over HTTP; share backend channels between Android and web (src/core/backend.js)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:52:26 +02:00
MrBlake c143053319 Version 0.12.0 2026-09-26 14:31:51 +02:00
MrBlakeandClaude Opus 5.5 58709e09f2 Themes: share app theme colors between desktop and Android (app-themes.css), add theme picker and accent color to the Android app, and add Windows XP, Windows 11, macOS (Liquid Glass) and Cyberpunk 2077 themes with matching terminal schemes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:31:51 +02:00
MrBlake 27091ab5d1 Version 0.11.4 2026-09-26 14:15:09 +02:00
MrBlakeandClaude Opus 5.5 6a6971e1cb Android: stop adding keyboard padding on top of the system's own resize, which left an empty block above the keyboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:15:09 +02:00
MrBlake edea4ce0d2 Version 0.11.3 2026-09-26 00:12:21 +02:00
MrBlakeandClaude Opus 5.5 b431428260 Android: handle status bar, navigation bar and keyboard insets natively and consume them, so the keyboard no longer shrinks the view twice and content no longer sits under the status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:12:21 +02:00
MrBlake aeb6c590db Version 0.11.2 2026-09-26 00:07:24 +02:00
MrBlakeandClaude Opus 5.5 428e74b2d8 Android: load @capacitor/core in the web view (registerPlugin was missing, so the app stopped on the loading screen) and show script errors on the start screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:07:24 +02:00
MrBlake b94f18d2e2 Version 0.11.1 2026-09-26 00:03:26 +02:00
MrBlakeandClaude Opus 5.5 7cff93419d Android: fix app hanging on the loading screen (Node start only returns when Node exits, wait for whenReady instead), build the native bridge with 16 KB page alignment, show an error instead of an endless spinner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 00:03:26 +02:00
MrBlake 1980d3044b Version 0.11.0 2026-09-25 23:56:47 +02:00
MrBlakeandClaude Opus 5.5 4542aff75f Add Android app: phone UI for hosts, SSH terminal with extra key row, keys, snippets, LAN sync and app lock (password + fingerprint), reusing the desktop Node modules via capacitor-nodejs; APKs per ABI in releases for Obtainium
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:56:39 +02:00
MrBlake 430c18b84e Version 0.10.3 2026-09-25 23:32:05 +02:00
MrBlakeandClaude Opus 5.5 98c7763c47 README: add screenshots of hosts, terminal, host settings, Docker, firewall, network, VPN and settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:32:05 +02:00
119 changed files with 11121 additions and 221 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
mobile
build
docs
.git
.gitea-token
web-data
+7
View File
@@ -2,3 +2,10 @@ node_modules/
dist/ dist/
package.json package.json
.gitea-token .gitea-token
!mobile/package.json
# Android-App: Build-Ausgaben, Plugin-Tarball (lädt build.js), Signaturschlüssel
mobile/vendor/
mobile/www/
mobile/android/keystore.properties
mobile/android/*.jks
web-data/
+29
View File
@@ -0,0 +1,29 @@
# MrTerm Web – die MrTerm-Oberfläche im Browser, selbst gehostet.
# docker compose up -d → http://<server>:8080 (beim ersten Aufruf Admin-Konto anlegen)
# ---- Build: Server samt ssh2/ws in eine Datei bündeln
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY src ./src
RUN npx esbuild src/web/server.js --bundle --platform=node --target=node22 --minify --legal-comments=none \
--external:electron --external:cpu-features --external:bufferutil --external:utf-8-validate \
--outfile=dist/server.js
# ---- Laufzeit: nur Node, gebündelter Server und die Dateien der Oberfläche
FROM node:22-alpine
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data MRTERM_ROOT=/app
WORKDIR /app
COPY package.json ./
COPY --from=build /src/dist/server.js dist/server.js
COPY src/i18n.js src/i18n.js
COPY src/renderer src/renderer
COPY src/web/public src/web/public
COPY --from=build /src/node_modules/@xterm node_modules/@xterm
RUN mkdir -p /data && chown node:node /data
USER node
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
CMD ["node", "dist/server.js"]
+79 -2
View File
@@ -2,8 +2,12 @@
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. 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.
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D)
MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**. MrTerm is available in **English** and **German**. It follows your system language by default, and you can change it under **Settings → Language**.
![MrTerm host overview with groups](docs/screenshots/hosts.png)
## Download & installation ## Download & installation
Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases). Get the latest version from the [releases page](https://git.mrblake.cc/MrBlake/MrTerm/releases).
@@ -25,6 +29,23 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
``` ```
`freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys. `freerdp` is needed for RDP connections. `gnome-keyring` or `kwallet` lets MrTerm encrypt your saved passwords and keys.
**Android**
- Install with [Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22de.mrterm.app%22%2C%22url%22%3A%22https%3A%2F%2Fgit.mrblake.cc%2FMrBlake%2FMrTerm%22%2C%22author%22%3A%22MrBlake%22%2C%22name%22%3A%22MrTerm%22%2C%22overrideSource%22%3A%22Codeberg%22%7D): tap the badge above on your phone, or add `https://git.mrblake.cc/MrBlake/MrTerm` in Obtainium and choose **Forgejo (Codeberg)** as the source. Obtainium then keeps MrTerm up to date.
- Or download `MrTerm-<version>.apk` from the releases page and install it directly.
## Android app
The Android app is made for phones and focuses on what you need on the go:
- **Hosts** with groups, search and Quick Connect (`user@host:port`)
- **SSH terminal** in full screen with an extra key row (Esc, Tab, Ctrl, Alt, arrow keys, `|`, `~` …), pinch-to-zoom and several open sessions
- **Keys**: generate or import SSH keys and copy the public key
- **Snippets** you can send to the terminal with one tap
- **LAN sync** with MrTerm on your computer: pair once, and your hosts, groups and snippets appear on the phone. You choose which passwords and keys are shared
- **App lock** with a password and fingerprint unlock. The vault is encrypted with a key kept in the Android Keystore
RDP, SFTP, port forwarding and VPN are only available in the desktop app.
## Features ## Features
- **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`) - **Hosts**: nested groups, tags, colors, search and Quick Connect (`user@host:port`, `rdp://host`)
@@ -41,13 +62,60 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
- **Known hosts**: MrTerm warns you if a server's host key changes - **Known hosts**: MrTerm warns you if a server's host key changes
- **History** of recent connections - **History** of recent connections
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV) - **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
- **Backup** export and import - **Backup** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server - **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color - **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet) - **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive - **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you - **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
## Screenshots
| | |
|---|---|
| ![SSH terminal](docs/screenshots/terminal.png) | ![Editing a host](docs/screenshots/edit-host.png) |
| **SSH terminal** with tabs and quick access to SFTP, Docker, Firewall and Network | **Host settings**: authentication, keys, jump hosts and VPN |
| ![Docker containers](docs/screenshots/docker.png) | ![UFW firewall rules](docs/screenshots/firewall.png) |
| **Docker & Podman** containers with CPU and memory usage | **Firewall** rules for UFW and iptables |
| ![Network configuration](docs/screenshots/network.png) | ![VPN configurations](docs/screenshots/vpn.png) |
| **Network**: interfaces, bonds (LACP), bridges, gateway and DNS | **VPN**: WireGuard and OpenVPN, connected automatically per host |
| ![Settings](docs/screenshots/settings.png) | |
| **Settings**: language, app lock, LAN sync and themes | |
## Web version (Docker)
MrTerm also runs as a self-hosted web app, similar to Termix: you use the familiar MrTerm interface in the browser, from any computer.
```bash
curl -O https://git.mrblake.cc/MrBlake/MrTerm/raw/branch/main/docker-compose.yml
docker compose up -d
```
Then open `http://<your-server>:8080` and create the administrator account. As an administrator you can add more users under **Settings → Account**.
- **Included:** hosts and groups, SSH terminal tabs, SFTP file manager (upload by button or drag & drop, download to your computer), keys, snippets, known hosts, Docker, firewall and network management, themes.
- **Not included:** RDP, VPN, port forwarding, LAN sync and the app lock. These need direct access to your computer.
- **Security:** every user has their own vault, encrypted with a key that only their login password can unlock. The server stores no readable passwords or keys. If a user forgets their password, their vault cannot be recovered.
- **HTTPS:** put MrTerm behind a reverse proxy with HTTPS (Traefik, Caddy, nginx …) before you use it over the internet, and set `TRUST_PROXY=1` in `docker-compose.yml`. The proxy must forward WebSockets.
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
- **SSH connections** start from the server, so the server must be able to reach your hosts.
### Updating the web version
Run these commands in the folder that contains your `docker-compose.yml`:
```bash
docker compose build --pull --no-cache
docker compose up -d
```
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
To remove images left over from earlier builds: `docker image prune`.
## Getting started ## Getting started
1. Click **New host**, enter the address, username and password or key, and click **Save**. 1. Click **New host**, enter the address, username and password or key, and click **Save**.
@@ -181,7 +249,16 @@ In VS Code terminals, unset `ELECTRON_RUN_AS_NODE` first.
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`. 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`.
**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. **Android app** (`mobile/`): Capacitor with [capacitor-nodejs](https://github.com/hampoelz/Capacitor-NodeJS). The phone runs the same Node modules as the desktop app (`src/main/ssh.js`, `store.js`, `sync.js` …), bundled with esbuild; the phone UI lives in `mobile/web/`.
```bash
cd mobile
node build.js --setup # once: downloads the Node.js plugin (checksum-verified) and installs dependencies
node build.js --apk # builds signed APKs into dist/ (needs JDK 21 + Android SDK, see JAVA_HOME / ANDROID_HOME)
../node_modules/.bin/electron dev/electron-dev.js # try the phone UI on the desktop (after node build.js)
```
APKs are signed with `mobile/android/keystore.properties` and the keystore it points to. Both are not committed. Keep a backup, because Android only installs updates signed with the same key.
**Publishing a release**: `./scripts/release-all.sh` asks for the version and release notes, commits and pushes the version, builds Windows + Arch/CachyOS + Android and uploads everything to Gitea. It reads the token from `GITEA_TOKEN` or from `.gitea-token`, which is not committed.
**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`. **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`.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+22
View File
@@ -0,0 +1,22 @@
# MrTerm Web
# docker compose up -d
# Danach http://<server>:8080 öffnen und das Admin-Konto anlegen.
# Hinter einem Reverse-Proxy mit HTTPS (empfohlen): TRUST_PROXY=1 setzen und den Port nur lokal freigeben.
services:
mrterm:
build:
# Baut direkt aus dem Repository – kein Klonen nötig. Alternativ "context: ." in einem Klon.
context: https://git.mrblake.cc/MrBlake/MrTerm.git
image: mrterm-web
container_name: mrterm
restart: unless-stopped
ports:
- "8080:8080"
environment:
- TZ=Europe/Berlin
# - TRUST_PROXY=1
volumes:
- mrterm-data:/data
volumes:
mrterm-data:
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

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

After

Width:  |  Height:  |  Size: 58 KiB

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

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#111728</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">MrTerm</string>
<string name="title_activity_main">MrTerm</string>
<string name="package_name">de.mrterm.app</string>
<string name="custom_url_scheme">de.mrterm.app</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+6
View File
@@ -0,0 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-nodejs'
project(':capacitor-nodejs').projectDir = new File('../node_modules/capacitor-nodejs/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+105
View File
@@ -0,0 +1,105 @@
// Baut die Android-App.
// node build.js --setup Node.js-Plugin herunterladen (Prüfsumme) und Abhängigkeiten installieren
// node build.js Weboberfläche + Node-Backend nach www/ bauen und ins Android-Projekt synchronisieren
// node build.js --apk zusätzlich signierte Release-APK nach ../dist/MrTerm-<version>.apk bauen
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const WWW = path.join(__dirname, 'www');
const version = require(path.join(ROOT, 'package.json')).version;
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: 'inherit', cwd: __dirname, ...opts });
const PLUGIN = {
url: 'https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.10/capacitor-nodejs.tgz',
file: path.join(__dirname, 'vendor', 'capacitor-nodejs-1.0.0-beta.10.tgz'),
sha256: 'cc47cba4190d5e0ea8e2b33f4e9bd47e159570c01571c51214467e4c21e87bd3',
};
async function setup() {
if (!fs.existsSync(PLUGIN.file)) {
console.log('Lade capacitor-nodejs …');
const res = await fetch(PLUGIN.url);
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
fs.mkdirSync(path.dirname(PLUGIN.file), { recursive: true });
fs.writeFileSync(PLUGIN.file, Buffer.from(await res.arrayBuffer()));
}
const sum = crypto.createHash('sha256').update(fs.readFileSync(PLUGIN.file)).digest('hex');
if (sum !== PLUGIN.sha256) { fs.rmSync(PLUGIN.file); throw new Error(`Prüfsumme von capacitor-nodejs stimmt nicht (${sum})`); }
run('npm', ['install']);
}
function copy(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.cpSync(from, to, { recursive: true });
}
// capacitor-nodejs baut seine Brückenbibliothek mit 4-KB-Seitenausrichtung; neuere Geräte (z. B. Pixel mit
// 16-KB-Speicherseiten) laden sie dann nicht. Linker-Option einmalig ergänzen.
function patchPlugin() {
const f = path.join(__dirname, 'node_modules', 'capacitor-nodejs', 'android', 'CMakeLists.txt');
const s = fs.readFileSync(f, 'utf8');
if (!s.includes('max-page-size=16384')) fs.writeFileSync(f, `${s}\ntarget_link_options(native-lib PRIVATE "-Wl,-z,max-page-size=16384")\n`);
}
function buildWeb() {
patchPlugin();
fs.rmSync(WWW, { recursive: true, force: true });
copy(path.join(__dirname, 'web'), WWW);
const nm = path.join(ROOT, 'node_modules', '@xterm');
copy(path.join(nm, 'xterm', 'lib', 'xterm.js'), path.join(WWW, 'lib', 'xterm.js'));
copy(path.join(nm, 'xterm', 'css', 'xterm.css'), path.join(WWW, 'lib', 'xterm.css'));
copy(path.join(nm, 'addon-fit', 'lib', 'addon-fit.js'), path.join(WWW, 'lib', 'addon-fit.js'));
copy(path.join(__dirname, 'node_modules', '@capacitor', 'core', 'dist', 'capacitor.js'), path.join(WWW, 'lib', 'capacitor.js'));
copy(path.join(ROOT, 'src', 'i18n.js'), path.join(WWW, 'lib', 'i18n.js'));
copy(path.join(ROOT, 'src', 'renderer', 'themes.js'), path.join(WWW, 'lib', 'themes.js'));
copy(path.join(ROOT, 'src', 'renderer', 'app-themes.css'), path.join(WWW, 'lib', 'app-themes.css'));
copy(path.join(ROOT, 'src', 'renderer', 'logo.png'), path.join(WWW, 'logo.png'));
// Node-Backend inkl. ssh2 in eine Datei bündeln (nodejs-mobile = Node 18)
require('esbuild').buildSync({
entryPoints: [path.join(__dirname, 'nodejs', 'server.js')],
outfile: path.join(WWW, 'nodejs', 'index.js'),
bundle: true, platform: 'node', target: 'node18', minify: true, legalComments: 'none',
external: ['bridge', 'cpu-features'],
alias: { electron: path.join(__dirname, 'nodejs', 'electron-shim.js') },
logLevel: 'warning',
});
fs.writeFileSync(path.join(WWW, 'nodejs', 'package.json'), JSON.stringify({ name: 'mrterm-backend', main: 'index.js' }));
run('npx', ['cap', 'sync', 'android']);
}
function toolchain() {
const base = path.join(os.homedir(), '.local', 'share', 'android-toolchain');
const env = { ...process.env };
env.JAVA_HOME ||= path.join(base, 'jdk');
env.ANDROID_HOME ||= path.join(base, 'sdk');
if (!fs.existsSync(env.JAVA_HOME) || !fs.existsSync(env.ANDROID_HOME)) throw new Error('JDK/Android-SDK nicht gefunden: JAVA_HOME und ANDROID_HOME setzen');
fs.writeFileSync(path.join(__dirname, 'android', 'local.properties'), `sdk.dir=${env.ANDROID_HOME}\n`);
return env;
}
function buildApk() {
if (!fs.existsSync(path.join(__dirname, 'android', 'keystore.properties'))) {
throw new Error('android/keystore.properties fehlt (Signaturschlüssel). Ohne ihn können Updates nicht installiert werden.');
}
run('./gradlew', ['--no-daemon', '-q', 'assembleRelease', `-PmrtermVersion=${version}`], { cwd: path.join(__dirname, 'android'), env: toolchain() });
const dir = path.join(__dirname, 'android', 'app', 'build', 'outputs', 'apk', 'release');
for (const abi of ['arm64-v8a', 'armeabi-v7a']) {
const out = path.join(ROOT, 'dist', `MrTerm-${version}-${abi}.apk`);
copy(path.join(dir, `app-${abi}-release.apk`), out);
console.log(`APK: ${out}`);
}
}
(async () => {
const args = process.argv.slice(2);
if (args.includes('--setup')) await setup();
else {
buildWeb();
if (args.includes('--apk')) buildApk();
}
})().catch((e) => { console.error(e.message || e); process.exit(1); });
+9
View File
@@ -0,0 +1,9 @@
{
"appId": "de.mrterm.app",
"appName": "MrTerm",
"webDir": "www",
"android": { "backgroundColor": "#14161d" },
"plugins": {
"CapacitorNodeJS": { "nodeDir": "nodejs", "startMode": "manual" }
}
}
+63
View File
@@ -0,0 +1,63 @@
// Entwicklung ohne Handy: startet die gebaute Oberfläche (www/) in einem Fenster in Handygröße
// und das Node-Backend im Electron-Hauptprozess, verbunden über eine nachgebaute Capacitor-Bridge.
// cd mobile && node build.js && ../node_modules/.bin/electron dev/electron-dev.js [--user-data-dir=…]
const { app, BrowserWindow, ipcMain, clipboard } = require('electron');
const path = require('path');
const Module = require('module');
const EventEmitter = require('events');
const crypto = require('crypto');
const WWW = path.join(__dirname, '..', 'www');
const toWeb = new EventEmitter();
const fromWeb = new EventEmitter();
let win;
// require('bridge') im Backend auf diese Nachbildung umleiten
const bridge = {
channel: { send: (name, ...args) => toWeb.emit('msg', name, args), addListener: (name, fn) => fromWeb.on(name, fn) },
getDataPath: () => path.join(app.getPath('userData'), 'mobile-data'),
};
const resolve = Module._resolveFilename;
Module._resolveFilename = function (req, ...rest) { return req === 'bridge' ? 'bridge' : resolve.call(this, req, ...rest); };
require.cache.bridge = { id: 'bridge', filename: 'bridge', loaded: true, exports: bridge };
toWeb.on('msg', (name, args) => { if (win && !win.isDestroyed()) win.webContents.send('dev:node', name, args); });
ipcMain.on('dev:send', (_e, name, args) => fromWeb.emit(name, ...args));
ipcMain.handle('dev:start', (_e, env) => {
Object.assign(process.env, env, { DATADIR: bridge.getDataPath() });
require('fs').mkdirSync(process.env.DATADIR, { recursive: true });
setTimeout(() => { started = true; require(path.join(WWW, 'nodejs', 'index.js')); }, 500);
});
let started;
ipcMain.handle('dev:ready', async () => { while (!started) await new Promise((r) => setTimeout(r, 100)); });
ipcMain.handle('dev:native', (_e, method, arg) => {
if (method === 'vaultKey') return { key: crypto.createHash('sha256').update(app.getPath('userData')).digest('base64') };
if (method === 'info') return { device: 'Dev Phone', locale: process.env.MRTERM_DEV_LOCALE || 'en-US', version: require('../../package.json').version };
if (method === 'bioAvailable') return { available: true, enrolled: false };
if (method === 'bioEnroll' || method === 'bioUnlock') return { secret: Buffer.alloc(32, 1).toString('base64') };
if (method === 'copy') return clipboard.writeText(arg.text);
if (method === 'paste') return { text: clipboard.readText() };
return {};
});
app.whenReady().then(() => {
win = new BrowserWindow({
width: 412, height: 870, backgroundColor: '#14161d',
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
});
win.loadFile(path.join(WWW, 'index.html'));
if (process.env.MRTERM_SHOT) {
const steps = JSON.parse(require('fs').readFileSync(process.env.MRTERM_SHOT, 'utf8'));
win.webContents.on('console-message', (e) => console.log('[r]', e.message));
win.webContents.once('did-finish-load', async () => {
await new Promise((r) => setTimeout(r, 2500));
for (const s of steps) {
if (s.js) await win.webContents.executeJavaScript(`(async()=>{${s.js}})();0`).catch((e) => console.log('js', e.message));
await new Promise((r) => setTimeout(r, s.wait || 900));
if (s.shot) require('fs').writeFileSync(path.join(process.env.MRTERM_SHOT_OUT, s.shot), (await win.webContents.capturePage()).toPNG());
}
app.exit(0);
});
}
});
app.on('window-all-closed', () => app.exit(0));
+16
View File
@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require('electron');
const listeners = new Map();
ipcRenderer.on('dev:node', (_e, name, args) => listeners.get(name)?.forEach((fn) => fn({ args })));
const native = (m) => (arg) => ipcRenderer.invoke('dev:native', m, arg);
contextBridge.exposeInMainWorld('DevBridge', {
NodeJS: {
// Wie auf Android: start() kehrt erst zurück, wenn Node beendet wird
start: (o) => { ipcRenderer.invoke('dev:start', o.env); return new Promise(() => {}); },
whenReady: () => ipcRenderer.invoke('dev:ready'),
send: ({ eventName, args }) => ipcRenderer.send('dev:send', eventName, args),
addListener: (name, fn) => { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); return { remove: () => listeners.get(name).delete(fn) }; },
},
Native: Object.fromEntries(['vaultKey', 'info', 'bioAvailable', 'bioEnroll', 'bioUnlock', 'bioDisable', 'multicast', 'copy', 'paste', 'background', 'bars'].map((m) => [m, native(m)])),
});
+30
View File
@@ -0,0 +1,30 @@
// Ersatz für die von store.js genutzten Electron-APIs unter Android.
// safeStorage verschlüsselt mit einem 256-Bit-Schlüssel, den die App im Android Keystore verpackt aufbewahrt
// und beim Start als MRTERM_VAULT_KEY (base64) an den Node-Prozess übergibt.
const crypto = require('crypto');
const dataDir = () => process.env.DATADIR || require('bridge').getDataPath();
const key = () => {
const k = Buffer.from(process.env.MRTERM_VAULT_KEY || '', 'base64');
return k.length === 32 ? k : null;
};
const safeStorage = {
isEncryptionAvailable: () => !!key(),
encryptString(plain) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const ct = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
},
decryptString(buf) {
const d = crypto.createDecipheriv('aes-256-gcm', key(), buf.subarray(0, 12));
d.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString('utf8');
},
};
module.exports = {
app: { getPath: () => dataDir(), getVersion: () => process.env.MRTERM_VERSION || '0.0.0', getLocale: () => process.env.MRTERM_LOCALE || 'en' },
safeStorage,
};
+48
View File
@@ -0,0 +1,48 @@
// Node-Backend der Android-App. Die Kanäle kommen aus src/core/backend.js (gemeinsam mit der Web-Version);
// hier nur, was Android betrifft: Bridge-Anbindung, Entsperren per Fingerabdruck, Pause/Fortsetzen.
// Protokoll: Oberfläche → 'call' [reqId, Kanal, Argumente] bzw. 'send' [Kanal, Argumente];
// Backend → 'reply' [reqId, ok, Wert|Fehler] und 'evt' [Kanal, Argumente].
const { channel } = require('bridge');
const crypto = require('crypto');
const { Store } = require('../../src/main/store');
const { createBackend } = require('../../src/core/backend');
const i18n = require('../../src/i18n');
const send = (ch, ...args) => channel.send('evt', ch, args);
process.on('uncaughtException', (e) => { console.error(e); send('toast', e?.message || String(e), 'error'); });
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
const store = new Store();
const applyLanguage = (lang = store.get().settings.language) => i18n.setLanguage(lang, process.env.MRTERM_LOCALE || 'en');
const be = createBackend({ store, send, platform: 'android', version: process.env.MRTERM_VERSION || '0.0.0', withSync: true, onLanguage: applyLanguage });
channel.addListener('call', async (reqId, ch, args = []) => {
try { channel.send('reply', reqId, true, await be.call(ch, args)); } catch (e) { channel.send('reply', reqId, false, e?.message || String(e)); }
});
channel.addListener('send', (ch, args = []) => be.notify(ch, args));
// ---------- Fingerabdruck (Geheimnis aus dem Android Keystore, nur zusätzlich zum Passwort) ----------
const bioKek = (b64) => Buffer.from(crypto.hkdfSync('sha256', Buffer.from(b64, 'base64'), Buffer.alloc(0), 'mrterm-bio-kek', 32));
be.handle('lock:unlockBio', (secret) => {
if (!store.lock?.bio) throw new Error(i18n.t('Fingerprint unlock is not set up.'));
try { store.unlockWith(bioKek(secret), store.lock.bio.wrap); } catch { throw new Error(i18n.t('Fingerprint unlock failed. Use your password.')); }
applyLanguage();
be.sync.start().catch(() => {});
return true;
});
be.handle('lock:setBio', (secret) => {
be.requireUnlocked();
if (!store.lock?.password) throw new Error(i18n.t('Set a password first.'));
store.lock.bio = secret ? { wrap: store.wrapDek(bioKek(secret)) } : null;
store.save();
return be.lockStatus();
});
// App im Hintergrund: Sync-Sockets schließen, im Vordergrund wieder öffnen
be.handle('app:pause', () => be.sync.stop());
be.handle('app:resume', () => be.sync.start().catch(() => {}));
store.load();
applyLanguage();
be.sync.start().catch(() => {});
channel.send('ready');
+1731
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "mrterm-mobile",
"private": true,
"version": "0.0.0",
"description": "MrTerm for Android",
"scripts": {
"build": "node build.js",
"apk": "node build.js --apk"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"esbuild": "^0.28.2"
},
"allowScripts": {
"esbuild@0.28.2": true
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/core": "^8.5.2",
"capacitor-nodejs": "file:vendor/capacitor-nodejs-1.0.0-beta.10.tgz"
}
}
+1078
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>MrTerm</title>
<link rel="stylesheet" href="lib/xterm.css" />
<link rel="stylesheet" href="lib/app-themes.css" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="boot"><img src="logo.png" alt="" /><div class="spinner"></div></div>
<!-- Hauptansicht: Inhalt + untere Navigation -->
<div id="main" hidden>
<main id="view"></main>
<nav id="tabs">
<button data-view="hosts"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg><span data-t>Hosts</span></button>
<button data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span data-t>Keys</span></button>
<button data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span data-t>Snippets</span></button>
<button data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z"/></svg><span data-t>Settings</span></button>
</nav>
</div>
<!-- Terminal im Vollbild -->
<div id="termScreen" hidden>
<header class="term-head">
<button class="icon" id="termBack" aria-label="Back"><svg viewBox="0 0 24 24"><path d="M15 18l-6-6 6-6"/></svg></button>
<button class="term-title" id="termTitle"><span class="dot"></span><span class="name"></span><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
<button class="icon" id="termKbd" aria-label="Keyboard"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>
<button class="icon" id="termMenu" aria-label="Menu"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</header>
<div id="terms"></div>
<div id="keybar"></div>
</div>
<div id="lockScreen" hidden></div>
<div id="layer"></div>
<div id="toasts"></div>
<script>
// Fehler beim Laden sichtbar machen statt endlos den Ladekreis zu zeigen
window.addEventListener('error', (e) => {
const b = document.getElementById('boot');
if (b && !b.hidden) b.innerHTML = '<div style="color:#ff5f6d;padding:24px;text-align:center;font:14px system-ui">' + String(e.message || e.error).replace(/</g, '&lt;') + '</div>';
});
</script>
<script src="lib/capacitor.js"></script>
<script src="lib/i18n.js"></script>
<script src="lib/themes.js"></script>
<script src="lib/xterm.js"></script>
<script src="lib/addon-fit.js"></script>
<script src="app.js"></script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
:root {
/* Farben kommen aus lib/app-themes.css (gemeinsam mit dem Desktop, Auswahl über data-theme) */
--surface: var(--card);
--surface2: var(--card-hover);
--line: var(--border);
--accent-soft: color-mix(in srgb, var(--accent) 16%, transparent);
--r: calc(var(--r) + 4px);
/* Status-/Navigationsleiste rechnet MainActivity als Rand um den WebView heraus */
--top: 0px;
--bottom: 0px;
color-scheme: dark;
}
:root.light { color-scheme: light; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); overflow: hidden; }
body { font: 15px/1.4 var(--font); font-family: var(--font), system-ui, Roboto, sans-serif; -webkit-user-select: none; user-select: none; overscroll-behavior: none; }
[hidden] { display: none !important; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
input, select, textarea { font: inherit; color: var(--text); -webkit-user-select: text; user-select: text; }
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.mono { font-family: ui-monospace, 'JetBrains Mono', 'DejaVu Sans Mono', monospace; }
/* ---------- Start ---------- */
#boot { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 28px; }
#boot img { width: 84px; height: 84px; }
.spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--surface2); border-top-color: var(--accent); animation: spin .8s linear infinite; }
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Grundlayout ---------- */
#main { position: fixed; inset: 0; display: flex; flex-direction: column; }
#view { flex: 1; overflow-y: auto; padding: calc(var(--top) + 8px) 16px 96px; -webkit-overflow-scrolling: touch; }
#tabs { display: flex; background: var(--surface); border-top: 1px solid var(--line); padding-bottom: var(--bottom); }
#tabs button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 9px 0 8px; color: var(--faint); font-size: 11.5px; font-weight: 600; }
#tabs button.active { color: var(--accent); }
.top { display: flex; align-items: center; gap: 10px; min-height: 52px; }
.top h1 { font-size: 26px; margin: 0; flex: 1; letter-spacing: -.3px; }
.top h1 .brand { color: var(--green); }
.icon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--muted); }
.icon:active { background: var(--surface2); }
.search { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; margin: 6px 0 14px; }
.search svg { color: var(--faint); width: 18px; height: 18px; }
.search input { flex: 1; background: none; border: 0; outline: 0; padding: 12px 0; }
.section { margin: 18px 4px 8px; font-size: 12px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.section .count { background: var(--surface2); border-radius: 8px; padding: 1px 7px; font-size: 11px; color: var(--faint); }
.section svg { width: 16px; height: 16px; }
.list { background: var(--surface); border-radius: var(--r); overflow: hidden; }
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 62px; position: relative; }
.item + .item::before { content: ''; position: absolute; top: 0; left: 64px; right: 0; border-top: 1px solid var(--line); }
.item:active { background: var(--surface2); }
.item .meta { flex: 1; min-width: 0; }
.item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .more { color: var(--faint); width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; margin-right: -6px; }
.item.disabled { opacity: .55; }
.avatar { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; font-weight: 800; color: #fff; flex: none; font-size: 16px; }
.avatar svg { width: 20px; height: 20px; }
.avatar.soft { background: var(--accent-soft); color: var(--accent); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.dot.on { background: var(--green); }
.dot.err { background: var(--red); }
.sessions { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; }
.sessions::-webkit-scrollbar { display: none; }
.chip { display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 20px; padding: 8px 14px; white-space: nowrap; font-weight: 600; font-size: 14px; }
.empty { text-align: center; padding: 60px 24px; color: var(--muted); }
.empty .avatar { margin: 0 auto 16px; width: 60px; height: 60px; border-radius: 18px; }
.empty .avatar svg { width: 28px; height: 28px; }
.empty h3 { color: var(--text); margin: 0 0 6px; }
.fab { position: fixed; right: 18px; bottom: calc(76px + var(--bottom)); width: 58px; height: 58px; border-radius: calc(var(--radius) + 8px); background: var(--accent); color: var(--on-accent); display: grid; place-items: center; box-shadow: 0 8px 24px #0008; }
.fab svg { width: 26px; height: 26px; }
/* ---------- Karten / Einstellungen ---------- */
.card { background: var(--surface); border-radius: var(--r); padding: 14px 16px; margin-bottom: 14px; }
.card h3 { margin: 0 0 4px; font-size: 16px; }
.card p, .hint { color: var(--muted); font-size: 13.5px; margin: 4px 0 10px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
.row + .row { border-top: 1px solid var(--line); }
.row .grow { flex: 1; min-width: 0; }
.row .sub { color: var(--muted); font-size: 13px; }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); font-weight: 600; }
.btn svg { width: 18px; height: 18px; }
.btn.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.btn.danger { color: var(--red); }
.btn.block { width: 100%; }
.btn:disabled { opacity: .5; }
.btn:active { filter: brightness(1.15); }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--muted); margin: 0 2px 6px; }
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px 13px; outline: 0; min-height: 48px; }
.field textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, monospace; font-size: 13px; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field .hint { margin: 6px 2px 0; font-size: 12.5px; }
.pw { position: relative; }
.pw input { padding-right: 48px; }
.pw button { position: absolute; right: 4px; top: 4px; width: 40px; height: 40px; display: grid; place-items: center; color: var(--faint); }
.two { display: grid; grid-template-columns: 1fr 96px; gap: 10px; }
.switch { position: relative; width: 50px; height: 30px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch span { position: absolute; inset: 0; background: var(--surface2); border-radius: 20px; transition: .2s; }
.switch span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 24px; height: 24px; border-radius: 50%; background: var(--muted); transition: .2s; }
.switch input:checked + span { background: var(--accent); }
.switch input:checked + span::after { transform: translateX(20px); background: var(--on-accent); }
.warn-box { border: 1px solid #ffb34755; background: #ffb34712; border-radius: 12px; padding: 10px 12px; color: var(--orange); font-size: 13px; margin: 8px 0; }
/* ---------- Ebenen: Seiten, Sheets, Dialoge ---------- */
#layer > * { position: fixed; inset: 0; z-index: 20; }
.page { background: var(--bg); display: flex; flex-direction: column; animation: slidein .18s ease-out; }
@keyframes slidein { from { transform: translateX(30px); opacity: 0; } }
.page-head { display: flex; align-items: center; gap: 6px; padding: calc(var(--top) + 6px) 8px 6px; border-bottom: 1px solid var(--line); background: var(--surface); }
.page-head h2 { flex: 1; font-size: 18px; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.page-head .btn { min-height: 38px; padding: 0 14px; }
.page-body { flex: 1; overflow-y: auto; padding: 16px 16px calc(24px + var(--bottom)); }
.scrim { background: #0009; display: flex; align-items: flex-end; animation: fade .15s; }
.scrim.center { align-items: center; justify-content: center; padding: 20px; }
@keyframes fade { from { opacity: 0; } }
.sheet { width: 100%; background: var(--surface); border-radius: 20px 20px 0 0; padding: 8px 8px calc(10px + var(--bottom)); max-height: 85vh; overflow-y: auto; animation: up .18s ease-out; }
@keyframes up { from { transform: translateY(40px); } }
.sheet .grab { width: 38px; height: 4px; background: var(--line); border-radius: 4px; margin: 4px auto 8px; }
.sheet .sheet-title { padding: 6px 12px 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.sheet button.opt { display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 14px; border-radius: 12px; text-align: left; font-weight: 500; }
.sheet button.opt:active { background: var(--surface2); }
.sheet button.opt.danger { color: var(--red); }
.sheet button.opt svg { color: var(--muted); }
.sheet button.opt.danger svg { color: var(--red); }
.dialog { width: 100%; max-width: 420px; background: var(--surface); border-radius: 20px; padding: 20px; max-height: 88vh; overflow-y: auto; animation: pop .15s ease-out; }
@keyframes pop { from { transform: scale(.96); opacity: 0; } }
.dialog h3 { margin: 0 0 8px; font-size: 18px; }
.dialog p { color: var(--muted); margin: 0 0 14px; }
.dialog .btns { justify-content: flex-end; margin-top: 16px; }
.fp { font-size: 12.5px; background: var(--bg); border-radius: 10px; padding: 10px; word-break: break-all; color: var(--text); -webkit-user-select: text; user-select: text; }
.pair-code { font: 700 38px ui-monospace, monospace; letter-spacing: 6px; text-align: center; padding: 16px; background: var(--bg); border-radius: 14px; color: var(--accent); }
.checks label { display: flex; align-items: center; gap: 12px; padding: 10px 2px; }
.checks input { width: 20px; height: 20px; accent-color: var(--accent); }
.checks h4 { margin: 12px 0 2px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .4px; }
#toasts { position: fixed; left: 16px; right: 16px; bottom: calc(84px + var(--bottom)); z-index: 50; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 12px; padding: 12px 14px; box-shadow: 0 8px 24px #0008; animation: up .2s; font-size: 14px; }
.toast.ok { border-color: var(--green); }
.toast.error { border-color: var(--red); }
/* ---------- Sperrbildschirm ---------- */
#lockScreen { position: fixed; inset: 0; z-index: 40; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 14px; }
#lockScreen img { width: 72px; height: 72px; margin-bottom: 6px; }
#lockScreen h2 { margin: 0; }
#lockScreen p { margin: 0 0 10px; color: var(--muted); text-align: center; }
#lockScreen form { width: 100%; max-width: 340px; display: flex; flex-direction: column; gap: 10px; }
#lockScreen .field { margin: 0; }
.bio-btn { width: 64px; height: 64px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center; margin-top: 10px; }
.bio-btn svg { width: 32px; height: 32px; }
/* ---------- Terminal ---------- */
#termScreen { position: fixed; left: 0; right: 0; top: 0; height: 100%; display: flex; flex-direction: column; background: var(--term-bg, #13151c); z-index: 10; }
.term-head { display: flex; align-items: center; gap: 2px; padding: calc(var(--top) + 2px) 4px 2px; background: var(--surface); border-bottom: 1px solid var(--line); }
.term-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; font-weight: 700; }
.term-title .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.term-title svg { width: 16px; height: 16px; color: var(--faint); }
.term-title .count { background: var(--surface2); color: var(--muted); border-radius: 8px; padding: 0 7px; font-size: 12px; }
#terms { flex: 1; position: relative; overflow: hidden; }
.term-wrap { position: absolute; inset: 0; padding: 4px 2px 0 6px; }
.term-wrap .xterm { height: 100%; }
.term-wrap .xterm-viewport { background-color: transparent !important; }
.term-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: var(--term-bg, #13151c); color: var(--muted); padding: 24px; text-align: center; z-index: 5; }
#keybar { display: flex; gap: 6px; overflow-x: auto; padding: 6px 6px calc(6px + var(--bottom)); background: var(--surface); border-top: 1px solid var(--line); scrollbar-width: none; }
#keybar::-webkit-scrollbar { display: none; }
#keybar button { flex: none; min-width: 44px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--surface2); font: 600 14px ui-monospace, monospace; color: var(--text); display: grid; place-items: center; }
#keybar button svg { width: 18px; height: 18px; }
#keybar button.on { background: var(--accent); color: var(--on-accent); }
#keybar button.lock { box-shadow: inset 0 0 0 2px var(--on-accent); }
body.kbd-open #keybar { padding-bottom: 6px; }
body.kbd-open #tabs, body.kbd-open .fab { display: none; }
/* Design-Extras */
[data-theme='xp'] .page-head, [data-theme='xp'] .term-head { background: linear-gradient(#0a5fdc, #0846b8 55%, #0a3f9f); color: #fff; border-color: #0a3f9f; }
[data-theme='xp'] .page-head .icon, [data-theme='xp'] .term-head .icon, [data-theme='xp'] .term-title svg { color: #fff; }
[data-theme='xp'] #tabs { background: linear-gradient(#3168d5, #1d4fbf); border-color: #0a3f9f; }
[data-theme='xp'] #tabs button { color: #c7d8ff; }
[data-theme='xp'] #tabs button.active { color: #fff; }
[data-theme='xp'] .btn.primary, [data-theme='xp'] .fab { background: linear-gradient(#3d86e8, #2159b8); }
[data-theme='cyberpunk'] .btn.primary, [data-theme='cyberpunk'] .fab { clip-path: polygon(0 0, 100% 0, 100% 70%, calc(100% - 12px) 100%, 0 100%); }
[data-theme='cyberpunk'] .btn.primary { text-transform: uppercase; letter-spacing: .6px; }
[data-theme='cyberpunk'] .top h1 { text-transform: uppercase; letter-spacing: 1px; text-shadow: 2px 0 #ff003c88, -2px 0 #00f0ff88; }
[data-theme='cyberpunk'] .list, [data-theme='cyberpunk'] .card { border-left: 2px solid var(--accent); }
[data-theme='cyberpunk'] #tabs button.active { color: var(--accent); text-shadow: 0 0 8px #fcee0a88; }
.theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 14px; }
.theme-prev { border-radius: 12px; padding: 8px; border: 2px solid var(--line); font-size: 12px; font-weight: 600; text-align: left; }
.theme-prev.active { border-color: var(--accent); }
.theme-prev .dp { display: flex; gap: 4px; height: 40px; margin-bottom: 6px; }
.theme-prev .dp i { width: 12px; border-radius: 3px; }
.theme-prev .dp div { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.theme-prev .dp b { flex: 1; border-radius: 3px; position: relative; }
.theme-prev .dp s { position: absolute; left: 4px; top: 4px; width: 14px; height: 6px; border-radius: 2px; }
.colors { display: flex; flex-wrap: wrap; gap: 10px; }
.colors button { width: 34px; height: 34px; border-radius: 50%; border: 3px solid transparent; }
.colors button.active { border-color: var(--text); }
/* macOS Liquid Glass */
[data-theme='macos'] #main, [data-theme='macos'] #view { background: transparent; }
[data-theme='macos'] :is(.list, .card, .search, #tabs, .page-head, .term-head, .sheet, .dialog, .toast, .chip, .btn, .fab, .field input, .field select, .field textarea, #keybar) {
-webkit-backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
}
[data-theme='macos'] :is(.list, .card, .search, .sheet, .dialog, .toast, .chip) { border: 1px solid var(--border); box-shadow: var(--glass-edge); }
[data-theme='macos'] .page, [data-theme='macos'] #lockScreen { background: var(--wallpaper); }
[data-theme='macos'] .page-body { background: transparent; }
[data-theme='macos'] #tabs, [data-theme='macos'] .page-head { background: rgba(255, 255, 255, .42); border-color: rgba(255, 255, 255, .7); }
[data-theme='macos'] .btn { border-radius: 999px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9); }
[data-theme='macos'] .btn.primary, [data-theme='macos'] .fab { background: linear-gradient(rgba(40, 145, 255, .92), rgba(0, 110, 240, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), 0 6px 18px rgba(0, 122, 255, .3); }
[data-theme='macos'] .fab { border-radius: 50%; }
[data-theme='macos'] .scrim { background: rgba(30, 40, 70, .18); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
[data-theme='macos'] .sheet, [data-theme='macos'] .dialog { background: rgba(255, 255, 255, .62); }
+511 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.10.2", "version": "0.23.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.10.2", "version": "0.23.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
@@ -18,7 +18,9 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
"electron-builder": "^26.15.3" "electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"ws": "^8.21.3"
} }
}, },
"node_modules/@electron-internal/extract-zip": { "node_modules/@electron-internal/extract-zip": {
@@ -325,6 +327,448 @@
"node": ">=14.14" "node": ">=14.14"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2071,6 +2515,48 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3992,6 +4478,28 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+7 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.10.2", "version": "0.23.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",
@@ -53,6 +53,7 @@
"directories": { "directories": {
"buildResources": "build" "buildResources": "build"
}, },
"beforePack": "scripts/ensure-koffi.js",
"asarUnpack": [ "asarUnpack": [
"node_modules/koffi/**", "node_modules/koffi/**",
"node_modules/@koromix/**" "node_modules/@koromix/**"
@@ -68,12 +69,15 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
"electron-builder": "^26.15.3" "electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"ws": "^8.21.3"
}, },
"allowScripts": { "allowScripts": {
"electron@44.4.5": true, "electron@44.4.5": true,
"electron-winstaller@5.4.0": true, "electron-winstaller@5.4.0": true,
"ssh2@1.17.0": true, "ssh2@1.17.0": true,
"koffi@3.3.1": true "koffi@3.3.1": true,
"esbuild@0.28.2": true
} }
} }
+19
View File
@@ -0,0 +1,19 @@
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { Arch } = require('electron-builder');
module.exports = async (context) => {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
const pkg = `@koromix/koffi-${platform}-${arch}`;
const root = path.join(__dirname, '..');
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
};
+34 -1
View File
@@ -1,12 +1,45 @@
// Erzeugt App-Icons aus assets/logo-full.png: npx electron scripts/make-icons.js // Erzeugt App-Icons aus assets/logo-full.png: npx electron scripts/make-icons.js
// Der dunkle Hintergrund um das Emblem wird freigestellt (transparent), das Gesicht innerhalb der Kontur bleibt.
const { app, nativeImage } = require('electron'); const { app, nativeImage } = require('electron');
const fs = require('fs'); const fs = require('fs');
// Von den Bildrändern aus fluten: Pixel ähnlich der Hintergrundfarbe werden transparent, der Kantensaum (max. 3 px)
// bekommt Transparenz aus seiner Helligkeit und wird gegen den Hintergrund "entmischt", damit kein dunkler Rand bleibt.
function cutOut(img) {
const { width: w, height: h } = img.getSize();
const src = img.toBitmap(); // BGRA
const out = Buffer.from(src);
const at = (x, y) => (y * w + x) * 4;
const bgI = at(w >> 1, 1);
const bg = [src[bgI + 2], src[bgI + 1], src[bgI]];
const seen = new Uint8Array(w * h);
const q = [];
for (let x = 0; x < w; x++) q.push([x, 0, 0], [x, h - 1, 0]);
for (let y = 0; y < h; y++) q.push([0, y, 0], [w - 1, y, 0]);
while (q.length) {
const [x, y, depth] = q.pop();
if (x < 0 || y < 0 || x >= w || y >= h || seen[y * w + x]) continue;
const i = at(x, y), r = src[i + 2], g = src[i + 1], b = src[i];
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
const bgLike = Math.hypot(r - bg[0], g - bg[1], b - bg[2]) < 34;
if (!bgLike && (lum >= 215 || depth >= 3)) continue;
seen[y * w + x] = 1;
const a = bgLike ? 0 : Math.max(0, Math.min(1, (lum - 40) / 195));
const un = (v, c) => Math.max(0, Math.min(255, Math.round((v - (1 - a) * c) / a)));
if (a <= 0) out.fill(0, i, i + 4);
else { out[i + 2] = un(r, bg[0]); out[i + 1] = un(g, bg[1]); out[i] = un(b, bg[2]); out[i + 3] = Math.round(a * 255); }
const nd = bgLike ? 0 : depth + 1;
q.push([x + 1, y, nd], [x - 1, y, nd], [x, y + 1, nd], [x, y - 1, nd]);
}
return nativeImage.createFromBitmap(out, { width: w, height: h });
}
app.whenReady().then(() => { app.whenReady().then(() => {
const src = nativeImage.createFromPath('assets/logo-full.png'); const src = nativeImage.createFromPath('assets/logo-full.png');
const { width } = src.getSize(); const { width } = src.getSize();
const k = width / 1254; const k = width / 1254;
// Ausschnitt: Kopf mit Hut, ohne Schriftzug // Ausschnitt: Kopf mit Hut, ohne Schriftzug
const head = src.crop({ x: Math.round(282 * k), y: Math.round(172 * k), width: Math.round(706 * k), height: Math.round(706 * k) }); const head = cutOut(src.crop({ x: Math.round(282 * k), y: Math.round(172 * k), width: Math.round(706 * k), height: Math.round(706 * k) }));
fs.writeFileSync('build/icon.png', head.resize({ width: 512, height: 512, quality: 'best' }).toPNG()); fs.writeFileSync('build/icon.png', head.resize({ width: 512, height: 512, quality: 'best' }).toPNG());
fs.writeFileSync('src/renderer/logo.png', head.resize({ width: 96, height: 96, quality: 'best' }).toPNG()); fs.writeFileSync('src/renderer/logo.png', head.resize({ width: 96, height: 96, quality: 'best' }).toPNG());
fs.writeFileSync('assets/icon.png', head.resize({ width: 256, height: 256, quality: 'best' }).toPNG()); fs.writeFileSync('assets/icon.png', head.resize({ width: 256, height: 256, quality: 'best' }).toPNG());
+8 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fragt nach der Version, baut Windows (Setup + Portable, per wine) und Arch/CachyOS (pacman + AppImage) # Fragt nach der Version, baut Windows (Setup + Portable, per wine), Arch/CachyOS (pacman + AppImage)
# und lädt alles als Release v<version> nach Gitea hoch. # und Android (APK, signiert mit mobile/android/keystore.properties) und lädt alles als Release v<version> nach Gitea hoch.
# Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt). # Token: Umgebungsvariable GITEA_TOKEN oder Datei .gitea-token im Projektordner (nicht eingecheckt).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -31,5 +31,11 @@ git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
rm -rf dist rm -rf dist
npm run dist:linux npm run dist:linux
npm run dist:win npm run dist:win
if [[ -f mobile/android/keystore.properties ]]; then
[[ -d mobile/node_modules/capacitor-nodejs ]] || (cd mobile && node build.js --setup)
(cd mobile && node build.js --apk)
else
echo "mobile/android/keystore.properties fehlt – Android-APK wird übersprungen"
fi
npm run release -- --notes "$notes" "${prerelease[@]}" npm run release -- --notes "$notes" "${prerelease[@]}"
+1 -1
View File
@@ -28,7 +28,7 @@ const arg = (n) => { const i = process.argv.indexOf(n); return i > 0 ? process.a
rel = await res.json(); rel = await res.json();
console.log('Release angelegt:', rel.html_url); console.log('Release angelegt:', rel.html_url);
} }
const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman)$/.test(f)); const files = fs.readdirSync('dist').filter((f) => f.includes(version) && /\.(exe|AppImage|pacman|apk)$/.test(f));
if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.'); if (!files.length) throw new Error('Keine Pakete für ' + version + ' in dist/ gefunden.');
for (const f of files) { for (const f of files) {
const old = (rel.assets || []).find((a) => a.name === f); const old = (rel.assets || []).find((a) => a.name === f);
+251
View File
@@ -0,0 +1,251 @@
// Gemeinsame Backend-Kanäle für die Android-App und die Web-Version (Docker).
// Der Electron-Hauptprozess (src/main/main.js) registriert seine Kanäle selbst, nutzt aber dieselben Module.
//
// createBackend({ store, send, platform, version, withSync, onLanguage })
// store Store-Instanz (bereits geladen)
// send (kanal, ...args) → Ereignis an die Oberfläche
// → { call(kanal, args), notify(kanal, args), close(), ssh, sync, handle }
const crypto = require('crypto');
const { utils: sshUtils } = require('ssh2');
const { SshManager } = require('../main/ssh');
const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network');
const { WebServerManager } = require('../main/webserver');
const { CertManager, probe: probeTls } = require('../main/certs');
const { SambaManager } = require('../main/samba');
const { AiAssistant } = require('../main/ai');
const i18n = require('../i18n');
const backup = require('./backup');
const composeTemplates = require('../main/compose-templates');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
const pending = new Map();
const ask = (ch, req, timeout = 0) => new Promise((resolve) => {
const reqId = crypto.randomUUID();
pending.set(reqId, resolve);
send(ch, { reqId, ...req });
if (timeout) setTimeout(() => { if (pending.delete(reqId)) resolve(null); }, timeout);
});
const answer = (reqId, value) => { const r = pending.get(reqId); pending.delete(reqId); r?.(value); };
const confirmHostKey = async (target, fingerprint, known) =>
!!(await ask('hostkey:request', { host: `${target.host}:${target.port}`, fingerprint, previous: known?.fingerprint || '' }));
const askSecret = (sessionId, req) => ask('secret:request', { sessionId, ...req });
const ssh = new SshManager(store, confirmHostKey, askSecret);
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
const ai = new AiAssistant(store, send);
let sync = null;
if (withSync) {
const { SyncService } = require('../main/sync');
sync = new SyncService(store, send, async (req) => !!(await ask('sync:pairPrompt', req, 115000)));
store.onChange = () => sync.schedule();
}
const handlers = new Map();
const handle = (ch, fn) => handlers.set(ch, fn);
const OPEN_WHILE_LOCKED = new Set(['app:version']);
async function call(ch, args = []) {
const fn = handlers.get(ch);
if (!fn) throw new Error(`Unknown channel ${ch}`);
if (store.locked && !ch.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(ch)) throw new Error(i18n.t('MrTerm is locked.'));
return fn(...args);
}
const listeners = {
'ssh:write': (id, d) => ssh.write(id, d),
'ssh:resize': (id, c, r) => ssh.resize(id, c, r),
'ssh:close': (id) => ssh.close(id),
'ask:reply': answer,
// Namen der Desktop-Vorlage (preload.js)
'secret:reply': answer,
'sync:pairReply': answer,
};
const notify = (ch, args = []) => { try { listeners[ch]?.(...args); } catch (e) { console.error(e); } };
// ---------- App-Sperre (Passwort) ----------
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
const lockStatus = () => {
const { language, appTheme, accent } = store.get().settings;
return { enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password, bio: !!store.lock?.bio, fido: [], meta: { language, appTheme, accent } };
};
const requireUnlocked = () => { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); };
const afterUnlock = () => { onLanguage(store.get().settings.language); sync?.start().catch(() => {}); };
handle('lock:status', lockStatus);
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
handle('lock:unlockPassword', async (pw) => {
const p = store.lock?.password;
if (!p) throw new Error(i18n.t('No password set.'));
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
afterUnlock();
return true;
});
handle('lock:setPassword', async (pw) => {
requireUnlocked();
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
const salt = crypto.randomBytes(16), N = 2 ** 15;
const kek = await kdf(pw, salt, N);
store.lock = store.lock || { password: null, fido: [] };
store.lock.password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
store.save();
return lockStatus();
});
handle('lock:removePassword', () => {
requireUnlocked();
if (store.lock) { store.lock.password = null; store.lock.bio = null; }
store.dropLockIfEmpty();
store.save();
return lockStatus();
});
// ---------- Tresor ----------
const publicData = () => { const { sync: _s, tombstones: _t, ...rest } = store.get(); return rest; };
handle('vault:get', () => ({ ...publicData(), encrypted: store.encrypted, platform }));
handle('vault:upsert', (col, item) => store.upsert(col, item));
handle('vault:remove', (col, id) => {
if (col === 'vpns') store.get().hosts.forEach((h) => { if (h.vpnId === id) { h.vpnId = null; h.updatedAt = Date.now(); } });
return store.remove(col, id);
});
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version }));
handle('backup:inspect', (content) => backup.inspectBackup(content));
handle('backup:import', (content, password, opts) => {
const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) onLanguage(store.get().settings.language);
return r;
});
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
handle('vault:bulkImport', (entries) => {
const groups = store.get().groups;
const ensure = (parts) => {
let parent = null;
for (const label of parts) {
let g = groups.find((x) => (x.parentId || null) === parent && x.label.toLowerCase() === label.toLowerCase());
if (!g) { g = { id: crypto.randomUUID(), label, parentId: parent, createdAt: Date.now() }; groups.push(g); }
parent = g.id;
}
return parent;
};
let hosts = 0, skipped = 0;
for (const e of entries) {
const groupId = ensure(e.folder || []);
if (!e.host) continue;
const dup = store.get().hosts.find((h) => h.address === e.host.address && (h.groupId || null) === groupId && (h.label || '') === (e.host.label || '') && (h.protocol || 'ssh') === e.host.protocol);
if (dup) { skipped++; continue; }
store.get().hosts.push({ ...e.host, id: crypto.randomUUID(), groupId, createdAt: Date.now(), updatedAt: Date.now() });
hosts++;
}
store.save();
return { hosts, skipped, groups: groups.length };
});
handle('app:version', () => version);
// ---------- Schlüssel ----------
handle('key:generate', ({ type, bits, comment, passphrase }) => {
const opts = { comment: comment || `mrterm@${platform}` };
if (type === 'rsa') opts.bits = Number(bits) || 4096;
if (passphrase) { opts.passphrase = passphrase; opts.cipher = 'aes256-cbc'; }
const k = sshUtils.generateKeyPairSync(type === 'rsa' ? 'rsa' : type === 'ecdsa' ? 'ecdsa' : 'ed25519', opts);
return { privateKey: k.private, publicKey: k.public };
});
handle('key:parse', ({ privateKey, passphrase }) => {
const k = sshUtils.parseKey(privateKey, passphrase || undefined);
if (k instanceof Error) throw k;
const key = Array.isArray(k) ? k[0] : k;
return { type: key.type, publicKey: `${key.type} ${key.getPublicSSH().toString('base64')} ${key.comment || ''}`.trim() };
});
// ---------- SSH-Terminal & SFTP ----------
function hostWithOverrides(ref) {
if (typeof ref === 'string') {
const h = store.resolveHost(ref);
if (!h) throw new Error(i18n.t('Host not found'));
return h;
}
if (ref?.ref) return { ...hostWithOverrides(ref.ref), execCommand: ref.execCommand, label: ref.label };
return ref;
}
handle('ssh:open', async (sessionId, ref, size) => {
const host = hostWithOverrides(ref);
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
return true;
});
handle('sftp:open', (id, ref) => ssh.openSftp(id, hostWithOverrides(ref)));
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
handle('sftp:close', (id) => ssh.close(id));
// ---------- Docker / Firewall / Netzwerk ----------
handle('docker:open', (id, ref) => docker.open(id, hostWithOverrides(ref), () => send('docker:closed', id)));
handle('docker:list', (id) => docker.list(id));
handle('docker:stats', (id) => docker.stats(id));
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
handle('docker:close', (id) => docker.close(id));
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
handle('docker:templates', (url) => composeTemplates.list(url));
handle('docker:template', (index, url) => composeTemplates.build(index, url));
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
handle('firewall:list', (id, backend) => firewall.list(id, backend));
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
handle('firewall:ipt', (id, op, args) => firewall.ipt(id, op, args));
handle('firewall:close', (id) => firewall.close(id));
handle('network:open', (id, ref) => network.open(id, hostWithOverrides(ref), () => send('network:closed', id)));
handle('network:read', (id) => network.read(id));
handle('network:save', (id, model, verify) => network.saveInterface(id, model, verify));
handle('network:remove', (id, model) => network.removeInterface(id, model));
handle('network:hostname', (id, name) => network.setHostname(id, name));
handle('network:resolv', (id, servers, search) => network.setResolv(id, servers, search));
handle('network:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id));
for (const [p, m] of Object.entries(admins)) {
handle(`${p}:open`, (id, ref) => m.open(id, hostWithOverrides(ref), () => send(`${p}:closed`, id)));
handle(`${p}:read`, (id) => m.read(id));
handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args));
handle(`${p}:close`, (id) => m.close(id));
}
handle('certs:probe', (host, port) => probeTls(host, port));
// ---------- Support-Assistent (Ollama) ----------
handle('ai:models', (url) => ai.models(url));
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
handle('ai:stop', (id) => ai.stop(id));
// ---------- Synchronisation ----------
if (sync) {
handle('sync:status', () => sync.status());
handle('sync:enable', (on, name) => sync.setEnabled(on, name));
handle('sync:pairable', (on) => { sync.setPairable(on); return sync.status(); });
handle('sync:pair', (id) => sync.pair(id));
handle('sync:unpair', (id) => sync.unpair(id));
handle('sync:now', () => sync.syncAll());
handle('sync:share', (sel) => sync.setShare(sel));
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
handle('sync:probe', (address) => sync.probe(address));
}
function close() {
ssh.closeAll?.();
ai.stopAll();
sync?.stop();
for (const r of pending.values()) r(null);
pending.clear();
}
return { call, notify, close, handle, ssh, sync, store, kdf, requireUnlocked, lockStatus };
}
module.exports = { createBackend };
+99
View File
@@ -0,0 +1,99 @@
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
const crypto = require('crypto');
const FORMAT = 'mrterm-backup';
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
const KDF = { N: 1 << 16, r: 8, p: 1 };
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
function snapshot(store, { settings = true } = {}) {
const d = store.get();
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
for (const c of COLLECTIONS) out[c] = d[c] || [];
if (settings) out.settings = d.settings;
return out;
}
// Liefert den Dateiinhalt (JSON-Text)
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
const data = snapshot(store, { settings });
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
const salt = crypto.randomBytes(16);
const key = scrypt(password, salt, KDF);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
c.setAAD(Buffer.from(FORMAT));
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
return JSON.stringify({
...head, encrypted: true,
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
ct: ct.toString('base64'),
}, null, 2);
}
function parse(content) {
let j;
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
if (!j || typeof j !== 'object') throw new Error('INVALID');
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
if (j.format !== FORMAT) {
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
return { encrypted: false, data: j, createdAt: null, app: '' };
}
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
}
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
// Vorabinfo für die Oberfläche (ohne Passwort)
function inspectBackup(content) {
const b = parse(content);
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
}
function decrypt(content, password) {
const b = parse(content);
if (!b.encrypted) return b.data;
if (!password) throw new Error('PASSWORD_REQUIRED');
const { kdf, cipher, ct } = b.raw;
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
try {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
d.setAAD(Buffer.from(FORMAT));
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
} catch { throw new Error('WRONG_PASSWORD'); }
}
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
const data = decrypt(content, password);
const d = store.get();
store.muted = true;
try {
for (const c of COLLECTIONS) {
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
if (replace) {
const keep = new Set(items.map((x) => x.id).filter(Boolean));
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
}
for (const item of items) store.upsert(c, { ...item });
}
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
if (Array.isArray(data.history)) {
const seen = new Set();
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
}
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
} finally {
store.muted = false;
}
store.save();
return counts(data);
}
module.exports = { createBackup, inspectBackup, importBackup };
+553 -1
View File
@@ -151,6 +151,42 @@
'Send Enter automatically': 'Enter automatisch senden', 'Send Enter automatically': 'Enter automatisch senden',
'Command is missing.': 'Befehl fehlt.', 'Command is missing.': 'Befehl fehlt.',
'No open terminal': 'Kein offenes Terminal', 'No open terminal': 'Kein offenes Terminal',
'Upload files …': 'Dateien hochladen …',
'Root privileges are required. Log in as root or enter the password of a user with sudo rights.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder gib das Passwort eines Benutzers mit sudo-Rechten ein.',
'sudo password': 'sudo-Passwort',
'sudo password for {user}': 'sudo-Passwort für {user}',
'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}',
'Open in new window': 'In neuem Fenster öffnen',
'Back to main window': 'Zurück ins Hauptfenster',
// Support-Assistent
'Delete chat': 'Chat löschen',
'Delete chat?': 'Chat löschen?',
'Saved chats': 'Gespeicherte Chats',
'Context:': 'Kontext:',
'Vault (no context)': 'Tresor (kein Kontext)',
'Explain this tab': 'Diesen Tab erklären',
'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.',
'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.',
'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.',
'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv',
'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale <b>Ollama</b>-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.',
'Support assistant (Ollama)': 'Support-Assistent (Ollama)',
'Assistant': 'Assistent',
'Support assistant': 'Support-Assistent',
'New chat': 'Neuer Chat',
'Include terminal output': 'Terminalausgabe mitsenden',
'Describe the problem … (Enter = send)': 'Problem beschreiben … (Enter = senden)',
'Explain last error': 'Letzten Fehler erklären',
'Send': 'Senden',
'Explain the last error in the terminal output and how to fix it.': 'Erkläre den letzten Fehler in der Terminalausgabe und wie ich ihn behebe.',
'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.',
'Insert into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)',
'Insert': 'Einfügen',
'Ollama server': 'Ollama-Server',
'Model': 'Modell',
'– select –': '– auswählen –',
'No models installed (ollama pull …)': 'Keine Modelle installiert (ollama pull …)',
'Load models': 'Modelle laden',
'No snippets yet.': 'Noch keine Snippets.', 'No snippets yet.': 'Noch keine Snippets.',
// Port Forwarding // Port Forwarding
@@ -277,6 +313,7 @@
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes 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.', 'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
'RDP session ended.': 'RDP-Sitzung beendet.', 'RDP session ended.': 'RDP-Sitzung beendet.',
'Adjusting resolution …': 'Auflösung wird angepasst …',
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).', 'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter', 'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
@@ -351,6 +388,93 @@
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.', '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.', '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.', 'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.',
'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.',
'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.',
'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.',
'Include settings': 'Einstellungen einschließen',
'Export': 'Exportieren',
'Export without password?': 'Ohne Passwort exportieren?',
'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.',
'{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs',
'Contents are encrypted.': 'Inhalt ist verschlüsselt.',
'Import settings': 'Einstellungen übernehmen',
'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)',
'Replace existing data?': 'Vorhandene Daten ersetzen?',
'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.',
'Replace': 'Ersetzen',
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
'Stacks folder': 'Stack-Ordner',
'Folder on the host in which each stack gets its own subfolder': 'Ordner auf dem Host, in dem jeder Stack einen eigenen Unterordner bekommt',
'Template source': 'Vorlagen-Quelle',
'URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.': 'URL einer templates.json im Portainer-Format. Leer = Lissy93/portainer-templates.',
'Containers': 'Container',
'Folder': 'Ordner',
'Change folder': 'Ordner ändern',
'Creates the folder with sudo and makes it writable for {user}.': 'Legt den Ordner per sudo an und macht ihn für {user} beschreibbar.',
'Set up folder': 'Ordner einrichten',
'From Git': 'Aus Git',
'New stack': 'Neuer Stack',
'Compose file': 'Compose-Datei',
'Found via docker compose ls, outside the stacks folder': 'Über docker compose ls gefunden, außerhalb des Stack-Ordners',
'external': 'extern',
'not deployed': 'nicht gestartet',
'no compose file found': 'keine Compose-Datei gefunden',
'Deploy (up -d)': 'Starten (up -d)',
'No matches.': 'Keine Treffer.',
'No stacks yet. Create one, clone it from Git or start from a template.': 'Noch keine Stacks. Lege einen an, klone ihn aus Git oder starte mit einer Vorlage.',
'Update images (pull + up)': 'Images aktualisieren (pull + up)',
'Git pull + redeploy': 'Git pull + neu starten',
'Down (remove containers)': 'Down (Container entfernen)',
'Stack from Git': 'Stack aus Git',
'Repository URL': 'Repository-URL',
'Stack name': 'Stack-Name',
'Branch (optional)': 'Branch (optional)',
'Compose file in the repository (optional)': 'Compose-Datei im Repository (optional)',
'If empty, the first compose file found is used.': 'Leer = die erste gefundene Compose-Datei wird verwendet.',
'Deploy right after cloning': 'Nach dem Klonen direkt starten',
'The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.': 'Das Repository wird auf dem Host in den Stack-Ordner geklont. Git fragt Zugangsdaten bei Bedarf im Terminal ab; für private Repos funktioniert auch ein SSH-Deploy-Key oder ein Token in der URL.',
'Clone': 'Klonen',
'App templates': 'App-Vorlagen',
'Search templates …': 'Vorlagen durchsuchen …',
'Source: {url} (Portainer template format, changeable in the settings).': 'Quelle: {url} (Portainer-Vorlagenformat, in den Einstellungen änderbar).',
'All categories': 'Alle Kategorien',
'Delete stack?': 'Stack löschen?',
'The stack “{name}” is stopped (down) and its folder {dir} is deleted.': 'Der Stack „{name}“ wird gestoppt (down) und sein Ordner {dir} gelöscht.',
'Also delete named volumes (data!)': 'Auch benannte Volumes löschen (Daten!)',
'Remove containers?': 'Container entfernen?',
'docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.': 'docker compose down stoppt und entfernt die Container von „{name}“. Volumes und Dateien bleiben erhalten.',
'Down': 'Down',
'Back': 'Zurück',
'Save & deploy': 'Speichern & starten',
'(variables, used as ${NAME} in the compose file)': '(Variablen, in der Compose-Datei als ${NAME} nutzbar)',
'Saved, but the compose file has an error: {msg}': 'Gespeichert, aber die Compose-Datei enthält einen Fehler: {msg}',
'Docker Compose was not found on this host (docker compose plugin or docker-compose).': 'Docker Compose wurde auf diesem Host nicht gefunden (docker-compose-Plugin oder docker-compose).',
'Could not read {file}': '{file} konnte nicht gelesen werden',
'Stack names may only contain lowercase letters, digits, - and _.': 'Stack-Namen dürfen nur Kleinbuchstaben, Ziffern, - und _ enthalten.',
'A stack named “{name}” already exists.': 'Ein Stack namens „{name}“ existiert bereits.',
'No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.': 'Keine Schreibrechte in {dir}. Nutze „Ordner einrichten“ oder speichere das Host-Passwort, damit MrTerm sudo verwenden kann.',
'Templates': 'Vorlagen',
'Run': 'Ausführen',
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
'Key': 'Schlüssel',
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
'User is missing.': 'Benutzer fehlt.',
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
'Use APT cache server': 'APT-Cache-Server verwenden',
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
'Proxy URL': 'Proxy-URL',
'Invalid URL.': 'Ungültige URL.',
'Remove APT cache server': 'APT-Cache-Server entfernen',
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
'Wrong PIN.': 'Falsche PIN.',
'{n} attempts left.': 'Noch {n} Versuche.',
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
// VPN // VPN
'— No VPN —': '— Kein VPN —', '— No VPN —': '— Kein VPN —',
@@ -461,7 +585,6 @@
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}', 'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
'Firewall session not found': 'Firewall-Sitzung nicht gefunden', 'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}', 'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.', 'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.', 'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).', 'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
@@ -597,6 +720,91 @@
'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.', 'Should this secret be synchronized to your paired devices ({names})? You can change this later under Settings → Synchronization.': 'Soll dieses Geheimnis mit deinen gekoppelten Geräten ({names}) synchronisiert werden? Du kannst das später unter Einstellungen → Synchronisation ändern.',
'Don\'t share': 'Nicht teilen', 'Don\'t share': 'Nicht teilen',
'Share': 'Teilen', 'Share': 'Teilen',
// Android-App
'Lock': 'Sperren',
'Add your first server or pair this phone with MrTerm on your computer to take over your hosts.': 'Lege deinen ersten Server an oder kopple dieses Handy mit MrTerm auf deinem Computer, um deine Hosts zu übernehmen.',
'No matching hosts. Press Enter to connect to “{q}”.': 'Keine passenden Hosts. Drücke Enter, um dich mit „{q}“ zu verbinden.',
'RDP is not available on Android': 'RDP ist unter Android nicht verfügbar',
'Please enter a name.': 'Bitte einen Namen eingeben.',
'Sessions': 'Sitzungen',
'New connection': 'Neue Verbindung',
'Paste': 'Einfügen',
'Copy selection': 'Auswahl kopieren',
'Nothing selected. Long-press the terminal to select text.': 'Nichts ausgewählt. Halte das Terminal gedrückt, um Text auszuwählen.',
'Larger font': 'Schrift größer',
'Smaller font': 'Schrift kleiner',
'Close session': 'Sitzung schließen',
'Generate a new SSH key or import an existing one.': 'Erzeuge einen neuen SSH-Schlüssel oder importiere einen vorhandenen.',
'Key created': 'Schlüssel erstellt',
'Add the public key to ~/.ssh/authorized_keys on your servers.': 'Trage den öffentlichen Schlüssel auf deinen Servern in ~/.ssh/authorized_keys ein.',
'Save frequently used commands and send them to a terminal with one tap.': 'Speichere häufig genutzte Befehle und sende sie mit einem Tippen an ein Terminal.',
'Run in terminal': 'Im Terminal ausführen',
'Command': 'Befehl',
'End the command with a line break to run it immediately.': 'Endet der Befehl mit einem Zeilenumbruch, wird er sofort ausgeführt.',
'Please enter a command.': 'Bitte einen Befehl eingeben.',
'General': 'Allgemein',
'System language': 'Systemsprache',
'Known hosts': 'Bekannte Hosts',
'{n} saved host keys': '{n} gespeicherte Hostschlüssel',
'Show': 'Anzeigen',
'Version {v}': 'Version {v}',
'Updates are installed with Obtainium.': 'Updates werden mit Obtainium installiert.',
'No known hosts yet.': 'Noch keine bekannten Hosts.',
'Remove host key?': 'Hostschlüssel entfernen?',
'Immediately': 'Sofort',
'After 1 minute': 'Nach 1 Minute',
'After 5 minutes': 'Nach 5 Minuten',
'Protect MrTerm with a password and optionally your fingerprint. The vault is then additionally encrypted.': 'Schütze MrTerm mit einem Passwort und optional deinem Fingerabdruck. Der Tresor wird dann zusätzlich verschlüsselt.',
'Remove app lock': 'App-Sperre entfernen',
'Remove app lock?': 'App-Sperre entfernen?',
'MrTerm will open without a password.': 'MrTerm öffnet sich dann ohne Passwort.',
'Unlock with fingerprint': 'Mit Fingerabdruck entsperren',
'Uses the fingerprint or face unlock of this phone.': 'Nutzt den Fingerabdruck- oder Gesichtsscanner dieses Handys.',
'Confirm fingerprint': 'Fingerabdruck bestätigen',
'Lock when leaving the app': 'Sperren beim Verlassen der App',
'Enter your password to unlock.': 'Gib dein Passwort ein, um zu entsperren.',
'Unlock MrTerm': 'MrTerm entsperren',
'Use password': 'Passwort verwenden',
'Your fingerprints have changed. Unlock with your password and set up fingerprint unlock again.': 'Deine Fingerabdrücke haben sich geändert. Entsperre mit deinem Passwort und richte das Entsperren per Fingerabdruck neu ein.',
'Take over hosts, groups, snippets and known hosts from MrTerm on your computer. The connection is end-to-end encrypted and stays in your local network.': 'Übernimm Hosts, Gruppen, Snippets und bekannte Hosts von MrTerm auf deinem Computer. Die Verbindung ist Ende-zu-Ende verschlüsselt und bleibt in deinem lokalen Netzwerk.',
'Both devices must be in the same Wi-Fi. If nothing is found, allow UDP port 47811 and TCP port 47812 in the firewall of your computer.': 'Beide Geräte müssen im selben WLAN sein. Wird nichts gefunden, gib in der Firewall deines Computers UDP-Port 47811 und TCP-Port 47812 frei.',
'Or enter an IP address': 'Oder IP-Adresse eingeben',
'Copy failed': 'Kopieren fehlgeschlagen',
'Fingerprint unlock is not set up.': 'Entsperren per Fingerabdruck ist nicht eingerichtet.',
'Fingerprint unlock failed. Use your password.': 'Entsperren per Fingerabdruck fehlgeschlagen. Verwende dein Passwort.',
'Set a password first.': 'Lege zuerst ein Passwort fest.',
// Web-Version
'RDP is not available in the web version.': 'RDP ist in der Web-Version nicht verfügbar.',
'Account': 'Konto',
'Signed in as {name}': 'Angemeldet als {name}',
'Administrator': 'Administrator',
'Current password': 'Aktuelles Passwort',
'Sign out': 'Abmelden',
'Users': 'Benutzer',
'User': 'Benutzer',
'Delete user?': 'Benutzer löschen?',
'“{name}” and their vault will be permanently deleted.': '„{name}“ und der zugehörige Tresor werden endgültig gelöscht.',
'Add user': 'Benutzer hinzufügen',
'Your vault is stored on the server, encrypted with a key that only your login password can unlock.': 'Dein Tresor liegt auf dem Server, verschlüsselt mit einem Schlüssel, den nur dein Login-Passwort entsperren kann.',
'Folders cannot be downloaded in the browser: {name}': 'Ordner können im Browser nicht heruntergeladen werden: {name}',
'Welcome! Create the administrator account for this MrTerm server.': 'Willkommen! Lege das Administrator-Konto für diesen MrTerm-Server an.',
'Sign in to your MrTerm server.': 'Melde dich an deinem MrTerm-Server an.',
'Create account': 'Konto anlegen',
'Sign in': 'Anmelden',
'Administrators only.': 'Nur für Administratoren.',
'Forbidden': 'Nicht erlaubt',
'Not signed in': 'Nicht angemeldet',
'Setup is already complete.': 'Die Einrichtung ist bereits abgeschlossen.',
'The current password is wrong.': 'Das aktuelle Passwort ist falsch.',
'The last administrator cannot be deleted.': 'Der letzte Administrator kann nicht gelöscht werden.',
'Too many failed attempts. Try again in 15 minutes.': 'Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.',
'User not found.': 'Benutzer nicht gefunden.',
'Wrong username or password.': 'Benutzername oder Passwort falsch.',
'You cannot delete yourself.': 'Du kannst dich nicht selbst löschen.',
'The password must be at least 8 characters long.': 'Das Passwort muss mindestens 8 Zeichen lang sein.',
'This username is already taken.': 'Dieser Benutzername ist bereits vergeben.',
'Usernames may contain letters, digits and . _ @ - (max. 64).': 'Benutzernamen dürfen Buchstaben, Ziffern und . _ @ - enthalten (max. 64).',
'Connection lost – reconnecting …': 'Verbindung verloren – verbinde neu …',
// Main-Prozess // Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host', 'Unknown host': 'Unbekannter Host',
@@ -608,6 +816,350 @@
'~/.ssh/config not found': '~/.ssh/config nicht gefunden', '~/.ssh/config not found': '~/.ssh/config nicht gefunden',
'Host not found': 'Host nicht gefunden', 'Host not found': 'Host nicht gefunden',
'Rule not found': 'Regel nicht gefunden', 'Rule not found': 'Regel nicht gefunden',
// Feature-Menüs & Verwaltungs-Tabs (gemeinsam)
'Web server': 'Webserver',
'Certificates': 'Zertifikate',
'Feature menus': 'Feature-Menüs',
'Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).': 'Lege fest, welche Verwaltungs-Tabs für SSH-Hosts angeboten werden (Host-Menü, Terminal-Leiste und Schnellsuche).',
'Animated background on the start page': 'Animierter Hintergrund auf der Startseite',
'failed': 'fehlgeschlagen',
'starting': 'startet',
'unknown': 'unbekannt',
'expired': 'abgelaufen',
'{n} days': '{n} Tage',
'Stop {name}?': '{name} stoppen?',
'The service will stop serving requests.': 'Der Dienst beantwortet dann keine Anfragen mehr.',
'{name} stopped': '{name} gestoppt',
'{name} started': '{name} gestartet',
'{name} restarted': '{name} neu gestartet',
'Reload configuration': 'Konfiguration neu laden',
'Configuration reloaded': 'Konfiguration neu geladen',
'Start automatically at boot': 'Beim Systemstart automatisch starten',
'Autostart': 'Autostart',
'Save & apply': 'Speichern & anwenden',
'Saved and applied.': 'Gespeichert und angewendet.',
'not installed': 'nicht installiert',
'Install': 'Installieren',
'Service': 'Dienst',
'Services': 'Dienste',
'Path': 'Pfad',
'File': 'Datei',
'Description': 'Beschreibung',
'Create': 'Erstellen',
'Details': 'Details',
'Loading …': 'Lade …',
'Session not found': 'Sitzung nicht gefunden',
'The configuration is invalid, the previous version was restored: {err}': 'Die Konfiguration ist ungültig, der vorherige Stand wurde wiederhergestellt: {err}',
'Saved, but reloading failed: {err}': 'Gespeichert, aber das Neuladen ist fehlgeschlagen: {err}',
'The configuration is invalid: {err}': 'Die Konfiguration ist ungültig: {err}',
'No known package manager on this host.': 'Auf diesem Host wurde keine bekannte Paketverwaltung gefunden.',
'Please enter at least one domain.': 'Bitte mindestens eine Domain eingeben.',
'Please enter a password.': 'Bitte ein Passwort eingeben.',
'Timeout': 'Zeitüberschreitung',
// Webserver
'Overview': 'Übersicht',
'Sites': 'Sites',
'Site': 'Site',
'Server': 'Server',
'Domains': 'Domains',
'Test configuration': 'Konfiguration testen',
'Configuration OK': 'Konfiguration in Ordnung',
'Configuration has errors': 'Konfiguration fehlerhaft',
'Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.': 'Es laufen mehrere Webserver ({list}). Nur einer davon kann Port 80/443 nutzen – achte darauf, dass sie auf unterschiedlichen Ports lauschen.',
'Install PHP-FPM': 'PHP-FPM installieren',
'No PHP-FPM service found.': 'Kein PHP-FPM-Dienst gefunden.',
'Sockets': 'Sockets',
'New site': 'Neue Site',
'No web server installed. Install nginx, Apache or Caddy in the overview.': 'Kein Webserver installiert. Installiere nginx, Apache oder Caddy in der Übersicht.',
'No sites configured yet.': 'Noch keine Sites eingerichtet.',
'Site disabled': 'Site deaktiviert',
'Site enabled': 'Site aktiviert',
'Delete site?': 'Site löschen?',
'The block “{name}” will be removed from the Caddyfile.': 'Der Block „{name}“ wird aus dem Caddyfile entfernt.',
'{path} will be deleted (a copy is kept in /var/backups/mrterm).': '{path} wird gelöscht (eine Kopie bleibt in /var/backups/mrterm).',
'Site deleted': 'Site gelöscht',
'Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.': 'Jede Änderung wird vor dem Neuladen mit dem Konfigurationstest des Webservers geprüft. Schlägt der Test fehl, wird der vorherige Stand wiederhergestellt.',
'Configuration files': 'Konfigurationsdateien',
'No configuration files found.': 'Keine Konfigurationsdateien gefunden.',
'Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.': 'Beim Speichern wird die Konfiguration geprüft und der Dienst neu geladen. Schlägt die Prüfung fehl, wird der vorherige Stand wiederhergestellt.',
'Static website': 'Statische Website',
'Reverse proxy': 'Reverse Proxy',
'Name of the configuration file': 'Name der Konfigurationsdatei',
'optional – default: first domain': 'optional – Standard: erste Domain',
'Document root': 'Dokumentenverzeichnis',
'Target (upstream)': 'Ziel (Upstream)',
'PHP-FPM socket': 'PHP-FPM-Socket',
'Create the folder with a placeholder page if it does not exist': 'Ordner mit Platzhalterseite anlegen, falls er fehlt',
'Caddy requests HTTPS certificates for public domains automatically.': 'Caddy holt sich für öffentliche Domains automatisch HTTPS-Zertifikate.',
'Site created': 'Site angelegt',
'Tip: request an HTTPS certificate in the “Certificates” tab.': 'Tipp: Ein HTTPS-Zertifikat bekommst du im Tab „Zertifikate“.',
'A site named “{name}” already exists.': 'Eine Site namens „{name}“ gibt es bereits.',
'Site not found': 'Site nicht gefunden',
// Zertifikate
'Check endpoint': 'Endpunkt prüfen',
'Automatic renewal': 'Automatische Erneuerung',
'not set up': 'nicht eingerichtet',
'Automatic renewal enabled': 'Automatische Erneuerung aktiviert',
'Request certificate': 'Zertifikat anfordern',
'Renew all': 'Alle erneuern',
'Renew certificates': 'Zertifikate erneuern',
'Test renewal': 'Erneuerung testen',
'Install certbot': 'certbot installieren',
'Self-signed …': 'Selbstsigniert …',
"certbot is not installed. Install it to request free certificates from Let's Encrypt.": 'certbot ist nicht installiert. Installiere es, um kostenlose Zertifikate von Let’s Encrypt anzufordern.',
'No certificates yet.': 'Noch keine Zertifikate.',
'Expires': 'Läuft ab',
'Renew': 'Erneuern',
'Delete certificate?': 'Zertifikat löschen?',
'“{name}” and its files will be deleted. Web server configurations that still use it will fail.': '„{name}“ und seine Dateien werden gelöscht. Webserver-Konfigurationen, die es noch verwenden, schlagen dann fehl.',
'Certificate deleted': 'Zertifikat gelöscht',
'Next renewal': 'Nächste Erneuerung',
'Remove certificate?': 'Zertifikat entfernen?',
'acme.sh will no longer renew “{name}”.': 'acme.sh erneuert „{name}“ dann nicht mehr.',
'Removed': 'Entfernt',
'Certificate files': 'Zertifikatsdateien',
'No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).': 'Keine weiteren Zertifikatsdateien gefunden (durchsucht: /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).',
'nginx (configures HTTPS automatically)': 'nginx (richtet HTTPS automatisch ein)',
'Apache (configures HTTPS automatically)': 'Apache (richtet HTTPS automatisch ein)',
'Webroot (running web server, files in .well-known)': 'Webroot (laufender Webserver, Dateien in .well-known)',
'Standalone (port 80 must be free)': 'Standalone (Port 80 muss frei sein)',
'DNS challenge (manual TXT record, also for wildcards)': 'DNS-Challenge (TXT-Eintrag von Hand, auch für Wildcards)',
'Webroot': 'Webroot',
'E-mail for expiry notices': 'E-Mail für Ablaufhinweise',
'Key type': 'Schlüsseltyp',
'Certificate name': 'Zertifikatsname',
'Redirect HTTP to HTTPS': 'HTTP auf HTTPS umleiten',
"Test certificate (Let's Encrypt staging, no rate limits)": 'Testzertifikat (Let’s Encrypt Staging, ohne Ratenlimits)',
'certbot runs in a terminal tab so you can follow the output and answer questions.': 'certbot läuft in einem Terminal-Tab, damit du die Ausgabe verfolgen und Rückfragen beantworten kannst.',
'Request': 'Anfordern',
'Self-signed certificate': 'Selbstsigniertes Zertifikat',
'Valid (days)': 'Gültig (Tage)',
'Common name (CN)': 'Common Name (CN)',
'Additional names / IPs': 'Weitere Namen / IPs',
'Stored in /etc/ssl/mrterm/<name>.crt and .key. Browsers show a warning for self-signed certificates.': 'Wird unter /etc/ssl/mrterm/<name>.crt und .key abgelegt. Browser zeigen bei selbstsignierten Zertifikaten eine Warnung.',
'Created: {path}': 'Erstellt: {path}',
'Check TLS endpoint': 'TLS-Endpunkt prüfen',
'Host': 'Host',
'Check': 'Prüfen',
'trusted': 'vertrauenswürdig',
'not trusted': 'nicht vertrauenswürdig',
'Issuer': 'Aussteller',
'Subject': 'Inhaber',
'Names': 'Namen',
'The connection is made from this computer – useful to check which certificate a web server actually delivers.': 'Die Verbindung wird von diesem Rechner aus aufgebaut – praktisch, um zu prüfen, welches Zertifikat ein Webserver tatsächlich ausliefert.',
'Wildcard certificates need the DNS challenge.': 'Wildcard-Zertifikate benötigen die DNS-Challenge.',
// Samba / AD
'Shares': 'Freigaben',
'Samba is not installed': 'Samba ist nicht installiert',
'Install Samba to share folders with Windows clients or to run a domain controller.': 'Installiere Samba, um Ordner für Windows-Clients freizugeben oder einen Domänencontroller zu betreiben.',
'Install Samba': 'Samba installieren',
'Server role': 'Serverrolle',
'Workgroup': 'Arbeitsgruppe',
'Edit smb.conf': 'smb.conf bearbeiten',
'New share': 'Neue Freigabe',
'No shares yet.': 'Noch keine Freigaben.',
'Access': 'Zugriff',
'read only': 'nur lesen',
'read/write': 'lesen/schreiben',
'guest': 'Gast',
'hidden': 'versteckt',
'all': 'alle',
'Delete share?': 'Freigabe löschen?',
'The share “{name}” will be removed from smb.conf. The files in {path} are kept.': 'Die Freigabe „{name}“ wird aus smb.conf entfernt. Die Dateien in {path} bleiben erhalten.',
'Share deleted': 'Freigabe gelöscht',
'Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.': 'Beim Speichern wird die Datei mit testparm geprüft und Samba neu geladen. Schlägt die Prüfung fehl, wird der vorherige Stand wiederhergestellt.',
'Edit share': 'Freigabe bearbeiten',
'Read only': 'Nur lesen',
'Visible in the network': 'Im Netzwerk sichtbar',
'Guest access': 'Gastzugriff',
'Allowed users / groups': 'Erlaubte Benutzer / Gruppen',
'empty = all; e.g. anna, @staff': 'leer = alle; z. B. anna, @staff',
'Users with write access': 'Benutzer mit Schreibrecht',
'optional; e.g. @admins': 'optional; z. B. @admins',
'File permissions (create mask)': 'Dateirechte (create mask)',
'Folder permissions (directory mask)': 'Ordnerrechte (directory mask)',
'Create the folder if it does not exist': 'Ordner anlegen, falls er fehlt',
'Share saved': 'Freigabe gespeichert',
'A share named “{name}” already exists.': 'Eine Freigabe namens „{name}“ gibt es bereits.',
'This server is an Active Directory domain controller. Users are managed in the “Domain” view.': 'Dieser Server ist ein Active-Directory-Domänencontroller. Benutzer werden in der Ansicht „Domäne“ verwaltet.',
'Samba users': 'Samba-Benutzer',
'No Samba users yet. Samba users need a Linux account and their own Samba password.': 'Noch keine Samba-Benutzer. Samba-Benutzer brauchen ein Linux-Konto und ein eigenes Samba-Passwort.',
'disabled': 'deaktiviert',
'Flags': 'Flags',
'Password changed': 'Passwort geändert',
'User enabled': 'Benutzer aktiviert',
'User disabled': 'Benutzer deaktiviert',
'Delete Samba user?': 'Samba-Benutzer löschen?',
'“{name}” can no longer access shares. The Linux account is kept.': '„{name}“ kann dann nicht mehr auf Freigaben zugreifen. Das Linux-Konto bleibt erhalten.',
'User deleted': 'Benutzer gelöscht',
'Add Samba user': 'Samba-Benutzer hinzufügen',
'Create a Linux account without login if it does not exist': 'Linux-Konto ohne Anmeldung anlegen, falls es fehlt',
'User added': 'Benutzer hinzugefügt',
'Linux user {user} does not exist.': 'Den Linux-Benutzer {user} gibt es nicht.',
'No Samba services found.': 'Keine Samba-Dienste gefunden.',
'Connected clients': 'Verbundene Clients',
'No connections.': 'Keine Verbindungen.',
'Computer': 'Computer',
'Encryption': 'Verschlüsselung',
'Open files': 'Geöffnete Dateien',
'Computers': 'Computer',
'Domain info': 'Domäneninfo',
'ldbsearch is missing (package ldb-tools), so only names are shown.': 'ldbsearch fehlt (Paket ldb-tools), daher werden nur Namen angezeigt.',
'Domain users': 'Domänenbenutzer',
'New user': 'Neuer Benutzer',
'E-mail': 'E-Mail',
'Last logon': 'Letzte Anmeldung',
'Delete domain user?': 'Domänenbenutzer löschen?',
'“{name}” will be permanently deleted from the domain.': '„{name}“ wird endgültig aus der Domäne gelöscht.',
'Group created': 'Gruppe angelegt',
'The group “{name}” will be deleted.': 'Die Gruppe „{name}“ wird gelöscht.',
'Group deleted': 'Gruppe gelöscht',
'Operating system': 'Betriebssystem',
'Delete computer account?': 'Computerkonto löschen?',
'“{name}” will be removed from the domain and must join again.': '„{name}“ wird aus der Domäne entfernt und muss erneut beitreten.',
'Computer deleted': 'Computer gelöscht',
'Domain membership': 'Domänenmitgliedschaft',
'This server is not a member of a domain (or realmd is not installed).': 'Dieser Server ist kein Mitglied einer Domäne (oder realmd ist nicht installiert).',
'Join domain …': 'Domäne beitreten …',
'Join domain': 'Domäne beitreten',
'Domain administrator': 'Domänen-Administrator',
'Runs in a terminal tab and asks for the password of the domain administrator.': 'Läuft in einem Terminal-Tab und fragt nach dem Passwort des Domänen-Administrators.',
'Join': 'Beitreten',
'Install domain join tools': 'Werkzeuge für den Domänenbeitritt installieren',
'To manage users, groups and computers here, provision this server as a Samba AD domain controller (samba-tool domain provision).': 'Um hier Benutzer, Gruppen und Computer zu verwalten, richte diesen Server als Samba-AD-Domänencontroller ein (samba-tool domain provision).',
'New domain user': 'Neuer Domänenbenutzer',
'First name': 'Vorname',
'Last name': 'Nachname',
'User must change the password at next logon': 'Benutzer muss das Passwort bei der nächsten Anmeldung ändern',
'User created': 'Benutzer angelegt',
'Members of {name}': 'Mitglieder von {name}',
'One member per line (users, groups or computers)': 'Ein Mitglied pro Zeile (Benutzer, Gruppen oder Computer)',
'Members updated': 'Mitglieder aktualisiert',
'This host is not a Samba AD domain controller.': 'Dieser Host ist kein Samba-AD-Domänencontroller.',
'This server is a member of the domain {domain}.': 'Dieser Server ist Mitglied der Domäne {domain}.',
'Leave domain': 'Domäne verlassen',
'Leave domain?': 'Domäne verlassen?',
'Domain users can no longer log in on this server. The computer account in the domain is kept.': 'Domänenbenutzer können sich dann nicht mehr auf diesem Server anmelden. Das Computerkonto in der Domäne bleibt erhalten.',
'Leave': 'Verlassen',
'Left the domain': 'Domäne verlassen',
'This server is not a member of a domain.': 'Dieser Server ist kein Mitglied einer Domäne.',
'Create a new domain': 'Neue Domäne erstellen',
'Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.': 'Macht diesen Server zum ersten Domänencontroller einer neuen Active-Directory-Domäne. Windows- und Linux-Rechner können ihr dann beitreten, und du verwaltest Benutzer, Gruppen und Computer direkt hier.',
'Create domain …': 'Domäne erstellen …',
'Create domain': 'Domäne erstellen',
'Install domain controller packages': 'Pakete für den Domänencontroller installieren',
'The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.': 'Der Server muss die Domäne per DNS auflösen können (Domänencontroller als DNS-Server). Anmeldungen von Domänenbenutzern übernimmt danach sssd.',
'Joining {domain} …': 'Trete {domain} bei …',
'Joined the domain': 'Der Domäne beigetreten',
'DNS domain (realm)': 'DNS-Domäne (Realm)',
'NetBIOS name': 'NetBIOS-Name',
'Password for Administrator': 'Passwort für Administrator',
'DNS forwarder': 'DNS-Weiterleitung',
'Queries for other domains are forwarded to this DNS server.': 'Anfragen für andere Domains werden an diesen DNS-Server weitergeleitet.',
'Use this domain controller as DNS server of the host (resolv.conf)': 'Diesen Domänencontroller als DNS-Server des Hosts verwenden (resolv.conf)',
'Store Unix attributes (UID/GID) in the directory (RFC 2307)': 'Unix-Attribute (UID/GID) im Verzeichnis speichern (RFC 2307)',
'The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).': 'Die Dateiserver-Dienste (smbd/nmbd) werden durch samba-ad-dc ersetzt; die bisherige smb.conf bleibt als smb.conf.mrterm-before-dc erhalten. Der Server braucht eine feste IP-Adresse und einen Hostnamen, der nicht dem Domänennamen entspricht. Das Passwort muss komplex sein (Groß-/Kleinbuchstaben, Ziffern, mind. 8 Zeichen).',
'Creating the domain {realm} … this takes about a minute.': 'Erstelle die Domäne {realm} … das dauert etwa eine Minute.',
'Domain {realm} created': 'Domäne {realm} erstellt',
'samba-tool is missing. Install the domain controller packages first.': 'samba-tool fehlt. Installiere zuerst die Pakete für den Domänencontroller.',
'This server already contains an AD domain database (/var/lib/samba/private/sam.ldb).': 'Auf diesem Server gibt es bereits eine AD-Domänendatenbank (/var/lib/samba/private/sam.ldb).',
'Creating the domain failed: {err}': 'Das Erstellen der Domäne ist fehlgeschlagen: {err}',
'The domain was created, but samba-ad-dc could not be started: {err}': 'Die Domäne wurde erstellt, aber samba-ad-dc ließ sich nicht starten: {err}',
'realmd is not installed. Install the domain join tools first.': 'realmd ist nicht installiert. Installiere zuerst die Werkzeuge für den Domänenbeitritt.',
'Joining the domain failed: {err}': 'Der Domänenbeitritt ist fehlgeschlagen: {err}',
'Samba from Fedora/RHEL packages cannot run an AD domain controller.': 'Samba aus den Fedora-/RHEL-Paketen kann keinen AD-Domänencontroller betreiben.',
// Konfigurations-Generator
'Generator': 'Generator',
'Start from a template …': 'Mit Vorlage beginnen …',
'Search settings (e.g. upload, websocket, hsts) …': 'Einstellungen durchsuchen (z. B. upload, websocket, hsts) …',
'Open in editor': 'Im Editor öffnen',
'Save as site': 'Als Site speichern',
'{server} is not installed on this host – you can still generate and copy the configuration.': '{server} ist auf diesem Host nicht installiert – du kannst die Konfiguration trotzdem erzeugen und kopieren.',
'The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.': 'Das Zertifikat muss vor dem Speichern vorhanden sein: fordere es zuerst im Tab „Zertifikate“ an (z. B. per Webroot) oder speichere die Site erst ohne HTTPS.',
'Automatic certificates are only available with Caddy.': 'Automatische Zertifikate gibt es nur mit Caddy.',
'Create the password file on the server with: htpasswd -c {file} <user>': 'Lege die Passwortdatei auf dem Server an mit: htpasswd -c {file} <benutzer>',
'Site saved: {path}': 'Site gespeichert: {path}',
'New site ({server})': 'Neue Site ({server})',
'Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.': 'Bearbeite die erzeugte Konfiguration nach Belieben. Beim Speichern wird die Site angelegt, geprüft und der Webserver neu geladen; schlägt die Prüfung fehl, bleibt alles unverändert.',
'Basics': 'Grundlagen',
'HTTPS / TLS': 'HTTPS / TLS',
'Security': 'Sicherheit',
'Access control': 'Zugriffskontrolle',
'Performance & caching': 'Leistung & Caching',
'Redirect': 'Weiterleitung',
'Logging': 'Protokollierung',
'Custom directives': 'Eigene Direktiven',
'Reverse proxy (Node, Docker, …)': 'Reverse Proxy (Node, Docker, …)',
'Load balancer': 'Load Balancer',
'Single-page app (React, Vue, …)': 'Single-Page-App (React, Vue, …)',
'PHP app (WordPress, Laravel, …)': 'PHP-App (WordPress, Laravel, …)',
'Redirect / domain move': 'Weiterleitung / Domainumzug',
'Hardened HTTPS site': 'Gehärtete HTTPS-Site',
'Site type': 'Art der Site',
'Single-page app (index.html fallback)': 'Single-Page-App (Fallback auf index.html)',
'PHP application': 'PHP-Anwendung',
'Redirect to another URL': 'Weiterleitung auf eine andere URL',
'Separated by spaces. The first one is the main name.': 'Durch Leerzeichen getrennt. Der erste ist der Hauptname.',
'HTTP port': 'HTTP-Port',
'Index files': 'Indexdateien',
'Target servers (upstream)': 'Zielserver (Upstream)',
'One or more, separated by spaces. Several targets = load balancing.': 'Einer oder mehrere, durch Leerzeichen getrennt. Mehrere Ziele = Lastverteilung.',
'Only requests below this path are forwarded.': 'Nur Anfragen unterhalb dieses Pfads werden weitergeleitet.',
'Load balancing method': 'Verfahren der Lastverteilung',
'Round robin': 'Reihum (Round Robin)',
'Least connections': 'Wenigste Verbindungen',
'Same client → same server (IP hash)': 'Gleicher Client → gleicher Server (IP-Hash)',
'WebSockets': 'WebSockets',
'Forward Upgrade/Connection headers.': 'Upgrade-/Connection-Header weitergeben.',
'Pass the original Host header': 'Ursprünglichen Host-Header weitergeben',
'X-Forwarded-For / -Proto / X-Real-IP': 'X-Forwarded-For / -Proto / X-Real-IP',
'Timeout (seconds)': 'Timeout (Sekunden)',
'Read timeout for slow backends. 0 = default.': 'Lese-Timeout für langsame Backends. 0 = Standard.',
'Buffer responses': 'Antworten puffern',
'Turn off for streaming (Server-Sent Events, long polling).': 'Für Streaming ausschalten (Server-Sent Events, Long Polling).',
'Do not verify the HTTPS certificate of the target': 'HTTPS-Zertifikat des Ziels nicht prüfen',
'Enable HTTPS': 'HTTPS aktivieren',
'HTTPS port': 'HTTPS-Port',
'Certificate': 'Zertifikat',
"Let's Encrypt (certbot, /etc/letsencrypt/live/<domain>)": 'Let’s Encrypt (certbot, /etc/letsencrypt/live/<domain>)',
'Automatic (Caddy)': 'Automatisch (Caddy)',
'Own files': 'Eigene Dateien',
'Certificate file': 'Zertifikatsdatei',
'Key file': 'Schlüsseldatei',
'Only TLS 1.2 and 1.3': 'Nur TLS 1.2 und 1.3',
'HSTS (seconds, 0 = off)': 'HSTS (Sekunden, 0 = aus)',
'Browsers then only use HTTPS for this domain. 31536000 = one year.': 'Browser verwenden für diese Domain dann nur noch HTTPS. 31536000 = ein Jahr.',
'Hide server version': 'Serverversion verbergen',
'X-Frame-Options (clickjacking protection)': 'X-Frame-Options (Schutz vor Clickjacking)',
'off': 'aus',
'Block hidden files (.git, .env, .htaccess)': 'Versteckte Dateien sperren (.git, .env, .htaccess)',
'Allowed IPs / networks': 'Erlaubte IPs / Netze',
'Empty = everyone. Others get 403.': 'Leer = alle. Andere erhalten 403.',
'Password protection (basic auth)': 'Passwortschutz (Basic Auth)',
'Password file (htpasswd)': 'Passwortdatei (htpasswd)',
'Create it with: htpasswd -c /etc/nginx/.htpasswd user': 'Anlegen mit: htpasswd -c /etc/nginx/.htpasswd benutzer',
'Realm (text in the login dialog)': 'Realm (Text im Anmeldedialog)',
'Rate limit (requests per second per IP, 0 = off)': 'Ratenlimit (Anfragen pro Sekunde je IP, 0 = aus)',
'Compression (gzip)': 'Komprimierung (gzip)',
'Browser cache for assets (days, 0 = off)': 'Browser-Cache für Assets (Tage, 0 = aus)',
'CSS, JS, images and fonts.': 'CSS, JS, Bilder und Schriften.',
'Max. upload size (MB, 0 = default)': 'Max. Upload-Größe (MB, 0 = Standard)',
'Target URL': 'Ziel-URL',
'Status code': 'Statuscode',
'301 (permanent)': '301 (dauerhaft)',
'302 (temporary)': '302 (vorübergehend)',
'Keep the path': 'Pfad beibehalten',
'example.org/a → target/a': 'example.org/a → ziel/a',
'Access log': 'Zugriffsprotokoll',
'Empty = default of the server, "off" = no access log.': 'Leer = Standard des Servers, „off“ = kein Zugriffsprotokoll.',
'Error log': 'Fehlerprotokoll',
'Additional directives': 'Zusätzliche Direktiven',
'Inserted as-is into the server block.': 'Wird unverändert in den Server-Block eingefügt.',
'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}', 'RDP exited (code {code}): {err}': 'RDP beendet (Code {code}): {err}',
'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}', 'RDP launch failed: {msg}': 'RDP-Start fehlgeschlagen: {msg}',
'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)', 'xfreerdp3 missing (sudo pacman -S freerdp)': 'xfreerdp3 fehlt (sudo pacman -S freerdp)',
+112
View File
@@ -0,0 +1,112 @@
// Gemeinsame Basis für Verwaltungs-Tabs über SSH (Webserver, Zertifikate, Samba/AD): Verbindung, root-Rechte
// (direkt als root oder per sudo), Skripte als root, Konfigurationsdateien mit Backup, Prüfung und automatischem Zurückrollen.
// Unterklassen setzen NAME (für Fehlermeldungen), OPS (aufrufbare Operationen) und implementieren read(id).
const i18n = require('../i18n');
const { execOn } = require('./docker');
const { acquireSudo } = require('./sudo');
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const b64 = (s) => Buffer.from(String(s)).toString('base64');
const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // Shell-Quoting in einfachen Anführungszeichen
const check = (v, rx, what) => { if (!rx.test(String(v ?? ''))) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: what, value: v })); return String(v); };
// Ausgabe mit @@ABSCHNITT-Markern in { ABSCHNITT: text } zerlegen
function sections(out) {
const res = {};
let cur = null;
for (const line of String(out).split('\n')) {
const m = line.match(/^@@(\w+)$/);
if (m) { cur = m[1]; res[cur] = []; continue; }
if (cur) res[cur].push(line);
}
return Object.fromEntries(Object.entries(res).map(([k, v]) => [k, v.join('\n').trim()]));
}
// Paketverwaltung des Hosts erkennen (für Installationsbefehle im Terminal)
const PKG_DETECT = 'if command -v apt-get >/dev/null 2>&1; then echo apt; elif command -v dnf >/dev/null 2>&1; then echo dnf; elif command -v yum >/dev/null 2>&1; then echo yum; elif command -v pacman >/dev/null 2>&1; then echo pacman; elif command -v zypper >/dev/null 2>&1; then echo zypper; elif command -v apk >/dev/null 2>&1; then echo apk; fi';
function installCmd(pkg, packages) {
const p = packages.join(' ');
return { apt: `apt-get update && apt-get install -y ${p}`, dnf: `dnf install -y ${p}`, yum: `yum install -y ${p}`, pacman: `pacman -S --needed --noconfirm ${p}`, zypper: `zypper install -y ${p}`, apk: `apk add ${p}` }[pkg] || '';
}
class AdminManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, sudo, sudoPw, ... }
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Session not found'));
return s;
}
// Shell-Skript als root ausführen (base64-kodiert, damit kein Quoting-Problem entsteht).
// stdin: weitere Eingabe für das Skript (z. B. Passwörter, die nicht in der Kommandozeile stehen sollen)
root(s, script, stdin = '') {
const cmd = `sh -c "$(printf %s ${b64(`export PATH=$PATH:/usr/sbin:/sbin:/usr/local/sbin\n${script}`)} | base64 -d)"`;
if (!s.sudo) return execOn(s.conn, cmd, stdin);
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n${stdin}`);
}
async rootOk(s, script, stdin) {
const r = await this.root(s, script, stdin);
if (r.code) throw new Error(lastLine(r.err) || lastLine(r.out) || i18n.t('Command failed with code {code}', { code: r.code }));
return r.out;
}
async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id);
const s = { conn, jumps, host, sudo: false };
this.sessions.set(id, s);
conn.on('close', () => { if (this.sessions.get(id) === s) { this.close(id); onClose(); } });
conn.on('error', () => {});
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
s.sudo = true;
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
}
s.pkg = (await this.root(s, PKG_DETECT)).out.trim();
return this.read(id);
}
// Präfix für Befehle, die im Terminal-Tab laufen (dort fragt sudo selbst nach dem Passwort)
termPrefix(s) { return s.sudo ? 'sudo ' : ''; }
termCmd(s, cmd) { return s.sudo ? `sudo sh -c ${q(cmd)}` : cmd; }
// Datei schreiben mit Backup; validate (Shell) muss erfolgreich sein, sonst wird der alte Stand wiederhergestellt.
// after: Befehl nach erfolgreicher Prüfung (z. B. reload). Liefert die Ausgabe der Prüfung.
async writeChecked(s, path, content, { validate = 'true', after = '', mode = '' } = {}) {
const r = await this.root(s, `f=${q(path)}
had=0; [ -e "$f" ] && { cp -a "$f" "$f.mrterm-bak" || exit 1; had=1; }
mkdir -p "$(dirname "$f")" || exit 1
printf %s ${b64(content)} | base64 -d > "$f" || exit 1
${mode ? `chmod ${mode} "$f"` : ''}
if ! out=$( (${validate}) 2>&1 ); then
if [ $had = 1 ]; then mv -f "$f.mrterm-bak" "$f"; else rm -f "$f"; fi
printf '%s\\n' "$out" >&2; exit 4
fi
printf '%s\\n' "$out"
${after ? `(${after}) 2>&1 || exit 6` : ''}
exit 0`);
if (r.code === 4) throw new Error(i18n.t('The configuration is invalid, the previous version was restored: {err}', { err: r.err.trim().split('\n').slice(-6).join('\n') }));
if (r.code === 6) throw new Error(i18n.t('Saved, but reloading failed: {err}', { err: lastLine(r.out) }));
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Command failed with code {code}', { code: r.code }));
return r.out.trim();
}
// Generischer Einstieg für die Oberfläche: nur Operationen aus OPS sind erlaubt
op(id, name, ...args) {
if (!this.constructor.OPS.includes(name)) throw new Error('Invalid operation');
return this[name](this.get(id), ...args);
}
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 = { AdminManager, sections, lastLine, b64, q, check, installCmd };
+93
View File
@@ -0,0 +1,93 @@
// Support-Assistent über eine Ollama-Instanz (lokal oder im Netz).
// Antworten werden gestreamt: send('ai:event', chatId, 'data' | 'done' | 'error', payload)
const http = require('http');
const https = require('https');
const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator.
The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers
and compose stacks, firewall (UFW/iptables), network configuration and SFTP.
You may receive the content of the user's active MrTerm tab as context (terminal output, container list, compose file,
firewall rules, …). Your job:
- Explain errors briefly and clearly, name the most likely cause.
- Suggest concrete, copy-pasteable shell commands in \`\`\`bash code blocks, one step at a time.
- Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes).
- If information is missing, say which command would provide it.
Answer in the language of the user's question. Be concise.`;
function request(base, path, { method = 'GET', body, signal } = {}) {
const url = new URL(path, base.endsWith('/') ? base : base + '/');
const lib = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = lib.request(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, signal, timeout: 15000 }, (res) => {
if (res.statusCode >= 400) {
let t = ''; res.on('data', (c) => (t += c));
res.on('end', () => { let m = t; try { m = JSON.parse(t).error || t; } catch {} reject(new Error(`Ollama: ${m || res.statusCode}`)); });
return;
}
resolve(res);
});
req.on('timeout', () => req.destroy(new Error('Ollama: timeout')));
req.on('error', (e) => reject(e.code === 'ECONNREFUSED' ? new Error(`Ollama not reachable at ${base}`) : e));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
class AiAssistant {
constructor(store, send) {
this.store = store;
this.send = send;
this.running = new Map();
}
get cfg() {
const s = this.store.get().settings;
return { url: s.aiUrl || 'http://localhost:11434', model: s.aiModel || '' };
}
async models(url) {
const res = await request(url || this.cfg.url, 'api/tags');
let t = ''; for await (const c of res) t += c;
return (JSON.parse(t).models || []).map((m) => m.name);
}
// messages: [{ role: 'user' | 'assistant', content }], context: letzte Terminalausgabe
async chat(chatId, messages, context = '') {
const { url, model } = this.cfg;
if (!model) throw new Error('No Ollama model selected (Settings → Support assistant).');
this.stop(chatId);
const ctrl = new AbortController();
this.running.set(chatId, ctrl);
const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : '');
(async () => {
try {
const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } });
res.setTimeout(0);
let buf = '';
for await (const chunk of res) {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
if (!line) continue;
const m = JSON.parse(line);
if (m.error) throw new Error(`Ollama: ${m.error}`);
if (m.message?.content) this.send('ai:event', chatId, 'data', m.message.content);
}
}
this.send('ai:event', chatId, 'done');
} catch (e) {
if (ctrl.signal.aborted) this.send('ai:event', chatId, 'done');
else this.send('ai:event', chatId, 'error', e.message || String(e));
} finally {
if (this.running.get(chatId) === ctrl) this.running.delete(chatId);
}
})();
return true;
}
stop(chatId) { this.running.get(chatId)?.abort(); this.running.delete(chatId); }
stopAll() { for (const c of this.running.values()) c.abort(); this.running.clear(); }
}
module.exports = { AiAssistant };
+180
View File
@@ -0,0 +1,180 @@
// Zertifikate-Tab: Let's Encrypt über certbot oder acme.sh, Ablaufdaten aller gefundenen Zertifikate,
// automatische Erneuerung, selbstsignierte Zertifikate und TLS-Prüfung eines Endpunkts.
// Ausstellen/Erneuern läuft in einem Terminal-Tab, damit Rückfragen (DNS-Challenge, E-Mail) beantwortet werden können.
const tls = require('tls');
const i18n = require('../i18n');
const { AdminManager, sections, q, check, installCmd } = require('./admin');
const RX = {
name: /^[\w.@-]{1,120}$/,
domain: /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$|^localhost$/,
email: /^[^\s@'"`$\\]+@[^\s@'"`$\\]+\.[^\s@'"`$\\]+$/,
webroot: /^\/[\w./@+-]*$/,
cert: /^\/(etc|var\/lib|opt|srv)\/[\w./@*+-]+\.(pem|crt|cer)$/,
};
const noDots = (p) => { if (String(p).split('/').includes('..')) throw new Error('Invalid path'); return p; };
// Zertifikatsdateien außerhalb von certbot/acme.sh, deren Ablauf angezeigt wird
const SCAN = '/etc/ssl/mrterm/*.crt /etc/nginx/ssl/*.crt /etc/nginx/ssl/*.pem /etc/apache2/ssl/*.crt /etc/apache2/ssl/*.pem /etc/pki/tls/certs/localhost.crt /var/lib/caddy/.local/share/caddy/certificates/*/*/*.crt';
const GATHER = `echo "@@CERTBOT"; command -v certbot >/dev/null 2>&1 && certbot --version 2>&1 | head -1
echo "@@PLUGINS"; command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | sed -n 's/^\\* //p'
echo "@@CERTS"; command -v certbot >/dev/null 2>&1 && certbot certificates 2>/dev/null
echo "@@TIMER"; for u in certbot.timer snap.certbot.renew.timer certbot-renew.timer; do s=$(systemctl is-enabled "$u" 2>/dev/null) && echo "$u|$s|$(systemctl is-active "$u" 2>/dev/null)"; done; [ -f /etc/cron.d/certbot ] && echo "cron|/etc/cron.d/certbot|"
echo "@@ACME"; for d in /root/.acme.sh "$HOME/.acme.sh"; do [ -x "$d/acme.sh" ] && { echo "DIR $d"; "$d/acme.sh" --list --listraw 2>/dev/null; break; }; done
echo "@@FILES"; for f in /etc/letsencrypt/live/*/cert.pem ${SCAN}; do [ -f "$f" ] || continue; echo "==F $f"; openssl x509 -noout -subject -issuer -enddate -ext subjectAltName -in "$f" 2>/dev/null; done
echo "@@WEB"; command -v nginx >/dev/null 2>&1 && echo nginx; { command -v apache2 >/dev/null 2>&1 || command -v httpd >/dev/null 2>&1; } && echo apache
echo "@@END"`;
// Ausgabe von "certbot certificates"
function parseCertbot(text) {
const out = [];
let cur = null;
for (const line of String(text).split('\n')) {
const t = line.trim();
let m;
if ((m = t.match(/^Certificate Name:\s*(.+)$/))) { cur = { name: m[1], domains: [] }; out.push(cur); }
else if (!cur) continue;
else if ((m = t.match(/^Domains:\s*(.+)$/))) cur.domains = m[1].split(/\s+/);
else if ((m = t.match(/^Expiry Date:\s*(\S+ \S+)\s*\((.+)\)/))) { cur.expires = Date.parse(m[1].replace(' ', 'T').replace(/\+00:00$/, 'Z')) || null; cur.state = m[2]; }
else if ((m = t.match(/^Key Type:\s*(.+)$/))) cur.keyType = m[1];
else if ((m = t.match(/^Certificate Path:\s*(.+)$/))) cur.certPath = m[1];
else if ((m = t.match(/^Private Key Path:\s*(.+)$/))) cur.keyPath = m[1];
}
return out;
}
// acme.sh --list --listraw: Main_Domain|KeyLength|SAN_Domains|Profile?|CA|Created|Renew
function parseAcme(text) {
const lines = String(text).split('\n');
const dir = (lines.find((l) => l.startsWith('DIR ')) || '').slice(4);
const rows = lines.filter((l) => l.includes('|') && !/^Main_Domain/.test(l));
const head = (lines.find((l) => /^Main_Domain/.test(l)) || 'Main_Domain|KeyLength|SAN_Domains|CA|Created|Renew').split('|');
return { dir, certs: rows.map((r) => { const v = r.split('|'); const o = Object.fromEntries(head.map((k, i) => [k, v[i] || ''])); return { domain: o.Main_Domain, keyLength: o.KeyLength.replace(/"/g, ''), sans: o.SAN_Domains === 'no' ? [] : o.SAN_Domains.split(','), ca: o.CA, created: o.Created, renew: o.Renew }; }) };
}
// openssl x509 -subject -issuer -enddate -ext subjectAltName je Datei
function parseFiles(text) {
return String(text).split(/^==F /m).filter(Boolean).map((part) => {
const [path, ...rest] = part.split('\n');
const body = rest.join('\n');
const end = body.match(/notAfter=(.+)/)?.[1];
const sans = (body.match(/DNS:[^,\s]+/g) || []).map((x) => x.slice(4));
return { path: path.trim(), subject: (body.match(/subject=\s*(.+)/)?.[1] || '').trim(), issuer: (body.match(/issuer=\s*(.+)/)?.[1] || '').trim(), expires: end ? Date.parse(end) : null, sans };
}).filter((x) => x.subject || x.expires);
}
class CertManager extends AdminManager {
static OPS = ['issueCommand', 'renewCommand', 'deleteCert', 'details', 'selfSigned', 'enableAutoRenew', 'installCommand', 'acmeRenewCommand', 'acmeRemove'];
async read(id) {
const s = this.get(id);
const sec = sections(await this.rootOk(s, GATHER));
const acme = parseAcme(sec.ACME || '');
s.acmeDir = acme.dir;
return {
pkg: s.pkg,
certbot: { version: (sec.CERTBOT || '').trim(), plugins: (sec.PLUGINS || '').split('\n').map((x) => x.trim()).filter(Boolean), certs: parseCertbot(sec.CERTS || '') },
timers: (sec.TIMER || '').split('\n').filter(Boolean).map((l) => { const [unit, enabled, active] = l.split('|'); return { unit, enabled, active }; }),
acme,
files: parseFiles(sec.FILES || ''),
web: (sec.WEB || '').split('\n').filter(Boolean),
};
}
// certbot-Befehl zum Ausstellen; method: nginx | apache | webroot | standalone | dns
issueCommand(s, o) {
const method = check(o.method, /^(nginx|apache|webroot|standalone|dns)$/, 'method');
const domains = String(o.domains || '').split(/[\s,]+/).filter(Boolean).map((d) => check(d, RX.domain, 'domain'));
if (!domains.length) throw new Error(i18n.t('Please enter at least one domain.'));
if (domains.some((d) => d.startsWith('*.')) && method !== 'dns') throw new Error(i18n.t('Wildcard certificates need the DNS challenge.'));
const args = ['certbot'];
if (method === 'nginx' || method === 'apache') args.push(`--${method}`);
else args.push('certonly');
if (method === 'webroot') args.push('--webroot', '-w', q(check(noDots(String(o.webroot || '').trim()), RX.webroot, 'webroot')));
if (method === 'standalone') args.push('--standalone');
if (method === 'dns') args.push('--manual', '--preferred-challenges', 'dns');
domains.forEach((d) => args.push('-d', q(d)));
if (o.email) args.push('--email', q(check(String(o.email).trim(), RX.email, 'email')), '--agree-tos', '--no-eff-email');
else args.push('--register-unsafely-without-email', '--agree-tos');
if (o.keyType === 'rsa') args.push('--key-type', 'rsa', '--rsa-key-size', '4096'); else args.push('--key-type', 'ecdsa');
if (o.redirect && (method === 'nginx' || method === 'apache')) args.push('--redirect');
if (o.staging) args.push('--staging');
if (o.name) args.push('--cert-name', q(check(String(o.name).trim(), RX.name, 'name')));
return this.termCmd(s, args.join(' '));
}
renewCommand(s, name, dryRun) {
const args = ['certbot', 'renew'];
if (name) args.push('--cert-name', q(check(name, RX.name, 'name')));
if (dryRun) args.push('--dry-run');
return this.termCmd(s, args.join(' '));
}
async deleteCert(s, name) {
await this.rootOk(s, `certbot delete --non-interactive --cert-name ${q(check(name, RX.name, 'name'))}`);
return true;
}
details(s, path) {
check(noDots(path), RX.cert, 'file');
return this.rootOk(s, `openssl x509 -noout -text -in ${q(path)}`);
}
// Selbstsigniertes Zertifikat unter /etc/ssl/mrterm/<name>.crt/.key
async selfSigned(s, o) {
const name = check(String(o.name || '').trim(), /^[\w.-]{1,80}$/, 'name');
const cn = check(String(o.cn || '').trim(), RX.domain, 'common name');
const sans = [cn, ...String(o.sans || '').split(/[\s,]+/).filter(Boolean).map((d) => check(d, /^[\w.*-]+$|^\d{1,3}(\.\d{1,3}){3}$/, 'SAN'))];
const days = Math.max(1, Math.min(3650, Number(o.days) || 365));
const san = [...new Set(sans)].map((x) => (/^\d{1,3}(\.\d{1,3}){3}$/.test(x) ? `IP:${x}` : `DNS:${x}`)).join(',');
const key = o.keyType === 'rsa' ? '-newkey rsa:4096' : '-newkey ec -pkeyopt ec_paramgen_curve:prime256v1';
await this.rootOk(s, `mkdir -p /etc/ssl/mrterm && chmod 755 /etc/ssl/mrterm
[ -e ${q(`/etc/ssl/mrterm/${name}.crt`)} ] && { echo 'exists' >&2; exit 3; }
openssl req -x509 ${key} -nodes -days ${days} -subj ${q(`/CN=${cn}`)} -addext ${q(`subjectAltName=${san}`)} -keyout ${q(`/etc/ssl/mrterm/${name}.key`)} -out ${q(`/etc/ssl/mrterm/${name}.crt`)} 2>&1 && chmod 600 ${q(`/etc/ssl/mrterm/${name}.key`)}`);
return { cert: `/etc/ssl/mrterm/${name}.crt`, key: `/etc/ssl/mrterm/${name}.key` };
}
async enableAutoRenew(s, unit) {
await this.rootOk(s, `systemctl enable --now ${check(unit || 'certbot.timer', /^[\w.@-]+\.timer$/, 'timer')}`);
return true;
}
installCommand(s) {
const pkgs = { apt: ['certbot', 'python3-certbot-nginx', 'python3-certbot-apache'], dnf: ['certbot', 'python3-certbot-nginx', 'python3-certbot-apache'], yum: ['certbot'], pacman: ['certbot', 'certbot-nginx', 'certbot-apache'], zypper: ['python3-certbot'], apk: ['certbot'] }[s.pkg];
if (!pkgs) throw new Error(i18n.t('No known package manager on this host.'));
return this.termCmd(s, installCmd(s.pkg, pkgs));
}
acmeRenewCommand(s, domain) {
if (!s.acmeDir) throw new Error('acme.sh not found');
return this.termCmd(s, `${q(`${s.acmeDir}/acme.sh`)} --renew -d ${q(check(domain, RX.domain, 'domain'))} --force`);
}
async acmeRemove(s, domain) {
if (!s.acmeDir) throw new Error('acme.sh not found');
await this.rootOk(s, `${q(`${s.acmeDir}/acme.sh`)} --remove -d ${q(check(domain, RX.domain, 'domain'))}`);
return true;
}
}
// TLS-Endpunkt von diesem Rechner aus prüfen (z. B. ob der Webserver das neue Zertifikat ausliefert)
function probe(host, port = 443) {
check(host, /^[\w.-]+$|^\[?[0-9a-fA-F:]+\]?$/, 'host');
return new Promise((resolve, reject) => {
const sock = tls.connect({ host, port: Number(port) || 443, servername: /^[\d.]+$|:/.test(host) ? undefined : host, rejectUnauthorized: false, timeout: 8000 }, () => {
const c = sock.getPeerCertificate();
resolve({
authorized: sock.authorized, error: sock.authorizationError || '', protocol: sock.getProtocol(),
subject: c.subject?.CN || '', issuer: [c.issuer?.O, c.issuer?.CN].filter(Boolean).join(' · '),
validFrom: Date.parse(c.valid_from), expires: Date.parse(c.valid_to),
sans: (c.subjectaltname || '').split(/,\s*/).map((x) => x.replace(/^DNS:/, '')).filter(Boolean), fingerprint: c.fingerprint256,
});
sock.end();
});
sock.on('timeout', () => { sock.destroy(); reject(new Error(i18n.t('Timeout'))); });
sock.on('error', reject);
});
}
module.exports = { CertManager, parseCertbot, parseAcme, parseFiles, probe };
+75
View File
@@ -0,0 +1,75 @@
// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates.
// Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die
// Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env.
const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
let cache = { url: '', at: 0, list: [] };
const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML
const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app';
async function list(url = DEFAULT_URL) {
url = url || DEFAULT_URL;
if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile));
const listed = items.map((t, i) => ({
i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [],
kind: t.image ? 'container' : 'stack', note: t.note || '', t,
}));
cache = { url, at: Date.now(), list: listed };
return listed;
}
// Für die Oberfläche ohne Rohdaten
const summary = (l) => l.map(({ t: _t, ...rest }) => rest);
function envFile(env) {
return (env || []).filter((e) => e?.name).map((e) => {
const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default;
const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – ');
return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`;
}).join('\n');
}
function containerCompose(t, name) {
const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`];
if (t.hostname) L.push(` hostname: ${y(t.hostname)}`);
if (t.command) L.push(` command: ${y(t.command)}`);
if (t.privileged) L.push(' privileged: true');
if (t.interactive) L.push(' stdin_open: true', ' tty: true');
if (t.network === 'host') L.push(' network_mode: host');
if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`));
if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`));
if (t.volumes?.length) {
L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => {
const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`;
return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`;
}));
}
if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`));
return L.join('\n') + '\n';
}
// Liefert { name, compose, env, note }
async function build(index, url) {
const l = await list(url);
const item = l.find((x) => x.i === index);
if (!item) throw new Error('Template not found');
const t = item.t;
const name = slug(t.name || t.title);
let compose;
if (t.image) compose = containerCompose(t, name);
else {
const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || '');
if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`);
const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`);
compose = await res.text();
}
return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') };
}
module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };
+257
View File
@@ -0,0 +1,257 @@
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
function head(major, n) {
if (n < 24) return Buffer.from([(major << 5) | n]);
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
}
function enc(v) {
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
if (v instanceof Map) {
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
return Buffer.concat([head(5, items.length), ...items.flat()]);
}
throw new Error('CBOR: unsupported value');
}
function dec(buf, pos = 0) {
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
let n = ai;
if (ai === 24) n = buf[pos++];
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
switch (major) {
case 0: return [n, pos];
case 1: return [-1 - n, pos];
case 2: return [buf.subarray(pos, pos + n), pos + n];
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
case 6: return dec(buf, pos);
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
}
}
// ---- CTAPHID ----
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
class CtapError extends Error {
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
}
function findDevice() {
const base = '/sys/class/hidraw';
let names = [];
try { names = fs.readdirSync(base); } catch { return null; }
for (const n of names) {
try {
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
} catch { /* ignorieren */ }
}
return null;
}
class Device {
static open() {
const p = findDevice();
if (!p) return null;
const d = new Device();
d.fd = fs.openSync(p, 'r+');
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
return d;
}
close() {
this.closed = true;
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
}
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
readPacket() {
const b = Buffer.alloc(64);
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
}
write(cmd, data) {
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
const first = Buffer.alloc(64);
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
data.copy(first, 7, 0, 57); pkt(first);
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
const c = Buffer.alloc(64);
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
}
}
async transact(cmd, data) {
this.write(cmd, data);
for (;;) {
const r = await this.readPacket();
if (!r.subarray(0, 4).equals(this.cid)) continue;
const rcmd = r[4] & 0x7f;
if (rcmd === CMD.KEEPALIVE) continue;
const len = r.readUInt16BE(5);
const parts = [r.subarray(7)];
let got = r.length - 7;
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
const body = Buffer.concat(parts).subarray(0, len);
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
return body;
}
}
async init() {
const nonce = crypto.randomBytes(8);
for (;;) {
const r = await this.transact(CMD.INIT, nonce);
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
}
}
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
async cbor(cmd, params) {
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
if (r[0] !== 0) throw new CtapError(r[0]);
return r.length > 1 ? dec(r, 1)[0] : new Map();
}
}
// ---- PIN-Protokoll 1 ----
const aes = (mode, key, data) => {
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
c.setAutoPadding(false);
return Buffer.concat([c.update(data), c.final()]);
};
async function sharedSecret(dev) {
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
const pub = ecdh.getPublicKey();
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
}
async function pinToken(dev, pin) {
const { key, platformKey } = await sharedSecret(dev);
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
return aes('dec', key, r.get(2));
}
async function pinRetries(dev) {
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
}
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
const abort = () => dev.cancel();
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
const opts = info.get(4) || new Map();
const params = new Map([
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
[6, new Map([['hmac-secret', true]])],
]);
const cdh = crypto.randomBytes(32);
params.set(1, cdh);
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
let wrong = false;
for (;;) {
const pin = await ui.askPin(await pinRetries(dev), wrong);
if (pin == null) throw new CtapError(0x2d);
try {
const tok = await pinToken(dev, pin);
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
params.set(9, 1);
break;
} catch (e) {
if (e.code === PIN.INVALID) { wrong = true; continue; }
throw e;
}
}
}
ui.touch(abort);
timer = setTimeout(abort, timeoutMs);
const r = await dev.cbor(0x01, params);
const authData = r.get(2);
const idLen = authData.readUInt16BE(53);
return Buffer.from(authData.subarray(55, 55 + idLen));
} finally {
clearTimeout(timer);
dev.close();
}
}
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
const rpId = 'localhost';
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
let cred = null;
for (const c of creds) {
try {
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
cred = c; break;
} catch (e) { if (e.code !== 0x2e) throw e; }
}
if (!cred) throw new CtapError(0x2e);
const { key, platformKey } = await sharedSecret(dev);
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
const saltEnc = aes('enc', key, salt);
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
ui.touch(() => dev.cancel());
timer = setTimeout(() => dev.cancel(), timeoutMs);
const r = await dev.cbor(0x02, new Map([
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
]));
const authData = r.get(2);
if (!(authData[32] & 0x80)) throw new CtapError(-1);
const ext = dec(authData, 37)[0].get('hmac-secret');
if (!ext) throw new CtapError(-1);
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
} finally {
clearTimeout(timer);
dev.close();
}
}
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
+146 -3
View File
@@ -2,12 +2,19 @@
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus. // 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. // Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { acquireSudo } = require('./sudo');
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' }; 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 LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'"; 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 lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/;
const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; };
const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/';
const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64');
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s); const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
function execOn(conn, cmd, stdin) { function execOn(conn, cmd, stdin) {
@@ -31,12 +38,18 @@ class DockerManager {
async open(id, host, onClose) { async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id); const { conn, jumps } = await this.ssh.connect(host, id);
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false }); this.sessions.set(id, { id, conn, jumps, host, runtime: null, sudo: false });
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } }); conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
conn.on('error', () => {}); conn.on('error', () => {});
return this.list(id); return this.list(id);
} }
// sudo-Passwort einmal pro Sitzung ermitteln (ohne Passwort, gespeichertes Host-Passwort oder Nachfrage)
async sudo(id, s) {
if (s.sudoPw === undefined) s.sudoPw = await acquireSudo(this.ssh, id, s.host, (c, stdin) => execOn(s.conn, c, stdin));
return s.sudoPw != null;
}
get(id) { get(id) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Docker session not found')); if (!s) throw new Error(i18n.t('Docker session not found'));
@@ -45,7 +58,7 @@ class DockerManager {
run(s, args) { run(s, args) {
const sudo = s.sudo ? "sudo -S -p '' " : ''; const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
} }
async list(id) { async list(id) {
@@ -56,7 +69,7 @@ class DockerManager {
s.runtime = r.out.trim(); s.runtime = r.out.trim();
} }
let r = await this.run(s, `ps -a --format ${LIST_FMT}`); let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code && denied(r.err) && !s.sudo) { if (r.code && denied(r.err) && !s.sudo && await this.sudo(id, s)) {
s.sudo = true; s.sudo = true;
r = await this.run(s, `ps -a --format ${LIST_FMT}`); r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code) s.sudo = false; if (r.code) s.sudo = false;
@@ -102,6 +115,136 @@ class DockerManager {
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`; return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
} }
// ---------- Compose-Stacks ----------
// Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls`
// gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort.
async composeCmd(s) {
if (s.compose) return s.compose;
const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose'];
for (const c of cands) {
const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`);
if (!r.code) return (s.compose = c);
}
throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).'));
}
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
async sh(s, script) {
let r = await execOn(s.conn, `sh -c ${q(script)}`);
if (r.code && /permission denied|operation not permitted/i.test(r.err) && await this.sudo(s.id, s)) {
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.sudoPw}\n`);
}
return r;
}
async stacks(id, stacksDir) {
const s = this.get(id);
const dir = absPath(stacksDir);
const compose = await this.composeCmd(s);
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; `
+ `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; `
+ `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); `
+ `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; `
+ `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`;
const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]);
const lines = r.out.split('\n').filter(Boolean);
let running = [];
try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ }
const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }]));
const list = lines.filter((l) => !l.startsWith('#')).map((l) => {
const [name, file, git] = l.split('\t');
return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' };
});
for (const [name, st] of status) {
if (list.some((x) => x.name === name) || !st.files[0]) continue;
list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status });
}
return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) };
}
runCompose(s, compose, args) {
const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
}
async readStack(id, file) {
const s = this.get(id);
const f = absPath(file);
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
const env = q(dirname(f) + '/.env');
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
const i = r.out.lastIndexOf('\n\0');
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
}
// { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning }
async saveStack(id, { stacksDir, name, file, compose, env }) {
const s = this.get(id);
let f;
if (file) f = absPath(file);
else {
if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`;
}
const d = dirname(f);
const envFile = `${d}/.env`;
const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && `
+ (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`);
const r = await this.sh(s, script);
if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name }));
if (r.code) {
if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d }));
throw new Error(lastLine(r.err) || 'Error');
}
const compose2 = await this.composeCmd(s);
const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`);
return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' };
}
// Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern)
async stackCommand(id, action, st, opts = {}) {
const s = this.get(id);
const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`;
const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `;
if (action === 'setup') {
const d = absPath(st.dir);
return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`;
}
if (action === 'clone') {
if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' }));
const parent = absPath(st.stacksDir).replace(/\/$/, '');
const d = `${parent}/${st.name}`;
const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : '';
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`;
return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && `
+ `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"`
+ (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : '');
}
const f = absPath(st.file);
const cd = `cd ${q(dirname(f))} && `;
const p = `${compose} -p ${q(st.name)} -f ${q(f)}`;
switch (action) {
case 'up': return `${cd}${p} up -d --remove-orphans`;
case 'stop': return `${cd}${p} stop`;
case 'restart': return `${cd}${p} restart`;
case 'down': return `${cd}${p} down`;
case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`;
case 'logs': return `${cd}${p} logs -f --tail 200`;
case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`;
case 'delete': {
if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted');
const d = absPath(st.dir);
return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`;
}
default: throw new Error('Invalid action');
}
}
close(id) { close(id) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s) return; if (!s) return;
+83 -4
View File
@@ -4,8 +4,11 @@
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost". // 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 // 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). // Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
const { BrowserWindow, session } = require('electron'); const { BrowserWindow, session } = require('electron');
const i18n = require('../i18n'); const i18n = require('../i18n');
const ctap2 = require('./ctap2');
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style> 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} html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
@@ -13,7 +16,10 @@ const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
.ic{font-size:34px} #t{max-width:340px;line-height:1.4} .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{-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} button:hover{background:#343a4f}
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`; form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
.row{display:flex;gap:8px}
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
let fidoSession; let fidoSession;
function getSession() { function getSession() {
@@ -29,17 +35,23 @@ const HELPERS = `
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)); const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
`; `;
async function ceremony(parent, text, script) { async function openWindow(parent, text) {
const w = new BrowserWindow({ const w = new BrowserWindow({
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false, parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true }, backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
}); });
try {
await w.loadURL('http://localhost/'); await w.loadURL('http://localhost/');
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)}; await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`); const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
w.show(); w.show();
w.focus(); w.focus();
return w;
}
async function ceremony(parent, text, script) {
const w = await openWindow(parent, text);
try {
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' }))); const 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]); 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 === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
@@ -50,8 +62,71 @@ async function ceremony(parent, text, script) {
} }
} }
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
async function registerNative(parent) {
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
const ui = {
async askPin(retries, wrong) {
let t = i18n.t('Enter the PIN of your security key.');
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
await setText(t);
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
return Promise.race([closed, js(`new Promise((resolve) => {
const f = document.getElementById('f'), p = document.getElementById('p');
f.hidden = false; p.value = ''; p.focus();
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
})`)]);
},
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
};
try {
const id = await ctap2.makeCredential(ui);
return id && b64url(id);
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
async function deriveNative(parent, creds, text) {
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
try {
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
return r && { credId: b64url(r.credId), secret: r.secret };
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url) // Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
async function register(parent) { async function register(parent) {
if (process.platform === 'linux') {
const id = await registerNative(parent);
if (id) return id;
}
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), ` const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
const cred = await navigator.credentials.create({ publicKey: { const cred = await navigator.credentials.create({ publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)), challenge: crypto.getRandomValues(new Uint8Array(32)),
@@ -69,6 +144,10 @@ async function register(parent) {
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url) // PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
// Liefert { credId, secret: Buffer(32) } // Liefert { credId, secret: Buffer(32) }
async function derive(parent, creds, text) { async function derive(parent, creds, text) {
if (process.platform === 'linux') {
const r = await deriveNative(parent, creds, text);
if (r) return r;
}
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), ` const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
const creds = ${JSON.stringify(creds)}; const creds = ${JSON.stringify(creds)};
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }])); const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
+4 -3
View File
@@ -3,6 +3,7 @@
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen. // Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { execOn } = require('./docker'); const { execOn } = require('./docker');
const { acquireSudo } = require('./sudo');
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const RX = { const RX = {
@@ -58,7 +59,7 @@ class FirewallManager {
// Befehl mit root-Rechten ausführen // Befehl mit root-Rechten ausführen
run(s, cmd) { run(s, cmd) {
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`); if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
} }
async runOk(s, cmd) { async runOk(s, cmd) {
const r = await this.run(s, cmd); const r = await this.run(s, cmd);
@@ -75,8 +76,8 @@ class FirewallManager {
const uid = (await execOn(conn, 'id -u')).out.trim(); const uid = (await execOn(conn, 'id -u')).out.trim();
if (uid !== '0') { if (uid !== '0') {
s.sudo = true; s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
} }
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\''); const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean); s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
+137 -31
View File
@@ -15,7 +15,13 @@ const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker'); const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall'); const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network'); const { NetworkConfigManager } = require('./network');
const { WebServerManager } = require('./webserver');
const { CertManager, probe: probeTls } = require('./certs');
const { SambaManager } = require('./samba');
const { SyncService } = require('./sync'); const { SyncService } = require('./sync');
const backup = require('../core/backup');
const composeTemplates = require('./compose-templates');
const { AiAssistant } = require('./ai');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -40,8 +46,19 @@ process.on('uncaughtException', (e) => {
}); });
process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e));
// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID),
// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster.
const tabWins = new Set();
const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster)
const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']);
const alive = (w) => w && !w.isDestroyed();
function send(channel, ...args) { function send(channel, ...args) {
if (win && !win.isDestroyed()) win.webContents.send(channel, ...args); if (channel === 'secret:request') {
const w = sessionOwner.get(args[0]?.sessionId);
if (alive(w)) return w.webContents.send(channel, ...args);
}
if (alive(win)) win.webContents.send(channel, ...args);
if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args);
} }
async function confirmHostKey(target, fingerprint, known) { async function confirmHostKey(target, fingerprint, known) {
@@ -75,6 +92,9 @@ const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh); const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh); const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh); const network = new NetworkConfigManager(ssh);
// Verwaltungs-Tabs mit gemeinsamen Kanälen <prefix>:open/read/op/close (siehe admin.js)
const admins = { web: new WebServerManager(ssh), certs: new CertManager(ssh), samba: new SambaManager(ssh) };
const ai = new AiAssistant(store, send);
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche // LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
const pairReplies = new Map(); const pairReplies = new Map();
@@ -87,12 +107,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => {
ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); }); ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); });
store.onChange = () => sync.schedule(); store.onChange = () => sync.schedule();
function createWindow() { function newWindow(opts = {}) {
win = new BrowserWindow({ const w = new BrowserWindow({
width: 1360, width: 1360,
height: 860, height: 860,
minWidth: 900, minWidth: 600,
minHeight: 560, minHeight: 400,
...opts,
backgroundColor: '#14161d', backgroundColor: '#14161d',
icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'), icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'),
frame: false, frame: false,
@@ -104,18 +125,57 @@ function createWindow() {
sandbox: true, sandbox: true,
}, },
}); });
w.on('maximize', () => w.webContents.send('win:state', true));
w.on('unmaximize', () => w.webContents.send('win:state', false));
w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
return w;
}
function createWindow() {
win = newWindow({ minWidth: 900, minHeight: 560 });
Menu.setApplicationMenu(null); Menu.setApplicationMenu(null);
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
win.on('maximize', () => send('win:state', true)); // Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen
win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); });
// Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen // Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen
const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } }; const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } };
win.on('move', followRdp); win.on('move', followRdp);
win.on('resize', followRdp); win.on('resize', followRdp);
win.on('restore', followRdp); win.on('restore', followRdp);
win.on('unmaximize', () => send('win:state', false));
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
} }
// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt
const handoffs = new Map();
function openTabWindow(data, pos) {
const token = crypto.randomUUID();
handoffs.set(token, data);
const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) });
tabWins.add(w);
w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } });
// Fenster zu: dort noch offene Sitzungen beenden
w.on('closed', () => {
tabWins.delete(w);
for (const [id, o] of sessionOwner) {
if (o !== w) continue;
sessionOwner.delete(id);
for (const m of [ssh, docker, firewall, network, ...Object.values(admins)]) { try { m.close(id); } catch {} }
}
});
}
ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; });
ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; });
// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung
ipcMain.on('win:claim', (e, id) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w);
});
// Tab ins Hauptfenster zurück
ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; });
// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen
ipcMain.on('win:released', (e, id) => {
for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id);
});
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar // Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
const OPEN_WHILE_LOCKED = new Set(['app:version']); const OPEN_WHILE_LOCKED = new Set(['app:version']);
function handle(channel, fn) { function handle(channel, fn) {
@@ -129,9 +189,10 @@ function handle(channel, fn) {
} }
// ---------- Fenster ---------- // ---------- Fenster ----------
ipcMain.on('win:min', () => win.minimize()); const senderWin = (e) => BrowserWindow.fromWebContents(e.sender);
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on('win:min', (e) => senderWin(e)?.minimize());
ipcMain.on('win:close', () => win.close()); ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); });
ipcMain.on('win:close', (e) => senderWin(e)?.close());
// ---------- App-Sperre (Passwort / FIDO2) ---------- // ---------- App-Sperre (Passwort / FIDO2) ----------
const kdf = (pw, salt, N) => new Promise((resolve, reject) => const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
@@ -232,6 +293,12 @@ handle('docker:stats', (id) => docker.stats(id));
handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
handle('docker:close', (id) => docker.close(id)); handle('docker:close', (id) => docker.close(id));
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
handle('docker:templates', (url) => composeTemplates.list(url));
handle('docker:template', (index, url) => composeTemplates.build(index, url));
// ---------- Firewall ---------- // ---------- Firewall ----------
handle('firewall:open', async (id, hostRef) => { handle('firewall:open', async (id, hostRef) => {
@@ -259,6 +326,19 @@ handle('network:readFile', (id, path) => network.readFile(id, path));
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify)); handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id)); handle('network:close', (id) => network.close(id));
// ---------- Webserver / Zertifikate / Samba-AD ----------
for (const [p, m] of Object.entries(admins)) {
handle(`${p}:open`, async (id, hostRef) => {
const host = hostWithOverrides(hostRef);
if (await vpn.ensureForHost(host)) send('vpn:changed');
return m.open(id, host, () => send(`${p}:closed`, id));
});
handle(`${p}:read`, (id) => m.read(id));
handle(`${p}:op`, (id, op, ...args) => m.op(id, op, ...args));
handle(`${p}:close`, (id) => m.close(id));
}
handle('certs:probe', (host, port) => probeTls(host, port));
// ---------- Synchronisation ---------- // ---------- Synchronisation ----------
handle('sync:status', () => sync.status()); handle('sync:status', () => sync.status());
handle('sync:enable', (on, name) => sync.setEnabled(on, name)); handle('sync:enable', (on, name) => sync.setEnabled(on, name));
@@ -270,6 +350,11 @@ handle('sync:share', (sel) => sync.setShare(sel));
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes)); handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
handle('sync:probe', (address) => sync.probe(address)); handle('sync:probe', (address) => sync.probe(address));
// ---------- Support-Assistent (Ollama) ----------
handle('ai:models', (url) => ai.models(url));
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
handle('ai:stop', (id) => ai.stop(id));
// ---------- VPN ---------- // ---------- VPN ----------
handle('vpn:status', () => vpn.status()); handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id)); handle('vpn:up', (id) => vpn.up(id));
@@ -280,19 +365,19 @@ handle('vault:settings', (s) => {
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) => store.forgetKnownHost(id)); handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:export', async () => { // Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
if (r.canceled) return false; handle('backup:inspect', (content) => backup.inspectBackup(content));
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 }); handle('backup:import', (content, password, opts) => {
return r.filePath; const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) applyLanguage();
return r;
}); });
handle('vault:import', async () => { handle('backup:saveFile', async (content, name) => {
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] }); const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
if (r.canceled) return false; if (r.canceled) return false;
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8')); fs.writeFileSync(r.filePath, content, { mode: 0o600 });
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns']) return r.filePath;
for (const item of data[col] || []) store.upsert(col, item);
return true;
}); });
// Import aus ~/.ssh/config // Import aus ~/.ssh/config
@@ -393,6 +478,11 @@ handle('sftp:open', async (sessionId, hostRef) => {
}); });
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));
// Dateien für den SFTP-Upload auswählen (Mehrfachauswahl)
handle('sftp:pickUpload', async () => {
const r = await dialog.showOpenDialog(win, { title: i18n.t('Upload'), properties: ['openFile', 'multiSelections', 'showHiddenFiles'] });
return r.canceled ? [] : r.filePaths.map((p) => ({ path: p, name: path.basename(p) }));
});
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => { handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
let last = 0; let last = 0;
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => { await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
@@ -458,30 +548,46 @@ 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'); if (await vpn.ensureForHost(host)) send('vpn:changed');
const emb = createEmbed(win.getNativeWindowHandle()); const sess = { cancelled: false };
const sess = { emb, cancelled: false };
rdpSessions.set(id, sess); rdpSessions.set(id, sess);
return startRdpEmbed(id, sess, host, bounds);
});
async function startRdpEmbed(id, sess, host, bounds, acceptCert = false) {
const emb = createEmbed(win.getNativeWindowHandle());
emb.acceptCert = acceptCert;
sess.emb = emb;
const size = { width: bounds.width, height: bounds.height }; const size = { width: bounds.width, height: bounds.height };
let proc; let proc;
if (process.platform === 'win32') { if (process.platform === 'win32') {
const file = rdp.writeRdpFile(host, size); const args = rdp.mstscArgs(host, size);
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file })))); proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
} else { } else {
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password }); proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
} }
proc.on('error', (e) => send('rdp:event', id, 'exit', { code: -1, message: e.message })); const current = () => rdpSessions.get(id) === sess && sess.emb === emb;
proc.on('error', (e) => { if (current()) send('rdp:event', id, 'exit', { code: -1, message: e.message }); });
proc.on('exit', (code) => { proc.on('exit', (code) => {
if (!current()) return; // durch Neuverbindung ersetzt
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || ''; const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
if (rdpSessions.get(id) === sess) send('rdp:event', id, 'exit', { code, message: err }); send('rdp:event', id, 'exit', { code, message: err });
rdpSessions.delete(id); rdpSessions.delete(id);
}); });
const ok = await emb.attach(bounds, () => sess.cancelled); // Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
if (ok) { emb.onResizeNeeded = (b) => {
if (!current()) return;
send('rdp:event', id, 'resizing');
emb.kill();
startRdpEmbed(id, sess, host, b, emb.certAccepted).then((ok) => {
if (!ok && rdpSessions.get(id) === sess && !sess.cancelled) send('rdp:event', id, 'exit', { code: -1, message: '' });
});
};
const ok = await emb.attach(bounds, () => sess.cancelled || !current());
if (ok && current()) {
if (sess.hidden) emb.hide(); if (sess.hidden) emb.hide();
send('rdp:event', id, 'attached'); send('rdp:event', id, 'attached');
} }
return ok; return ok;
}); }
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} }); ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } }); ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } }); ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
+4 -3
View File
@@ -4,6 +4,7 @@
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann. // nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
const i18n = require('../i18n'); const i18n = require('../i18n');
const { execOn } = require('./docker'); const { execOn } = require('./docker');
const { acquireSudo } = require('./sudo');
const nc = require('./netconf'); const nc = require('./netconf');
const STATE = '/var/lib/mrterm-net'; const STATE = '/var/lib/mrterm-net';
@@ -127,7 +128,7 @@ class NetworkConfigManager {
root(s, script, conn = s.conn) { root(s, script, conn = s.conn) {
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`; const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
if (!s.sudo) return execOn(conn, cmd); if (!s.sudo) return execOn(conn, cmd);
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
} }
async rootOk(s, script) { async rootOk(s, script) {
const r = await this.root(s, script); const r = await this.root(s, script);
@@ -142,8 +143,8 @@ class NetworkConfigManager {
this.watch(id, s); this.watch(id, s);
if ((await execOn(conn, 'id -u')).out.trim() !== '0') { if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
s.sudo = true; s.sudo = true;
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
} }
return this.read(id); return this.read(id);
} }
+135 -36
View File
@@ -1,5 +1,5 @@
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab). // Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
// Windows: mstsc.exe wird gestartet, sein Hauptfenster (TscShellContainerClass) per SetParent eingehängt. // Windows: mstsc.exe wird gestartet, sein Sitzungsfenster (TscShellContainerClass) rahmenlos über den Tab gelegt.
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland). // Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters. // Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
const { spawn } = require('child_process'); const { spawn } = require('child_process');
@@ -12,13 +12,25 @@ const i18n = require('../i18n');
// 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
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window" // mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag. // von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
// Mit "dynamic resolution" passt mstsc die Auflösung der Sitzung an, sobald sich die Fenstergröße ändert.
function winApi() { function winApi() {
if (winApi.api) return winApi.api; if (winApi.api) return winApi.api;
const u = koffi.load('user32.dll'); const u = koffi.load('user32.dll');
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)'); const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
const WinEventProc = koffi.proto('void __stdcall MrTermWinEventProc(intptr hook, uint32 event, intptr hwnd, long idObject, long idChild, uint32 thread, uint32 time)');
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' }); const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
const RECT = koffi.struct('MrTermRECT', { left: 'int', top: 'int', right: 'int', bottom: 'int' });
winApi.api = { winApi.api = {
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'), EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
EnumChildWindows: u.func('bool __stdcall EnumChildWindows(intptr hwnd, MrTermEnumProc *cb, intptr lParam)'),
SetWinEventHook: u.func('intptr __stdcall SetWinEventHook(uint32 min, uint32 max, intptr mod, MrTermWinEventProc *cb, uint32 pid, uint32 tid, uint32 flags)'),
UnhookWinEvent: u.func('bool __stdcall UnhookWinEvent(intptr hook)'),
GetDlgItem: u.func('intptr __stdcall GetDlgItem(intptr hwnd, int id)'),
PostMessageW: u.func('bool __stdcall PostMessageW(intptr hwnd, uint32 msg, uintptr w, intptr l)'),
GetAncestor: u.func('intptr __stdcall GetAncestor(intptr hwnd, uint32 flags)'),
GetWindowRect: u.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ MrTermRECT *rect)'),
SetLayeredWindowAttributes: u.func('bool __stdcall SetLayeredWindowAttributes(intptr hwnd, uint32 key, uint8 alpha, uint32 flags)'),
WinEventProc,
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'), GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'), GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'), IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
@@ -34,58 +46,143 @@ function winApi() {
return winApi.api; return winApi.api;
} }
function findWindowsOfPid(pid, cls) { const buf = Buffer.alloc(512);
const api = winApi(); const className = (hwnd) => buf.toString('utf16le', 0, winApi().GetClassNameW(hwnd, buf, 256) * 2);
const out = []; function childClasses(hwnd) {
const buf = Buffer.alloc(512); const out = new Set();
api.EnumWindows((hwnd) => { winApi().EnumChildWindows(hwnd, (c) => { out.add(className(c)); return true; }, 0);
const p = [0];
api.GetWindowThreadProcessId(hwnd, p);
if (p[0] === pid && api.IsWindowVisible(hwnd)) {
const n = api.GetClassNameW(hwnd, buf, 256);
if (!cls || buf.toString('utf16le', 0, n * 2) === cls) out.push(hwnd);
}
return true;
}, 0);
return out; return out;
} }
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8; const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000; const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000; const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080; const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080, WS_EX_LAYERED = 0x00080000;
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX; const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
const SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40; const SWP_NOSIZE = 0x1, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5; const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
const EVENT_OBJECT_CREATE = 0x8000, EVENT_OBJECT_SHOW = 0x8002, WINEVENT_OUTOFCONTEXT = 0, GA_ROOT = 2, LWA_ALPHA = 2;
const BM_CLICK = 0xf5, ID_CERT_YES = 14004, ID_CERT_VIEW = 13443; // Zertifikatswarnung von mstsc: "Ja", "Zertifikat anzeigen"
class WinEmbed { class WinEmbed {
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.cls = 'TscShellContainerClass'; this.visible = true; } constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.visible = true; }
start({ rdpFile }) { // args: mstsc-Kommandozeile. Mit /v:… statt .rdp-Datei zeigt mstsc keine Sicherheitswarnung "Unbekannter Herausgeber".
this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' }); start({ args }) {
this.proc = spawn('mstsc.exe', args, { stdio: 'ignore' });
this.hookWindows();
this.proc.on('exit', () => this.unhook());
return this.proc; return this.proc;
} }
// Wartet, bis mstsc sein Sitzungsfenster geöffnet hat (Anmelde-/Zertifikatsdialoge davor bleiben eigene Fenster) // Fängt die Fenster von mstsc ab, sobald sie entstehen (läuft über die Nachrichtenschleife des Electron-Hauptthreads):
async attach(bounds, isCancelled) { // Sitzungsfenster -> noch unsichtbar rahmenlos über den Tab legen, damit nie ein normales mstsc-Fenster aufblitzt
const t0 = Date.now(); // "Verbindung wird hergestellt" (Dialog mit Fortschrittsbalken) -> unsichtbar lassen, der Tab zeigt den Status selbst
while (Date.now() - t0 < 120000) { // andere Dialoge (Zertifikat, Fehler) -> brauchen eine Entscheidung, daher mittig über dem Tab einblenden
if (isCancelled() || this.proc.exitCode !== null) return false; hookWindows() {
const [hwnd] = findWindowsOfPid(this.proc.pid, this.cls);
if (hwnd) {
const api = winApi(); const api = winApi();
this.cb = koffi.register((_hook, event, hwnd, idObject, idChild) => {
try {
if (idObject !== 0 || idChild !== 0 || Number(api.GetAncestor(hwnd, GA_ROOT)) !== Number(hwnd)) return;
const cls = className(hwnd);
if (cls === 'TscShellContainerClass') {
if (event === EVENT_OBJECT_CREATE || !this.hwnd) this.adopt(hwnd);
if (event === EVENT_OBJECT_SHOW) this.onSessionShown();
} else if (cls === '#32770') {
const isCert = () => api.GetDlgItem(hwnd, ID_CERT_YES) && api.GetDlgItem(hwnd, ID_CERT_VIEW);
if (event === EVENT_OBJECT_CREATE) this.setAlpha(hwnd, 0);
else if (childClasses(hwnd).has('msctls_progress32')) api.ShowWindow(hwnd, SW_HIDE);
else if (this.acceptCert && isCert()) api.PostMessageW(api.GetDlgItem(hwnd, ID_CERT_YES), BM_CLICK, 0, 0);
else { if (isCert()) this.certPrompted = true; this.presentDialog(hwnd); }
}
} catch { /* Fenster kann bereits wieder zu sein */ }
}, koffi.pointer(api.WinEventProc));
this.hook = api.SetWinEventHook(EVENT_OBJECT_CREATE, EVENT_OBJECT_SHOW, 0, this.cb, this.proc.pid, 0, WINEVENT_OUTOFCONTEXT);
}
unhook() {
if (this.hook) { winApi().UnhookWinEvent(this.hook); this.hook = null; }
if (this.cb) { koffi.unregister(this.cb); this.cb = null; }
}
setAlpha(hwnd, alpha) {
const api = winApi();
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) | WS_EX_LAYERED) >>> 0);
api.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
}
presentDialog(hwnd) {
const api = winApi();
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
const r = {};
api.GetWindowRect(hwnd, r);
const b = this.bounds || { x: 0, y: 0, width: 0, height: 0 };
const pt = { x: b.x + Math.round((b.width - (r.right - r.left)) / 2), y: b.y + Math.round((b.height - (r.bottom - r.top)) / 2) };
api.ClientToScreen(this.parent, pt);
api.SetWindowPos(hwnd, 0, pt.x, pt.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
this.setAlpha(hwnd, 255);
api.SetForegroundWindow(hwnd);
}
// Sitzungsfenster rahmenlos und ohne Taskleisten-Eintrag als "owned window" von MrTerm
adopt(hwnd) {
const api = winApi();
this.hwnd = hwnd;
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0; const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style); api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0); const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex); api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent); // Besitzer = MrTerm api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
this.hwnd = hwnd; // Bis zur Anmeldung außerhalb des Bildschirms parken: mstsc zentriert seine Dialoge über diesem Fenster,
this.setBounds(bounds); // so bleibt auch "Verbindung wird hergestellt" unsichtbar, bevor der Hook ihn ausblenden kann.
return true; if (!this.connected) api.SetWindowPos(hwnd, 0, -32000, -32000, this.bounds?.width || 800, this.bounds?.height || 600, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
} }
await new Promise((r) => setTimeout(r, 250));
// mstsc blendet das Sitzungsfenster erst nach erfolgreicher Anmeldung ein und setzt dabei Rahmen und Größe neu
onSessionShown() {
this.connected = true;
this.adopt(this.hwnd);
if (!this.visible) winApi().ShowWindow(this.hwnd, SW_HIDE);
this.setBounds();
this.detectFixedSize();
this.setBounds();
this.onConnected?.();
} }
return false;
// Ohne dynamische Auflösung (Server zu alt, xrdp < 0.10 …) begrenzt mstsc das Fenster auf die Sitzungsgröße –
// erkennbar daran, dass es sich nicht vergrößern lässt. Dann hilft nur Neuverbinden mit der neuen Größe.
detectFixedSize() {
const api = winApi();
const r = {};
api.GetWindowRect(this.hwnd, r);
api.SetWindowPos(this.hwnd, 0, r.left, r.top, r.right - r.left + 32, r.bottom - r.top + 32, SWP_NOZORDER | SWP_NOACTIVATE);
const g = {};
api.GetWindowRect(this.hwnd, g);
this.fixedSize = g.right - g.left < r.right - r.left + 32 ? { width: r.right - r.left, height: r.bottom - r.top } : null;
}
// Zertifikat wurde für diese Sitzung bestätigt (Rückfrage gezeigt und danach verbunden) – gilt für Neuverbindungen beim Größenändern
get certAccepted() { return this.acceptCert || (this.certPrompted && this.connected); }
checkFixedSize() {
clearTimeout(this.resizeTimer);
const b = this.bounds;
if (!this.fixedSize || !this.visible || (Math.abs(b.width - this.fixedSize.width) < 8 && Math.abs(b.height - this.fixedSize.height) < 8)) return;
this.resizeTimer = setTimeout(() => this.onResizeNeeded?.(this.bounds), 800);
}
// Wartet, bis die Sitzung steht (Sitzungsfenster sichtbar)
attach(bounds, isCancelled) {
this.bounds = bounds;
return new Promise((resolve) => {
const t0 = Date.now();
const done = (ok) => { clearInterval(iv); this.onConnected = null; resolve(ok); };
this.onConnected = () => done(true);
const iv = setInterval(() => {
if (this.connected) done(true);
else if (isCancelled() || this.proc.exitCode !== null || Date.now() - t0 > 120000) done(false);
}, 250);
});
} }
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten // b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
@@ -96,15 +193,17 @@ class WinEmbed {
const pt = { x: this.bounds.x, y: this.bounds.y }; const pt = { x: this.bounds.x, y: this.bounds.y };
api.ClientToScreen(this.parent, pt); api.ClientToScreen(this.parent, pt);
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height), api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible ? SWP_SHOWWINDOW : 0)); SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible && this.connected ? SWP_SHOWWINDOW : 0));
if (this.connected) this.checkFixedSize();
} }
reposition() { this.setBounds(); } reposition() { this.setBounds(); }
show() { this.visible = true; if (this.hwnd) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } } show() { this.visible = true; if (this.hwnd && this.connected) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
hide() { this.visible = false; if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); } hide() { this.visible = false; clearTimeout(this.resizeTimer); if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); } focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen // Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
detach() { detach() {
if (!this.hwnd) return; if (!this.hwnd) return;
this.unhook();
const api = winApi(); const api = winApi();
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0); api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0); const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
@@ -115,7 +214,7 @@ class WinEmbed {
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW); api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
this.hwnd = null; this.hwnd = null;
} }
kill() { try { this.proc?.kill(); } catch {} } kill() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} }
} }
// ------------------------------------------------------------------ Linux (X11) // ------------------------------------------------------------------ Linux (X11)
+11 -1
View File
@@ -63,6 +63,16 @@ function writeRdpFile(host, size) {
return file; return file;
} }
// mstsc-Kommandozeile für eingebettete Sitzungen. Seit 2026 zeigt mstsc bei jeder unsignierten .rdp-Datei eine
// Sicherheitswarnung – mit /v:… startet es ohne. Die übrigen Optionen kommen dann aus Default.rdp (Zwischenablage an,
// Audio lokal, keine Laufwerke, dynamische Auflösung); weicht der Host davon ab, geht es nur über eine .rdp-Datei.
function mstscArgs(host, size) {
const port = Number(host.port) || 3389;
const needsFile = host.rdpDrives || host.rdpClipboard === false || host.rdpAudio === false || host.rdpResize === 'scale';
if (needsFile) return [writeRdpFile(host, size)];
return [`/v:${port === 3389 ? host.address : `${host.address}:${port}`}`, `/w:${size.width}`, `/h:${size.height}`];
}
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet. // Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
function withWinCredentials(host, run) { function withWinCredentials(host, run) {
const user = userOf(host); const user = userOf(host);
@@ -110,4 +120,4 @@ function launch(host, settings) {
return { client, process: p, getErr: () => err }; return { client, process: p, getErr: () => err };
} }
module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs }; module.exports = { detect, launch, linuxClient, writeRdpFile, mstscArgs, withWinCredentials, freerdpArgs };
+347
View File
@@ -0,0 +1,347 @@
// Samba-/AD-Tab: Freigaben (smb.conf), Samba-Benutzer, aktive Verbindungen, Dienste und – auf einem
// Samba-AD-Domänencontroller – Benutzer, Gruppen und Computer der Domäne (samba-tool).
// Passwörter gehen nur über stdin an smbpasswd/samba-tool, nie über die Kommandozeile.
// smb.conf wird abschnittsweise geändert (Kommentare bleiben erhalten), mit testparm geprüft und sonst zurückgerollt.
const i18n = require('../i18n');
const { AdminManager, sections, q, check, installCmd } = require('./admin');
const SMB_CONF = '/etc/samba/smb.conf';
const RX = {
share: /^[\w$][\w $.-]{0,79}$/,
user: /^[a-zA-Z0-9_][\w.-]{0,63}\$?$/,
path: /^\/[\w./@+ -]*$/,
users: /^[\w@%+.\\, -]{0,400}$/,
mask: /^0?[0-7]{3,4}$/,
text: /^[^\n\r'"`$\\]{0,200}$/,
group: /^[\w][\w .-]{0,63}$/,
};
const noDots = (p) => { if (String(p).split('/').includes('..')) throw new Error('Invalid path'); return p; };
const BOOL = /^(yes|no)$/;
// Felder des Freigabe-Formulars → smb.conf-Parameter
const SHARE_FIELDS = { path: 'path', comment: 'comment', readOnly: 'read only', browseable: 'browseable', guestOk: 'guest ok', validUsers: 'valid users', writeList: 'write list', createMask: 'create mask', directoryMask: 'directory mask' };
const GATHER = `echo "@@VERSION"; command -v smbd >/dev/null 2>&1 && smbd --version 2>/dev/null
echo "@@TOOLS"; for c in smbd samba samba-tool pdbedit smbpasswd smbstatus testparm wbinfo realm net ldbsearch; do command -v $c >/dev/null 2>&1 && echo $c; done
echo "@@UNITS"; for u in smbd nmbd winbind samba samba-ad-dc sssd; do s=$(systemctl is-enabled "$u" 2>/dev/null); case "$s" in ''|not-found) continue;; esac; echo "$u|$(systemctl is-active "$u" 2>/dev/null)|$s"; done
echo "@@ROLE"; command -v testparm >/dev/null 2>&1 && testparm -s --parameter-name='server role' 2>/dev/null
echo "@@CONF"; command -v testparm >/dev/null 2>&1 && testparm -s 2>/dev/null
echo "@@USERS"; command -v pdbedit >/dev/null 2>&1 && pdbedit -L -v 2>/dev/null
echo "@@STATUS"; command -v smbstatus >/dev/null 2>&1 && { smbstatus -j 2>/dev/null || { echo NOJSON; smbstatus 2>/dev/null; }; }
echo "@@REALM"; command -v realm >/dev/null 2>&1 && realm list 2>/dev/null
echo "@@WBINFO"; command -v wbinfo >/dev/null 2>&1 && wbinfo --online-status 2>/dev/null
echo "@@END"`;
// testparm -s: [abschnitt] und "param = wert"
function parseConf(text) {
const out = [];
let cur = null;
for (const line of String(text).split('\n')) {
let m;
if ((m = line.match(/^\s*\[(.+)\]\s*$/))) { cur = { name: m[1], params: {} }; out.push(cur); }
else if (cur && (m = line.match(/^\s*([^=]+?)\s*=\s*(.*)$/))) cur.params[m[1].toLowerCase()] = m[2];
}
return out;
}
function parsePdbedit(text) {
return String(text).split(/^-{5,}$/m).map((block) => {
const get = (k) => (block.match(new RegExp(`^${k}:[ \\t]*(.*)$`, 'm')) || [])[1]?.trim() || '';
const user = get('Unix username');
if (!user) return null;
const flags = get('Account Flags');
return { user, fullName: get('Full Name'), flags, disabled: /D/.test(flags.replace(/[[\]\s]/g, '')), lastLogon: get('Logon time') };
}).filter(Boolean);
}
// smbstatus -j (Samba ≥ 4.16) oder Text
function parseStatus(text) {
if (String(text).startsWith('NOJSON')) return { raw: text.replace(/^NOJSON\n?/, ''), sessions: [], shares: [], files: [] };
let j;
try { j = JSON.parse(text); } catch { return { raw: text, sessions: [], shares: [], files: [] }; }
const sessions = Object.values(j.sessions || {}).map((x) => ({ id: x.session_id, user: x.username, group: x.groupname, machine: x.remote_machine, host: x.hostname, protocol: x.session_dialect, encryption: x.encryption?.cipher || '', signing: x.signing?.cipher || '' }));
const shares = Object.values(j.tcons || {}).map((x) => ({ service: x.service, machine: x.machine, connected: x.connected_at, session: x.session_id }));
const files = Object.values(j.open_files || {}).map((x) => ({ path: `${x.service_path || ''}/${x.filename || ''}`.replace(/\/+/g, '/'), share: x.service_path, opens: Object.keys(x.opens || {}).length }));
return { sessions, shares, files };
}
// LDIF von ldbsearch → Objekte (mehrwertige Attribute als Array)
function parseLdif(text) {
const out = [];
for (const block of String(text).replace(/\n /g, '').split(/\n(?=# record|dn: )/)) {
if (!/^dn: /m.test(block)) continue;
const o = {};
for (const line of block.split('\n')) {
const m = line.match(/^(\w+)(::?) (.*)$/);
if (!m) continue;
const v = m[2] === '::' ? Buffer.from(m[3], 'base64').toString() : m[3];
const k = m[1];
if (o[k] === undefined) o[k] = v; else o[k] = [].concat(o[k], v);
}
out.push(o);
}
return out;
}
const arr = (v) => (v === undefined ? [] : [].concat(v));
const cn = (dn) => (String(dn).match(/^CN=([^,]+)/i) || [])[1] || dn;
// Windows FILETIME (100 ns seit 1601) → ms
const fileTime = (v) => { const n = Number(v); return n > 0 && n < 9e18 ? Math.round(n / 1e4 - 11644473600000) : null; };
class SambaManager extends AdminManager {
static OPS = ['service', 'readConf', 'writeConf', 'saveShare', 'deleteShare', 'addUser', 'setPassword', 'setUserEnabled', 'deleteUser',
'adUserCreate', 'adUserDelete', 'adUserEnable', 'adSetPassword', 'adGroupCreate', 'adGroupDelete', 'adGroupMembers', 'adComputerDelete', 'adRead', 'provisionDomain', 'joinDomain', 'leaveDomain', 'joinCommand', 'installCommand'];
async read(id) {
const s = this.get(id);
const sec = sections(await this.rootOk(s, GATHER));
const tools = new Set((sec.TOOLS || '').split('\n').filter(Boolean));
const units = (sec.UNITS || '').split('\n').filter(Boolean).map((l) => { const [unit, active, enabled] = l.split('|'); return { unit, active, enabled }; });
const role = (sec.ROLE || '').trim();
s.tools = tools;
s.units = new Set(units.map((u) => u.unit));
s.dc = /active directory domain controller/i.test(role);
const conf = parseConf(sec.CONF || '');
const global = conf.find((c) => c.name === 'global')?.params || {};
return {
pkg: s.pkg,
version: (sec.VERSION || '').replace(/^Version\s*/, ''),
installed: tools.has('smbd') || tools.has('samba'),
tools: [...tools], units, role, dc: s.dc,
global: { workgroup: global.workgroup || '', realm: global.realm || '', netbios: global['netbios name'] || '', security: global.security || '' },
shares: conf.filter((c) => c.name.toLowerCase() !== 'global').map((c) => ({
name: c.name, path: c.params.path || '', comment: c.params.comment || '', readOnly: c.params['read only'] ?? (c.params.writeable === 'yes' || c.params.writable === 'yes' ? 'no' : 'yes'),
browseable: c.params.browseable ?? c.params.browsable ?? 'yes', guestOk: c.params['guest ok'] ?? c.params.public ?? 'no', validUsers: c.params['valid users'] || '', writeList: c.params['write list'] || '',
createMask: c.params['create mask'] || '', directoryMask: c.params['directory mask'] || '', special: ['printers', 'print$', 'homes', 'netlogon', 'sysvol'].includes(c.name.toLowerCase()), params: c.params,
})),
users: parsePdbedit(sec.USERS || ''),
status: parseStatus(sec.STATUS || ''),
realm: (sec.REALM || '').trim(),
winbind: (sec.WBINFO || '').trim(),
};
}
// Abschnitt [name] in smb.conf ersetzen (lines = null → löschen); Kommentare anderer Abschnitte bleiben unverändert
static replaceSection(text, name, lines) {
const src = String(text).split('\n');
const isHead = (l) => /^\s*\[.+\]\s*$/.test(l);
const start = src.findIndex((l) => isHead(l) && l.trim().slice(1, -1).trim().toLowerCase() === name.toLowerCase());
const block = lines ? [`[${name}]`, ...lines.map((l) => ` ${l}`), ''] : [];
if (start < 0) { if (!lines) return text; return `${src.join('\n').replace(/\s*$/, '\n')}\n${block.join('\n')}`; }
let end = src.length;
for (let i = start + 1; i < src.length; i++) if (isHead(src[i])) { end = i; break; }
// Kommentarzeilen direkt vor dem nächsten Abschnitt gehören zu diesem
while (end - 1 > start && /^\s*[#;]/.test(src[end - 1])) end--;
// Beim Löschen auch den Kommentar direkt über dem eigenen Abschnitt entfernen
let from = start;
if (!lines) while (from > 0 && /^\s*[#;]/.test(src[from - 1])) from--;
src.splice(from, end - from, ...block);
return src.join('\n').replace(/\n{3,}/g, '\n\n');
}
reload() { return 'if command -v smbcontrol >/dev/null 2>&1; then smbcontrol all reload-config >/dev/null 2>&1 || true; fi'; }
async service(s, unit, action) {
if (!s.units?.has(unit)) throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'service', value: unit }));
check(action, /^(start|stop|restart|enable|disable)$/, 'action');
await this.rootOk(s, action === 'enable' || action === 'disable' ? `systemctl ${action} --now ${unit}` : `systemctl ${action} ${unit}`);
return true;
}
readConf(s) { return this.rootOk(s, `cat ${SMB_CONF}`); }
writeConf(s, content) { return this.writeChecked(s, SMB_CONF, content, { validate: `testparm -s ${SMB_CONF} >/dev/null`, after: this.reload() }); }
async saveShare(s, share, oldName, createDir) {
const name = check(String(share.name || '').trim(), RX.share, 'name');
if (name.toLowerCase() === 'global') throw new Error(i18n.t('Invalid value for {field}: {value}', { field: 'name', value: name }));
const path = check(noDots(String(share.path || '').trim()), RX.path, 'path');
const lines = [];
for (const [k, param] of Object.entries(SHARE_FIELDS)) {
const v = String(share[k] ?? '').trim();
if (!v) continue;
if (['readOnly', 'browseable', 'guestOk'].includes(k)) check(v, BOOL, param);
else if (k === 'createMask' || k === 'directoryMask') check(v, RX.mask, param);
else if (k === 'validUsers' || k === 'writeList') check(v, RX.users, param);
else if (k === 'comment') check(v, RX.text, param);
else if (k === 'path') check(v, RX.path, param);
lines.push(`${param} = ${k === 'path' ? path : v}`);
}
// Weitere Parameter einer bestehenden Freigabe übernehmen (z. B. vfs objects), die das Formular nicht kennt
for (const [k, v] of Object.entries(share.extra || {})) {
if (Object.values(SHARE_FIELDS).includes(k) || ['writeable', 'writable', 'browsable', 'public'].includes(k)) continue;
lines.push(`${check(k, /^[a-z][a-z0-9 :_-]{0,60}$/, 'parameter')} = ${check(v, /^[^\n\r]{0,400}$/, k)}`);
}
let text = await this.readConf(s);
if (oldName && oldName.toLowerCase() !== name.toLowerCase()) text = SambaManager.replaceSection(text, check(oldName, RX.share, 'name'), null);
else if (!oldName && parseConf(text).some((c) => c.name.toLowerCase() === name.toLowerCase())) throw new Error(i18n.t('A share named “{name}” already exists.', { name }));
text = SambaManager.replaceSection(text, name, lines);
if (createDir) await this.rootOk(s, `mkdir -p ${q(path)}`);
return this.writeConf(s, text);
}
async deleteShare(s, name) {
const text = await this.readConf(s);
return this.writeConf(s, SambaManager.replaceSection(text, check(name, RX.share, 'name'), null));
}
// Samba-Benutzer anlegen; optional fehlenden Linux-Benutzer ohne Login-Shell mit anlegen
async addUser(s, user, password, createUnix) {
const u = check(user, RX.user, 'user');
if (!password) throw new Error(i18n.t('Please enter a password.'));
await this.rootOk(s, `id ${q(u)} >/dev/null 2>&1 || { ${createUnix ? `useradd -M -s /usr/sbin/nologin ${q(u)} 2>/dev/null || useradd -M -s /sbin/nologin ${q(u)}` : `echo ${q(i18n.t('Linux user {user} does not exist.', { user: u }))} >&2; exit 3`}; } || exit 1
IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | smbpasswd -s -a ${q(u)}`, `${password}\n`);
return true;
}
async setPassword(s, user, password) {
if (!password) throw new Error(i18n.t('Please enter a password.'));
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | smbpasswd -s ${q(check(user, RX.user, 'user'))}`, `${password}\n`);
return true;
}
async setUserEnabled(s, user, on) {
await this.rootOk(s, `smbpasswd ${on ? '-e' : '-d'} ${q(check(user, RX.user, 'user'))}`);
return true;
}
async deleteUser(s, user) {
await this.rootOk(s, `smbpasswd -x ${q(check(user, RX.user, 'user'))}`);
return true;
}
// ---------- Samba-AD-Domänencontroller ----------
requireDc(s) { if (!s.dc) throw new Error(i18n.t('This host is not a Samba AD domain controller.')); }
async adRead(s) {
this.requireDc(s);
const info = await this.root(s, 'samba-tool domain info 127.0.0.1 2>&1; echo "@@LEVEL"; samba-tool domain level show 2>&1 | grep -E "level|Forest|Domain"; echo "@@FSMO"; samba-tool fsmo show 2>&1');
const sec = sections(`@@INFO\n${info.out}`);
let users = [], groups = [], computers = [];
if (s.tools.has('ldbsearch')) {
const ldb = (filter, attrs) => this.rootOk(s, `ldbsearch -H "$(testparm -s --parameter-name='private dir' 2>/dev/null)/sam.ldb" ${q(filter)} ${attrs} 2>/dev/null`).then(parseLdif);
const rawUsers = await ldb('(&(objectCategory=person)(objectClass=user))', 'sAMAccountName displayName mail userAccountControl memberOf lastLogonTimestamp description');
const rawGroups = await ldb('(objectClass=group)', 'sAMAccountName description member groupType');
const rawComputers = await ldb('(objectClass=computer)', 'sAMAccountName dNSHostName operatingSystem operatingSystemVersion lastLogonTimestamp');
// DN → Kontoname, denn samba-tool erwartet bei Mitgliedschaften sAMAccountName statt Anzeigename
const sam = new Map([...rawUsers, ...rawGroups, ...rawComputers].map((o) => [String(o.dn).toLowerCase(), o.sAMAccountName]));
const acct = (dn) => sam.get(String(dn).toLowerCase()) || cn(dn);
users = rawUsers.map((o) => ({
name: o.sAMAccountName, display: o.displayName || '', mail: o.mail || '', disabled: !!(Number(o.userAccountControl) & 2), groups: arr(o.memberOf).map(acct), lastLogon: fileTime(o.lastLogonTimestamp), description: o.description || '',
}));
groups = rawGroups.map((o) => ({
name: o.sAMAccountName, description: o.description || '', members: arr(o.member).map(acct), builtin: (Number(o.groupType) & 1) === 1,
}));
computers = rawComputers.map((o) => ({
name: String(o.sAMAccountName || '').replace(/\$$/, ''), dns: o.dNSHostName || '', os: [o.operatingSystem, o.operatingSystemVersion].filter(Boolean).join(' '), lastLogon: fileTime(o.lastLogonTimestamp),
}));
} else {
const list = async (c) => (await this.rootOk(s, `samba-tool ${c} list 2>/dev/null`)).split('\n').map((x) => x.trim()).filter(Boolean);
users = (await list('user')).map((name) => ({ name, groups: [] }));
groups = (await list('group')).map((name) => ({ name, members: [] }));
computers = (await list('computer')).map((name) => ({ name: name.replace(/\$$/, '') }));
}
const sort = (a, b) => String(a.name).localeCompare(String(b.name));
return { info: sec.INFO || '', level: sec.LEVEL || '', fsmo: sec.FSMO || '', users: users.sort(sort), groups: groups.sort(sort), computers: computers.sort(sort), limited: !s.tools.has('ldbsearch') };
}
// samba-tool fragt Passwörter mit getpass ab; ohne Terminal liest es sie von stdin
async adUserCreate(s, o) {
this.requireDc(s);
const u = check(String(o.name || '').trim(), RX.user, 'user');
if (!o.password) throw new Error(i18n.t('Please enter a password.'));
const opt = (flag, v, rx) => (String(v || '').trim() ? ` ${flag}=${q(check(String(v).trim(), rx, flag))}` : '');
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | samba-tool user create ${q(u)}${opt('--given-name', o.given, RX.text)}${opt('--surname', o.surname, RX.text)}${opt('--mail-address', o.mail, /^[^\s'"`$\\]+@[^\s'"`$\\]+$/)}${opt('--description', o.description, RX.text)}${o.mustChange ? ' --must-change-at-next-login' : ''} 2>&1`, `${o.password}\n`);
return true;
}
async adUserDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool user delete ${q(check(name, RX.user, 'user'))} 2>&1`); return true; }
async adUserEnable(s, name, on) { this.requireDc(s); await this.rootOk(s, `samba-tool user ${on ? 'enable' : 'disable'} ${q(check(name, RX.user, 'user'))} 2>&1`); return true; }
async adSetPassword(s, name, password, mustChange) {
this.requireDc(s);
if (!password) throw new Error(i18n.t('Please enter a password.'));
await this.rootOk(s, `IFS= read -r PW; printf '%s\\n%s\\n' "$PW" "$PW" | samba-tool user setpassword ${q(check(name, RX.user, 'user'))}${mustChange ? ' --must-change-at-next-login' : ''} 2>&1`, `${password}\n`);
return true;
}
async adGroupCreate(s, name, description) {
this.requireDc(s);
await this.rootOk(s, `samba-tool group add ${q(check(String(name).trim(), RX.group, 'group'))}${description ? ` --description=${q(check(description, RX.text, 'description'))}` : ''} 2>&1`);
return true;
}
async adGroupDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool group delete ${q(check(name, RX.group, 'group'))} 2>&1`); return true; }
async adGroupMembers(s, group, add, remove) {
this.requireDc(s);
const g = q(check(group, RX.group, 'group'));
const list = (a) => a.map((m) => check(m, /^[\w][\w .$-]{0,63}$/, 'member')).join(',');
if (add?.length) await this.rootOk(s, `samba-tool group addmembers ${g} ${q(list(add))} 2>&1`);
if (remove?.length) await this.rootOk(s, `samba-tool group removemembers ${g} ${q(list(remove))} 2>&1`);
return true;
}
async adComputerDelete(s, name) { this.requireDc(s); await this.rootOk(s, `samba-tool computer delete ${q(check(name, RX.user, 'computer'))} 2>&1`); return true; }
// Neue AD-Domäne anlegen: dieser Server wird Samba-AD-Domänencontroller.
// smbd/nmbd/winbind werden abgeschaltet (der DC bringt eigene Dienste mit), die alte smb.conf wird gesichert.
// samba-tool nimmt das Admin-Passwort nur als Option an; es kommt per stdin in das Skript und steht nur kurz in dessen Prozessliste.
async provisionDomain(s, o) {
const realm = check(String(o.realm || '').trim().toUpperCase(), /^[A-Z0-9]([A-Z0-9-]*[A-Z0-9])?(\.[A-Z0-9]([A-Z0-9-]*[A-Z0-9])?)+$/, 'realm');
const domain = check(String(o.domain || realm.split('.')[0]).trim().toUpperCase(), /^[A-Z0-9][A-Z0-9-]{0,14}$/, 'NetBIOS domain');
if (!o.password) throw new Error(i18n.t('Please enter a password.'));
const fwd = String(o.forwarder || '').trim();
if (fwd) check(fwd, /^[\d.]+$|^[0-9a-fA-F:]+$/, 'DNS forwarder');
const r = await this.root(s, `command -v samba-tool >/dev/null 2>&1 || exit 3
[ -f /var/lib/samba/private/sam.ldb ] && exit 5
IFS= read -r PW
for u in smbd nmbd winbind; do systemctl stop $u 2>/dev/null; systemctl disable $u 2>/dev/null; done
[ -f /etc/samba/smb.conf ] && mv -f /etc/samba/smb.conf /etc/samba/smb.conf.mrterm-before-dc
if ! samba-tool domain provision --realm=${q(realm)} --domain=${q(domain)} --server-role=dc --dns-backend=SAMBA_INTERNAL --adminpass="$PW"${o.rfc2307 ? ' --use-rfc2307' : ''}${fwd ? ` --option=${q(`dns forwarder = ${fwd}`)}` : ''} 2>&1; then
[ -f /etc/samba/smb.conf.mrterm-before-dc ] && mv -f /etc/samba/smb.conf.mrterm-before-dc /etc/samba/smb.conf
for u in smbd nmbd; do systemctl enable --now $u 2>/dev/null; done
exit 4
fi
cp -f /var/lib/samba/private/krb5.conf /etc/krb5.conf
${o.useAsDns ? `if [ ! -L /etc/resolv.conf ]; then cp -a /etc/resolv.conf /etc/resolv.conf.mrterm-before-dc; printf 'search %s\\nnameserver 127.0.0.1\\n' ${q(realm.toLowerCase())} > /etc/resolv.conf; fi` : ''}
systemctl unmask samba-ad-dc 2>/dev/null; systemctl enable samba-ad-dc 2>/dev/null; systemctl restart samba-ad-dc || exit 6
echo provisioned`, `${o.password}\n`);
if (r.code === 3) throw new Error(i18n.t('samba-tool is missing. Install the domain controller packages first.'));
if (r.code === 5) throw new Error(i18n.t('This server already contains an AD domain database (/var/lib/samba/private/sam.ldb).'));
if (r.code === 4) throw new Error(i18n.t('Creating the domain failed: {err}', { err: (r.out + r.err).trim().split('\n').filter((l) => !/^WARNING: Using passwords on command line/.test(l)).slice(-4).join('\n') }));
if (r.code === 6) throw new Error(i18n.t('The domain was created, but samba-ad-dc could not be started: {err}', { err: (r.out + r.err).trim().split('\n').slice(-3).join('\n') }));
if (r.code) throw new Error((r.out + r.err).trim().split('\n').slice(-3).join('\n') || i18n.t('Command failed with code {code}', { code: r.code }));
return true;
}
// Bestehender Domäne beitreten (realmd + sssd); das Admin-Passwort liest realm von stdin
async joinDomain(s, domain, admin, password) {
const d = check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain');
const a = check(String(admin || 'Administrator').trim(), RX.user, 'user');
if (!password) throw new Error(i18n.t('Please enter a password.'));
if (!s.tools.has('realm')) throw new Error(i18n.t('realmd is not installed. Install the domain join tools first.'));
const r = await this.root(s, `IFS= read -r PW; printf '%s\\n' "$PW" | realm join -v -U ${q(a)} ${q(d)} 2>&1`, `${password}\n`);
if (r.code) throw new Error(i18n.t('Joining the domain failed: {err}', { err: (r.out + r.err).trim().split('\n').filter((l) => !/^\s*\*/.test(l)).slice(-3).join('\n') || r.code }));
return true;
}
async leaveDomain(s, domain) {
await this.rootOk(s, `realm leave -v ${q(check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain'))} 2>&1`);
return true;
}
// Domäne beitreten (fragt das Passwort des Domänen-Admins) – im Terminal
joinCommand(s, domain, admin) {
const d = check(String(domain).trim(), /^[a-zA-Z0-9.-]+$/, 'domain');
const a = check(String(admin || 'Administrator').trim(), RX.user, 'user');
if (s.tools.has('realm')) return this.termCmd(s, `realm join -v -U ${q(a)} ${q(d)}`);
return this.termCmd(s, `net ads join -U ${q(a)} -S ${q(d)}`);
}
installCommand(s, kind) {
const pkgs = kind === 'join'
? { apt: ['realmd', 'sssd', 'sssd-tools', 'adcli', 'samba-common-bin', 'packagekit'], dnf: ['realmd', 'sssd', 'adcli', 'samba-common-tools'], yum: ['realmd', 'sssd', 'adcli', 'samba-common-tools'], pacman: ['realmd', 'sssd', 'adcli', 'samba'], zypper: ['realmd', 'sssd', 'adcli', 'samba-client'] }[s.pkg]
: kind === 'dc'
// Fedora/RHEL bauen Samba ohne AD-DC (MIT-Kerberos), daher nur Debian/Ubuntu, Arch und openSUSE
? { apt: ['samba', 'winbind', 'krb5-user', 'smbclient', 'ldb-tools', 'dnsutils'], pacman: ['samba', 'krb5', 'ldb'], zypper: ['samba-ad-dc', 'samba-client', 'krb5-client'] }[s.pkg]
: { apt: ['samba'], dnf: ['samba'], yum: ['samba'], pacman: ['samba'], zypper: ['samba'], apk: ['samba'] }[s.pkg];
if (!pkgs) throw new Error(kind === 'dc' && /dnf|yum/.test(s.pkg) ? i18n.t('Samba from Fedora/RHEL packages cannot run an AD domain controller.') : i18n.t('No known package manager on this host.'));
// krb5-user fragt sonst interaktiv nach dem Standard-Realm; samba-ad-dc ist ab Debian 13 / Ubuntu 24.04 ein eigenes Paket
const extra = kind === 'dc' && s.pkg === 'apt' ? ' && { ! apt-cache show samba-ad-dc >/dev/null 2>&1 || apt-get install -y samba-ad-dc; }' : '';
return this.termCmd(s, `${s.pkg === 'apt' ? 'export DEBIAN_FRONTEND=noninteractive; ' : ''}${installCmd(s.pkg, pkgs)}${extra}`);
}
}
module.exports = { SambaManager, parseConf, parsePdbedit, parseStatus, parseLdif };

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