Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a608948823 | ||
|
|
70a15eddbc | ||
|
|
039d89b9f7 | ||
|
|
b38837d4e0 | ||
|
|
b716ce3415 | ||
|
|
b33af709aa | ||
|
|
618f5ad010 | ||
|
|
29fef7cb33 | ||
|
|
74ce2c20be | ||
|
|
e8fefe4ebe | ||
|
|
797722c510 | ||
|
|
c589033964 | ||
|
|
0cefd5a21f |
@@ -32,7 +32,9 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
||||
- **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display
|
||||
- **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer
|
||||
- **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key
|
||||
- **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH
|
||||
- **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D)
|
||||
- **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host
|
||||
- **Snippets**: save frequently used commands and send them to a terminal with one click
|
||||
- **Known hosts**: MrTerm warns you if a server's host key changes
|
||||
- **History** of recent connections
|
||||
@@ -40,6 +42,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on
|
||||
- **Backup** export and import
|
||||
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
|
||||
- **Encrypted vault** using your operating system's keyring (Windows DPAPI, Linux libsecret/KWallet)
|
||||
- **App lock** with a password and/or a FIDO2 security key such as a YubiKey. The vault is then additionally encrypted, and it can lock automatically when you're inactive
|
||||
- **Automatic updates**: MrTerm checks for new versions on startup and can install them for you
|
||||
|
||||
## Getting started
|
||||
@@ -61,9 +64,42 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r
|
||||
| `Ctrl+Shift+C` / `Ctrl+Shift+V` | Copy / paste in the terminal |
|
||||
| `Ctrl+Shift+F` | Search in the terminal |
|
||||
| `Ctrl` + `+` / `-` / `0` | Font size |
|
||||
| `Ctrl+Shift+L` | Lock MrTerm |
|
||||
|
||||
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
||||
|
||||
## Docker
|
||||
|
||||
Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory.
|
||||
|
||||
- **Open shell**: opens a terminal tab inside the container (bash if available, otherwise sh). You can also double-click a running container.
|
||||
- **Logs**: follows the container's logs live in a terminal tab.
|
||||
- **Start, stop, restart, delete** from the row buttons or the right-click menu.
|
||||
|
||||
Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically.
|
||||
|
||||
## VPN
|
||||
|
||||
Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host.
|
||||
|
||||
When you open an assigned host (terminal, SFTP, RDP or port forwarding), MrTerm connects the VPN first if it isn't already connected. You can also connect and disconnect manually. By default, MrTerm disconnects the VPNs it started when you close it.
|
||||
|
||||
- **Linux**: uses NetworkManager, so no root password is needed. For OpenVPN, install the plugin with `sudo pacman -S networkmanager-openvpn`.
|
||||
- **Windows**: WireGuard requires [WireGuard for Windows](https://www.wireguard.com/install/) and asks for administrator permission when connecting. OpenVPN requires the [OpenVPN GUI](https://openvpn.net/community-downloads/).
|
||||
- OpenVPN certificates and keys must be embedded in the `.ovpn` file.
|
||||
|
||||
## App lock
|
||||
|
||||
Under **Settings → App lock** you can protect MrTerm with a password, one or more FIDO2 security keys (e.g. YubiKey), or both. Once a method is set up:
|
||||
|
||||
- MrTerm starts locked, and your hosts, keys and passwords stay encrypted until you unlock it.
|
||||
- Lock it any time with the lock icon in the title bar or `Ctrl+Shift+L`, or let it lock automatically after a period of inactivity.
|
||||
- Open sessions keep running in the background while MrTerm is locked.
|
||||
|
||||
Security keys need to support the *hmac-secret* (PRF) extension, which YubiKey 5 and most current FIDO2 keys do. Touching the key is enough, no PIN is needed. On Linux, the key must be accessible to your user. This is the default on Arch/CachyOS.
|
||||
|
||||
**Keep in mind:** if you forget the password and lose all registered security keys, your vault cannot be recovered. Setting up a second unlock method is a good idea.
|
||||
|
||||
## Updates
|
||||
|
||||
MrTerm looks for updates on startup. You can also check manually under **Settings → Updates**. When a new version is available, click **Install now** and MrTerm will download the right package for your system and restart.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
|
||||
@@ -24,7 +24,9 @@ if [[ "$version" != "$current" ]]; then
|
||||
git add package.json package-lock.json
|
||||
git commit -m "Version ${version}"
|
||||
fi
|
||||
git push origin HEAD
|
||||
# Push mit dem Gitea-Token (Basic-Auth, Benutzer aus GITEA_USER, Standard MrBlake)
|
||||
auth="$(printf '%s:%s' "${GITEA_USER:-MrBlake}" "$GITEA_TOKEN" | base64 -w0)"
|
||||
git -c http.extraHeader="Authorization: Basic ${auth}" push origin HEAD
|
||||
|
||||
rm -rf dist
|
||||
npm run dist:linux
|
||||
|
||||
+99
@@ -311,6 +311,105 @@
|
||||
'Actions': 'Aktionen',
|
||||
'Log in': 'Anmelden',
|
||||
|
||||
// App-Sperre
|
||||
'App lock': 'App-Sperre',
|
||||
'Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.': 'MrTerm mit Passwort und/oder FIDO2-Sicherheitsschlüssel (z. B. YubiKey) sperren. Der Vault wird dann zusätzlich verschlüsselt und lässt sich nur mit einer dieser Methoden öffnen.',
|
||||
'MrTerm is locked': 'MrTerm ist gesperrt',
|
||||
'MrTerm is locked.': 'MrTerm ist gesperrt.',
|
||||
'Unlock': 'Entsperren',
|
||||
'Unlock with security key': 'Mit Sicherheitsschlüssel entsperren',
|
||||
'Disable app lock?': 'App-Sperre deaktivieren?',
|
||||
'This is the last unlock method. MrTerm will no longer be locked.': 'Das ist die letzte Entsperrmethode. MrTerm wird danach nicht mehr gesperrt.',
|
||||
'Disable': 'Deaktivieren',
|
||||
'Set': 'Festgelegt',
|
||||
'Not set': 'Nicht festgelegt',
|
||||
'Change password': 'Passwort ändern',
|
||||
'Set password': 'Passwort festlegen',
|
||||
'New password': 'Neues Passwort',
|
||||
'Repeat password': 'Passwort wiederholen',
|
||||
'The passwords do not match.': 'Die Passwörter stimmen nicht überein.',
|
||||
'Password saved': 'Passwort gespeichert',
|
||||
'Security key': 'Sicherheitsschlüssel',
|
||||
'Security key (FIDO2)': 'Sicherheitsschlüssel (FIDO2)',
|
||||
'Remove security key?': 'Sicherheitsschlüssel entfernen?',
|
||||
'Add security key': 'Sicherheitsschlüssel hinzufügen',
|
||||
'Security key added': 'Sicherheitsschlüssel hinzugefügt',
|
||||
'Lock now': 'Jetzt sperren',
|
||||
'Lock (Ctrl+Shift+L)': 'Sperren (Strg+Shift+L)',
|
||||
'Lock automatically after inactivity': 'Automatisch sperren bei Inaktivität',
|
||||
'Never': 'Nie',
|
||||
'{n} minutes': '{n} Minuten',
|
||||
'If you forget the password and lose all security keys, the vault cannot be recovered.': 'Wenn du das Passwort vergisst und alle Sicherheitsschlüssel verlierst, lässt sich der Vault nicht wiederherstellen.',
|
||||
'No password set.': 'Kein Passwort festgelegt.',
|
||||
'Wrong password.': 'Falsches Passwort.',
|
||||
'No security key registered.': 'Kein Sicherheitsschlüssel registriert.',
|
||||
'Unknown security key.': 'Unbekannter Sicherheitsschlüssel.',
|
||||
'This security key could not unlock the vault.': 'Dieser Sicherheitsschlüssel konnte den Vault nicht entsperren.',
|
||||
'The password must be at least 6 characters long.': 'Das Passwort muss mindestens 6 Zeichen lang sein.',
|
||||
'Touch your security key to register it.': 'Berühre deinen Sicherheitsschlüssel, um ihn zu registrieren.',
|
||||
'Touch your security key again to finish.': 'Berühre deinen Sicherheitsschlüssel noch einmal zum Abschließen.',
|
||||
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||
|
||||
// VPN
|
||||
'— No VPN —': '— Kein VPN —',
|
||||
'Connected automatically before connecting to this host.': 'Wird vor dem Verbinden mit diesem Host automatisch aufgebaut.',
|
||||
'Search VPNs …': 'VPNs suchen …',
|
||||
'New VPN': 'Neues VPN',
|
||||
'No VPNs': 'Keine VPNs',
|
||||
'Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.': 'Füge WireGuard- oder OpenVPN-Konfigurationen hinzu und ordne sie Hosts zu. MrTerm baut das VPN automatisch auf, wenn du einen solchen Host öffnest.',
|
||||
'connected': 'verbunden',
|
||||
'disconnected': 'getrennt',
|
||||
'Disconnect': 'Trennen',
|
||||
'Connecting …': 'Verbinde …',
|
||||
'VPN “{name}” connected': 'VPN „{name}“ verbunden',
|
||||
'Delete VPN?': 'VPN löschen?',
|
||||
'“{name}” will be removed. Assigned hosts will connect without VPN.': '„{name}“ wird entfernt. Zugeordnete Hosts verbinden sich dann ohne VPN.',
|
||||
'Configuration': 'Konfiguration',
|
||||
'Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).': 'Zertifikate und Schlüssel müssen in der .ovpn-Datei eingebettet sein (<ca>, <cert>, <key> …).',
|
||||
'Contents of a WireGuard .conf file.': 'Inhalt einer WireGuard-.conf-Datei.',
|
||||
'Edit VPN': 'VPN bearbeiten',
|
||||
'Office VPN': 'Büro-VPN',
|
||||
'Disconnect when MrTerm closes': 'Beim Beenden von MrTerm trennen',
|
||||
'Assigned hosts': 'Zugeordnete Hosts',
|
||||
'Configuration is missing.': 'Konfiguration fehlt.',
|
||||
'This does not look like a WireGuard configuration.': 'Das sieht nicht nach einer WireGuard-Konfiguration aus.',
|
||||
'VPN configuration': 'VPN-Konfiguration',
|
||||
'NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.': 'NetworkManager (nmcli) nicht gefunden. MrTerm nutzt NetworkManager für VPN-Verbindungen.',
|
||||
'OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn': 'OpenVPN-Unterstützung für NetworkManager fehlt. Installieren mit: sudo pacman -S networkmanager-openvpn',
|
||||
'VPN configuration could not be imported: {err}': 'VPN-Konfiguration konnte nicht importiert werden: {err}',
|
||||
'VPN “{name}” could not be connected: {err}': 'VPN „{name}“ konnte nicht verbunden werden: {err}',
|
||||
'VPN not found': 'VPN nicht gefunden',
|
||||
'OpenVPN GUI not found. Install OpenVPN from openvpn.net.': 'OpenVPN GUI nicht gefunden. Installiere OpenVPN von openvpn.net.',
|
||||
'WireGuard for Windows not found. Install it from wireguard.com.': 'WireGuard für Windows nicht gefunden. Installiere es von wireguard.com.',
|
||||
'timeout': 'Zeitüberschreitung',
|
||||
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …',
|
||||
|
||||
// Docker
|
||||
'Docker containers': 'Docker-Container',
|
||||
'Search containers …': 'Container suchen …',
|
||||
'Show stopped': 'Gestoppte anzeigen',
|
||||
'Connection closed.': 'Verbindung beendet.',
|
||||
'No containers on this host.': 'Keine Container auf diesem Host.',
|
||||
'Ports': 'Ports',
|
||||
'Memory': 'Speicher',
|
||||
'Open shell': 'Shell öffnen',
|
||||
'Logs': 'Logs',
|
||||
'Restart': 'Neu starten',
|
||||
'Copy ID': 'ID kopieren',
|
||||
'Delete container?': 'Container löschen?',
|
||||
'“{name}” will be removed including its writable layer. Volumes are kept.': '„{name}“ wird samt beschreibbarer Ebene entfernt. Volumes bleiben erhalten.',
|
||||
'Stop container?': 'Container stoppen?',
|
||||
'Started': 'Gestartet',
|
||||
'Stopped': 'Gestoppt',
|
||||
'Restarted': 'Neu gestartet',
|
||||
'Deleted': 'Gelöscht',
|
||||
'Docker session not found': 'Docker-Sitzung nicht gefunden',
|
||||
'Neither Docker nor Podman was found on this host.': 'Auf diesem Host wurde weder Docker noch Podman gefunden.',
|
||||
'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.',
|
||||
'{runtime} exited with code {code}': '{runtime} beendet mit Code {code}',
|
||||
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec).
|
||||
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
|
||||
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
||||
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
||||
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||
|
||||
function execOn(conn, cmd, stdin) {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(cmd, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let out = '', errOut = '';
|
||||
stream.on('data', (d) => { out += d; });
|
||||
stream.stderr.on('data', (d) => { errOut += d; });
|
||||
stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut }));
|
||||
stream.end(stdin ?? '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class DockerManager {
|
||||
constructor(ssh) {
|
||||
this.ssh = ssh;
|
||||
this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo }
|
||||
}
|
||||
|
||||
async open(id, host, onClose) {
|
||||
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
|
||||
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||
conn.on('error', () => {});
|
||||
return this.list(id);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||
return s;
|
||||
}
|
||||
|
||||
run(s, args) {
|
||||
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||
}
|
||||
|
||||
async list(id) {
|
||||
const s = this.get(id);
|
||||
if (!s.runtime) {
|
||||
const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127');
|
||||
if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.'));
|
||||
s.runtime = r.out.trim();
|
||||
}
|
||||
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||
if (r.code && denied(r.err) && !s.sudo) {
|
||||
s.sudo = true;
|
||||
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||
if (r.code) s.sudo = false;
|
||||
}
|
||||
if (r.code) {
|
||||
if (denied(r.err) || /sudo/i.test(r.err)) {
|
||||
throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' }));
|
||||
}
|
||||
throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||
}
|
||||
const containers = r.out.split('\n').filter(Boolean).map((l) => {
|
||||
const [cid, name, image, state, status, ports] = l.split('\t');
|
||||
return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports };
|
||||
});
|
||||
return { runtime: s.runtime, sudo: s.sudo, containers };
|
||||
}
|
||||
|
||||
// CPU/RAM der laufenden Container (dauert ~2 s)
|
||||
async stats(id) {
|
||||
const s = this.get(id);
|
||||
const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`);
|
||||
if (r.code) return {};
|
||||
return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => {
|
||||
const [cid, cpu, mem] = l.split('\t');
|
||||
return [cid.slice(0, 12), { cpu, mem }];
|
||||
}));
|
||||
}
|
||||
|
||||
async action(id, action, cid) {
|
||||
const s = this.get(id);
|
||||
if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action');
|
||||
const r = await this.run(s, `${ACTIONS[action]} ${cid}`);
|
||||
if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort)
|
||||
command(id, kind, cid) {
|
||||
const s = this.get(id);
|
||||
if (!SAFE_ID.test(cid)) throw new Error('Invalid container');
|
||||
const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`;
|
||||
if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`;
|
||||
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
|
||||
}
|
||||
|
||||
close(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return;
|
||||
this.sessions.delete(id);
|
||||
try { s.conn.end(); } catch {}
|
||||
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DockerManager };
|
||||
@@ -0,0 +1,86 @@
|
||||
// FIDO2/WebAuthn (YubiKey & Co.) für die App-Sperre.
|
||||
// Chromium erlaubt WebAuthn nur auf HTTPS oder http://localhost – nicht unter file://. Deshalb läuft die
|
||||
// Abfrage in einem kleinen eigenen Fenster, dessen http://localhost-Seite über eine eigene Session
|
||||
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
||||
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
||||
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||
const { BrowserWindow, session } = require('electron');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;border:1px solid #2c3142;box-sizing:border-box;text-align:center;padding:16px}
|
||||
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
||||
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
||||
button:hover{background:#343a4f}
|
||||
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
||||
|
||||
let fidoSession;
|
||||
function getSession() {
|
||||
if (fidoSession) return fidoSession;
|
||||
fidoSession = session.fromPartition('mrterm-fido');
|
||||
fidoSession.protocol.handle('http', () => new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
||||
return fidoSession;
|
||||
}
|
||||
|
||||
// Gemeinsame Hilfsfunktionen im Fenster (base64url <-> Bytes)
|
||||
const HELPERS = `
|
||||
const b64 = (u) => btoa(String.fromCharCode(...new Uint8Array(u))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
||||
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||
`;
|
||||
|
||||
async function ceremony(parent, text, script) {
|
||||
const w = new BrowserWindow({
|
||||
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
||||
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||
});
|
||||
try {
|
||||
await w.loadURL('http://localhost/');
|
||||
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||
w.show();
|
||||
w.focus();
|
||||
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
||||
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
||||
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||
if (r?.error) throw new Error(r.error);
|
||||
return r;
|
||||
} finally {
|
||||
if (!w.isDestroyed()) w.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||
async function register(parent) {
|
||||
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||
const cred = await navigator.credentials.create({ publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
rp: { name: 'MrTerm', id: 'localhost' },
|
||||
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'MrTerm', displayName: 'MrTerm' },
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -8 }, { type: 'public-key', alg: -257 }],
|
||||
authenticatorSelection: { userVerification: 'discouraged', residentKey: 'discouraged' },
|
||||
timeout: 60000, extensions: { prf: {} },
|
||||
} });
|
||||
return { credId: b64(cred.rawId), prf: cred.getClientExtensionResults().prf?.enabled };`);
|
||||
if (r.prf === false) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||
return r.credId;
|
||||
}
|
||||
|
||||
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||
// Liefert { credId, secret: Buffer(32) }
|
||||
async function derive(parent, creds, text) {
|
||||
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||
const creds = ${JSON.stringify(creds)};
|
||||
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||
const a = await navigator.credentials.get({ publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: 'localhost', timeout: 60000, userVerification: 'discouraged',
|
||||
allowCredentials: creds.map((c) => ({ type: 'public-key', id: unb64(c.credId) })),
|
||||
extensions: { prf: { evalByCredential } },
|
||||
} });
|
||||
const first = a.getClientExtensionResults().prf?.results?.first;
|
||||
return { credId: b64(a.rawId), secret: first ? b64(first) : null };`);
|
||||
if (!r.secret) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||
return { credId: r.credId, secret: Buffer.from(r.secret, 'base64url') };
|
||||
}
|
||||
|
||||
module.exports = { register, derive };
|
||||
+136
-11
@@ -10,6 +10,9 @@ const rdp = require('./rdp');
|
||||
const { Updater } = require('./updater');
|
||||
const { createEmbed, embedSupported } = require('./rdp-embed');
|
||||
const i18n = require('../i18n');
|
||||
const fido = require('./fido');
|
||||
const { VpnManager } = require('./vpn');
|
||||
const { DockerManager } = require('./docker');
|
||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||
|
||||
let win;
|
||||
@@ -58,6 +61,8 @@ function askSecret(sessionId, req) {
|
||||
|
||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||
const updater = new Updater(store, send);
|
||||
const vpn = new VpnManager(store, app.getPath('userData'));
|
||||
const docker = new DockerManager(ssh);
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
@@ -88,9 +93,14 @@ function createWindow() {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
}
|
||||
|
||||
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
function handle(channel, fn) {
|
||||
ipcMain.handle(channel, async (_e, ...args) => {
|
||||
try { return { ok: true, value: await fn(...args) }; }
|
||||
try {
|
||||
if (store.locked && !channel.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(channel)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||
return { ok: true, value: await fn(...args) };
|
||||
}
|
||||
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
||||
});
|
||||
}
|
||||
@@ -100,10 +110,108 @@ ipcMain.on('win:min', () => win.minimize());
|
||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win.close());
|
||||
|
||||
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||
const fidoKek = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), 'mrterm-fido-kek', 32));
|
||||
function lockStatus() {
|
||||
const { language, appTheme, accent } = store.get().settings;
|
||||
return {
|
||||
enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password,
|
||||
fido: (store.lock?.fido || []).map(({ id, label }) => ({ id, label })), meta: { language, appTheme, accent },
|
||||
};
|
||||
}
|
||||
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
|
||||
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
|
||||
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); }
|
||||
|
||||
handle('lock:status', lockStatus);
|
||||
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||
handle('lock:unlockPassword', async (pw) => {
|
||||
const p = store.lock?.password;
|
||||
if (!p) throw new Error(i18n.t('No password set.'));
|
||||
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:unlockFido', async () => {
|
||||
const list = store.lock?.fido || [];
|
||||
if (!list.length) throw new Error(i18n.t('No security key registered.'));
|
||||
const r = await fido.derive(win, list.map(({ credId, prfSalt }) => ({ credId, prfSalt })));
|
||||
const entry = list.find((f) => f.credId === r.credId);
|
||||
if (!entry) throw new Error(i18n.t('Unknown security key.'));
|
||||
try { store.unlockWith(fidoKek(r.secret), entry.wrap); } catch { throw new Error(i18n.t('This security key could not unlock the vault.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:setPassword', async (pw) => {
|
||||
requireUnlocked();
|
||||
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||
const kek = await kdf(pw, salt, N);
|
||||
ensureLock().password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removePassword', () => {
|
||||
requireUnlocked();
|
||||
if (store.lock) store.lock.password = null;
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:addFido', async (label) => {
|
||||
requireUnlocked();
|
||||
const credId = await fido.register(win);
|
||||
const prfSalt = crypto.randomBytes(32).toString('base64url');
|
||||
const r = await fido.derive(win, [{ credId, prfSalt }], i18n.t('Touch your security key again to finish.'));
|
||||
ensureLock().fido.push({ id: crypto.randomUUID(), label: label || i18n.t('Security key'), credId, prfSalt, wrap: store.wrapDek(fidoKek(r.secret)) });
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removeFido', (id) => {
|
||||
requireUnlocked();
|
||||
if (store.lock) store.lock.fido = store.lock.fido.filter((f) => f.id !== id);
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
|
||||
// ---------- Vault ----------
|
||||
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||
handle('vault:remove', (col, id) => store.remove(col, id));
|
||||
handle('vault:upsert', async (col, item) => {
|
||||
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
|
||||
if (col === 'vpns' && item.id) {
|
||||
const old = store.get().vpns.find((v) => v.id === item.id);
|
||||
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
|
||||
}
|
||||
return store.upsert(col, item);
|
||||
});
|
||||
handle('vault:remove', async (col, id) => {
|
||||
if (col === 'vpns') {
|
||||
await vpn.forget(store.get().vpns.find((v) => v.id === id));
|
||||
store.get().hosts.forEach((h) => { if (h.vpnId === id) h.vpnId = null; });
|
||||
}
|
||||
return store.remove(col, id);
|
||||
});
|
||||
|
||||
// ---------- Docker ----------
|
||||
handle('docker:open', async (id, hostRef) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
return docker.open(id, host, () => send('docker:closed', id));
|
||||
});
|
||||
handle('docker:list', (id) => docker.list(id));
|
||||
handle('docker:stats', (id) => docker.stats(id));
|
||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||
handle('docker:close', (id) => docker.close(id));
|
||||
|
||||
// ---------- VPN ----------
|
||||
handle('vpn:status', () => vpn.status());
|
||||
handle('vpn:up', (id) => vpn.up(id));
|
||||
handle('vpn:down', (id) => vpn.down(id));
|
||||
handle('vault:settings', (s) => {
|
||||
store.setSettings(s);
|
||||
if ('language' in s) applyLanguage();
|
||||
@@ -194,12 +302,16 @@ function hostWithOverrides(hostOrId) {
|
||||
if (!h) throw new Error(i18n.t('Host not found'));
|
||||
return h;
|
||||
}
|
||||
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
|
||||
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
|
||||
return hostOrId; // Quick-Connect: temporärer Host
|
||||
}
|
||||
|
||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
||||
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||
return true;
|
||||
});
|
||||
@@ -212,7 +324,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
|
||||
});
|
||||
|
||||
// ---------- SFTP ----------
|
||||
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef)));
|
||||
handle('sftp:open', async (sessionId, hostRef) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
return ssh.openSftp(sessionId, host);
|
||||
});
|
||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||
@@ -246,9 +362,10 @@ handle('local:op', (op, a, b) => {
|
||||
});
|
||||
|
||||
// ---------- Port-Forwarding ----------
|
||||
handle('fw:start', (id) => {
|
||||
handle('fw:start', async (id) => {
|
||||
const fw = store.get().forwards.find((f) => f.id === id);
|
||||
if (!fw) throw new Error(i18n.t('Rule not found'));
|
||||
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
|
||||
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
||||
});
|
||||
handle('fw:stop', (id) => ssh.stopForward(id));
|
||||
@@ -256,8 +373,9 @@ handle('fw:active', () => ssh.activeForwards());
|
||||
|
||||
// ---------- RDP ----------
|
||||
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
||||
handle('rdp:launch', (hostId) => {
|
||||
handle('rdp:launch', async (hostId) => {
|
||||
const host = hostWithOverrides(hostId);
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
const r = rdp.launch(host, store.get().settings);
|
||||
if (r.process) {
|
||||
@@ -277,6 +395,7 @@ handle('rdp:embedSupported', () => {
|
||||
handle('rdp:open', async (id, hostRef, bounds) => {
|
||||
const host = hostWithOverrides(hostRef);
|
||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||
const emb = createEmbed(win.getNativeWindowHandle());
|
||||
const sess = { emb, cancelled: false };
|
||||
rdpSessions.set(id, sess);
|
||||
@@ -369,11 +488,17 @@ app.whenReady().then(() => {
|
||||
store.load();
|
||||
applyLanguage();
|
||||
createWindow();
|
||||
if (store.get().settings.updateAutoCheck && app.isPackaged) {
|
||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||
}
|
||||
scheduleUpdateCheck();
|
||||
});
|
||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
||||
|
||||
// Automatische Update-Prüfung – bei gesperrtem Vault erst nach dem Entsperren (Einstellungen sind verschlüsselt)
|
||||
let updateScheduled = false;
|
||||
function scheduleUpdateCheck() {
|
||||
if (updateScheduled || store.sealed || !store.get().settings.updateAutoCheck || !app.isPackaged) return;
|
||||
updateScheduled = true;
|
||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||
}
|
||||
app.on('window-all-closed', async () => { ssh.closeAll(); await vpn.downOnQuit(); app.quit(); });
|
||||
|
||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||
if (process.env.MRTERM_SMOKE) {
|
||||
|
||||
+7
-3
@@ -137,15 +137,19 @@ class SshManager {
|
||||
|
||||
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
||||
await new Promise((res, rej) => {
|
||||
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => {
|
||||
const pty = { term: 'xterm-256color', cols, rows };
|
||||
const onStream = (err, stream) => {
|
||||
if (err) return rej(err);
|
||||
sess.stream = stream;
|
||||
stream.on('data', (d) => send('data', d.toString('utf8')));
|
||||
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
||||
stream.on('close', () => conn.end());
|
||||
if (host.startupCommand) stream.write(host.startupCommand + '\n');
|
||||
if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
|
||||
res();
|
||||
});
|
||||
};
|
||||
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
|
||||
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
|
||||
else conn.shell(pty, { env }, onStream);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+61
-2
@@ -12,6 +12,7 @@ const DEFAULTS = {
|
||||
keys: [],
|
||||
snippets: [],
|
||||
forwards: [],
|
||||
vpns: [],
|
||||
knownHosts: {},
|
||||
history: [],
|
||||
settings: {
|
||||
@@ -31,16 +32,63 @@ const DEFAULTS = {
|
||||
updateToken: '',
|
||||
updateAutoCheck: true,
|
||||
updatePrerelease: false,
|
||||
autoLock: 0,
|
||||
language: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
const merge = (parsed) => ({ ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } });
|
||||
|
||||
// AES-256-GCM; Ergebnis als base64-Felder für JSON
|
||||
function box(key, plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([c.update(plain), c.final()]);
|
||||
return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
||||
}
|
||||
function unbox(key, b) {
|
||||
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64'));
|
||||
d.setAuthTag(Buffer.from(b.tag, 'base64'));
|
||||
return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]);
|
||||
}
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.dir = app.getPath('userData');
|
||||
this.file = path.join(this.dir, 'vault.dat');
|
||||
this.data = structuredClone(DEFAULTS);
|
||||
this.encrypted = false;
|
||||
// App-Sperre: Inhalt zusätzlich mit zufälligem Datenschlüssel (DEK, AES-256-GCM) verschlüsselt.
|
||||
// Der DEK liegt je Entsperrmethode verpackt vor: Passwort (scrypt) und/oder FIDO2-Schlüssel (PRF/hmac-secret).
|
||||
this.lock = null; // { password: { salt, N, wrap } | null, fido: [{ id, label, credId, prfSalt, wrap }] }
|
||||
this.dek = null;
|
||||
this.sealed = null; // verschlüsselter Inhalt, solange nach dem Start noch nicht entsperrt
|
||||
this.locked = false;
|
||||
}
|
||||
|
||||
get lockEnabled() { return !!(this.lock && (this.lock.password || this.lock.fido.length)); }
|
||||
|
||||
// Entsperren mit einem Schlüssel, der den DEK verpackt hat (wirft bei falschem Schlüssel)
|
||||
unlockWith(kek, wrap) {
|
||||
const dek = unbox(kek, wrap);
|
||||
if (this.sealed) {
|
||||
const parsed = JSON.parse(unbox(dek, this.sealed).toString('utf8'));
|
||||
this.data = merge(parsed);
|
||||
this.sealed = null;
|
||||
}
|
||||
this.dek = dek;
|
||||
this.locked = false;
|
||||
}
|
||||
|
||||
// Neue Entsperrmethode: verpackt den (ggf. neu erzeugten) DEK mit kek
|
||||
wrapDek(kek) {
|
||||
if (!this.dek) this.dek = crypto.randomBytes(32);
|
||||
return box(kek, this.dek);
|
||||
}
|
||||
|
||||
// Letzte Methode entfernt → Sperre aus, Inhalt wieder nur per Betriebssystem verschlüsselt
|
||||
dropLockIfEmpty() {
|
||||
if (!this.lockEnabled) { this.lock = null; this.dek = null; }
|
||||
}
|
||||
|
||||
canEncrypt() {
|
||||
@@ -64,12 +112,23 @@ class Store {
|
||||
json = raw.toString('utf8');
|
||||
}
|
||||
const parsed = JSON.parse(json);
|
||||
this.data = { ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } };
|
||||
if (parsed.mrtermLock) {
|
||||
// Gesperrt: nur Darstellungs-Einstellungen (meta) sind bis zum Entsperren bekannt
|
||||
this.lock = parsed.lock;
|
||||
this.sealed = parsed.data;
|
||||
this.locked = true;
|
||||
this.data = merge({ settings: parsed.meta || {} });
|
||||
} else this.data = merge(parsed);
|
||||
return this.data;
|
||||
}
|
||||
|
||||
save() {
|
||||
const json = JSON.stringify(this.data, null, 2);
|
||||
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
|
||||
let json = JSON.stringify(this.data, null, 2);
|
||||
if (this.lockEnabled && this.dek) {
|
||||
const { language, appTheme, accent } = this.data.settings;
|
||||
json = JSON.stringify({ mrtermLock: 1, meta: { language, appTheme, accent }, lock: this.lock, data: box(this.dek, Buffer.from(json)) });
|
||||
}
|
||||
const tmp = this.file + '.tmp';
|
||||
if (this.canEncrypt()) {
|
||||
fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), safeStorage.encryptString(json)]));
|
||||
|
||||
+53
-6
@@ -7,7 +7,53 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const DEFAULT_REPO = 'https://git.mrblake.cc/MrBlake/MrTerm';
|
||||
// Startet ein Programm und liefert den Exit-Code ('ENOENT', falls es nicht existiert).
|
||||
// Electron/Chromium setzt unter Linux PR_SET_NO_NEW_PRIVS, das alle Kindprozesse erben – dann
|
||||
// funktionieren weder pkexec noch sudo. Deshalb über systemd-run --user starten: Elternprozess ist
|
||||
// dann der systemd-User-Manager, ohne diesen Schalter.
|
||||
const GUI_ENV = ['DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'XDG_RUNTIME_DIR', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS'];
|
||||
function spawnCode(cmd, args) {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn(cmd, args, { stdio: 'ignore' });
|
||||
p.on('error', (e) => resolve(e.code === 'ENOENT' ? 'ENOENT' : e.message));
|
||||
p.on('exit', (c) => resolve(c));
|
||||
});
|
||||
}
|
||||
let hasSystemdRun;
|
||||
async function run(cmd, args) {
|
||||
if (hasSystemdRun === undefined) hasSystemdRun = (await spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', 'true'])) === 0;
|
||||
if (!hasSystemdRun) return spawnCode(cmd, args);
|
||||
const inPath = cmd.includes('/') ? fs.existsSync(cmd) : (process.env.PATH || '').split(':').some((d) => d && fs.existsSync(path.join(d, cmd)));
|
||||
if (!inPath) return 'ENOENT';
|
||||
const env = GUI_ENV.filter((k) => process.env[k]).flatMap((k) => ['-E', `${k}=${process.env[k]}`]);
|
||||
return spawnCode('systemd-run', ['--user', '--quiet', '--wait', '--collect', ...env, '--', cmd, ...args]);
|
||||
}
|
||||
|
||||
// Fallback ohne Polkit-Agent: pacman -U per sudo in einem Terminalfenster; wartet, bis das Fenster geschlossen ist.
|
||||
// Der Erfolg wird über eine Marker-Datei erkannt, da nicht jedes Terminal den Exit-Code durchreicht.
|
||||
async function installInTerminal(file) {
|
||||
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
const marker = file + '.ok';
|
||||
try { fs.unlinkSync(marker); } catch {}
|
||||
const script = `sudo /usr/bin/pacman -U --noconfirm ${q(file)} && touch ${q(marker)} || { echo; read -rp "[Enter]"; }`;
|
||||
const terms = [
|
||||
['konsole', ['--nofork', '-e', 'bash', '-c', script]],
|
||||
['gnome-terminal', ['--wait', '--', 'bash', '-c', script]],
|
||||
['kitty', ['bash', '-c', script]],
|
||||
['alacritty', ['-e', 'bash', '-c', script]],
|
||||
['foot', ['bash', '-c', script]],
|
||||
['xfce4-terminal', ['--disable-server', '-x', 'bash', '-c', script]],
|
||||
['xterm', ['-e', 'bash', '-c', script]],
|
||||
];
|
||||
for (const [cmd, args] of terms) {
|
||||
const c = await run(cmd, args);
|
||||
if (c === 'ENOENT') continue;
|
||||
return fs.existsSync(marker) ? 0 : c || 1;
|
||||
}
|
||||
return 'ENOENT';
|
||||
}
|
||||
|
||||
const DEFAULT_REPO ='https://git.mrblake.cc/MrBlake/MrTerm';
|
||||
|
||||
function parseRepo(url) {
|
||||
const u = new URL((url || DEFAULT_REPO).replace(/\.git$/, '').replace(/\/+$/, ''));
|
||||
@@ -134,11 +180,12 @@ class Updater {
|
||||
app.relaunch({ execPath: target });
|
||||
app.exit(0);
|
||||
} else if (kind === 'pacman' && /\.(pacman|pkg\.tar\.zst)$/.test(file)) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const p = spawn('pkexec', ['pacman', '-U', '--noconfirm', file], { stdio: 'ignore' });
|
||||
p.on('error', reject);
|
||||
p.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(i18n.t('pacman exited with code {code}', { code: c })))));
|
||||
});
|
||||
// 1. pkexec (grafische Passwortabfrage über den Polkit-Agent)
|
||||
let code = await run('pkexec', ['/usr/bin/pacman', '-U', '--noconfirm', file]);
|
||||
// 126/127 = Abfrage abgebrochen bzw. kein Polkit-Agent → Terminal mit sudo öffnen
|
||||
if (code === 126 || code === 127 || code === 'ENOENT') code = await installInTerminal(file);
|
||||
if (code === 'ENOENT') throw new Error(i18n.t('Automatic installation is not possible here. The file is located at: {file}', { file }));
|
||||
if (code !== 0) throw new Error(i18n.t('pacman exited with code {code}', { code }));
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
} else {
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// VPN-Verbindungen (WireGuard / OpenVPN), die vor dem Verbinden zu zugeordneten Hosts automatisch aufgebaut werden.
|
||||
// Linux: NetworkManager (nmcli) – kein root nötig. MrTerm legt pro VPN eine Verbindung "mrterm-<id>" an
|
||||
// (autoconnect aus). OpenVPN braucht das Plugin networkmanager-openvpn.
|
||||
// Windows: WireGuard als Tunnel-Dienst über wireguard.exe (UAC-Abfrage), OpenVPN über die OpenVPN GUI.
|
||||
const { execFile, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
function exec(cmd, args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { windowsHide: true, timeout: 90000 }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (typeof err.code === 'number' ? err.code : err.code === 'ENOENT' ? 'ENOENT' : 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || err?.message || '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
const lastLine = (s) => s.split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
class VpnManager {
|
||||
constructor(store, userDir) {
|
||||
this.store = store;
|
||||
this.dir = path.join(userDir, 'vpn');
|
||||
this.busy = new Map(); // id -> laufendes up() (parallele Verbindungen zum selben VPN nur einmal aufbauen)
|
||||
this.started = new Set(); // von MrTerm aufgebaute VPNs
|
||||
}
|
||||
|
||||
get(id) { return this.store.get().vpns.find((v) => v.id === id); }
|
||||
nmName(v) { return 'mrterm-' + v.id.slice(0, 8); }
|
||||
// Interface-/Tunnelname: max. 15 Zeichen (Linux), nur [a-z0-9]
|
||||
ifName(v) { return 'mt' + v.id.replace(/-/g, '').slice(0, 10); }
|
||||
|
||||
// ---------------------------------------------------------------- Linux (NetworkManager)
|
||||
async nm(args) {
|
||||
const r = await exec('nmcli', args);
|
||||
if (r.code === 'ENOENT') throw new Error(i18n.t('NetworkManager (nmcli) not found. MrTerm uses NetworkManager for VPN connections.'));
|
||||
return r;
|
||||
}
|
||||
async nmExists(v) {
|
||||
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show']);
|
||||
return r.stdout.split('\n').includes(this.nmName(v));
|
||||
}
|
||||
async nmImport(v) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mrterm-vpn-'));
|
||||
const file = path.join(tmp, this.ifName(v) + (v.type === 'openvpn' ? '.ovpn' : '.conf'));
|
||||
try {
|
||||
fs.writeFileSync(file, v.config || '', { mode: 0o600 });
|
||||
const r = await this.nm(['connection', 'import', 'type', v.type === 'openvpn' ? 'openvpn' : 'wireguard', 'file', file]);
|
||||
if (r.code) {
|
||||
const err = lastLine(r.stderr);
|
||||
if (v.type === 'openvpn' && /plugin|openvpn/i.test(err)) throw new Error(i18n.t('OpenVPN support for NetworkManager is missing. Install it with: sudo pacman -S networkmanager-openvpn'));
|
||||
throw new Error(i18n.t('VPN configuration could not be imported: {err}', { err }));
|
||||
}
|
||||
const uuid = (r.stdout.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0];
|
||||
const mod = ['connection', 'modify', uuid || this.ifName(v), 'connection.id', this.nmName(v), 'connection.autoconnect', 'no'];
|
||||
if (v.type === 'openvpn' && v.username) mod.push('+vpn.data', `username=${v.username}`);
|
||||
if (v.type === 'openvpn' && v.password) mod.push('+vpn.data', 'password-flags=0', 'vpn.secrets', `password=${v.password}`);
|
||||
const m = await this.nm(mod);
|
||||
if (m.code) throw new Error(lastLine(m.stderr));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Windows
|
||||
get wgExe() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'WireGuard', 'wireguard.exe'); }
|
||||
get ovpnGui() { return path.join(process.env.ProgramFiles || 'C:\\Program Files', 'OpenVPN', 'bin', 'openvpn-gui.exe'); }
|
||||
// Programm mit Administratorrechten starten (UAC) und warten
|
||||
async elevate(exe, args) {
|
||||
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||
const cmd = `$p = Start-Process -FilePath ${q(exe)} -ArgumentList @(${args.map((a) => q(`"${a}"`)).join(',')}) -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode`;
|
||||
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', cmd]);
|
||||
if (r.code) throw new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: path.basename(exe), err: lastLine(r.stderr) || r.code }));
|
||||
}
|
||||
winOvpnName(v) { return this.ifName(v) + '.ovpn'; }
|
||||
async winOpenvpnUp() {
|
||||
const r = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
||||
"@(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -match 'TAP-Windows|Wintun|OpenVPN|ovpn-dco' }).Count"]);
|
||||
return Number(r.stdout.trim()) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Gemeinsame API
|
||||
async isUp(v) {
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type === 'openvpn') return this.winOpenvpnUp();
|
||||
const r = await exec('sc', ['query', `WireGuardTunnel$${this.ifName(v)}`]);
|
||||
return /RUNNING/.test(r.stdout);
|
||||
}
|
||||
const r = await this.nm(['-t', '-f', 'NAME', 'connection', 'show', '--active']);
|
||||
return r.stdout.split('\n').includes(this.nmName(v));
|
||||
}
|
||||
|
||||
async status() {
|
||||
const out = {};
|
||||
for (const v of this.store.get().vpns) { try { out[v.id] = await this.isUp(v); } catch { out[v.id] = false; } }
|
||||
return out;
|
||||
}
|
||||
|
||||
up(id) {
|
||||
if (this.busy.has(id)) return this.busy.get(id);
|
||||
const p = this._up(id).finally(() => this.busy.delete(id));
|
||||
this.busy.set(id, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async _up(id) {
|
||||
const v = this.get(id);
|
||||
if (!v) throw new Error(i18n.t('VPN not found'));
|
||||
if (await this.isUp(v)) return;
|
||||
const fail = (err) => new Error(i18n.t('VPN “{name}” could not be connected: {err}', { name: v.label, err }));
|
||||
if (process.platform === 'win32') {
|
||||
fs.mkdirSync(this.dir, { recursive: true });
|
||||
if (v.type === 'openvpn') {
|
||||
if (!fs.existsSync(this.ovpnGui)) throw new Error(i18n.t('OpenVPN GUI not found. Install OpenVPN from openvpn.net.'));
|
||||
const dir = path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, this.winOvpnName(v)), v.config || '');
|
||||
spawn(this.ovpnGui, ['--connect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||
} else {
|
||||
if (!fs.existsSync(this.wgExe)) throw new Error(i18n.t('WireGuard for Windows not found. Install it from wireguard.com.'));
|
||||
const file = path.join(this.dir, this.ifName(v) + '.conf');
|
||||
fs.writeFileSync(file, v.config || '');
|
||||
await this.elevate(this.wgExe, ['/installtunnelservice', file]);
|
||||
}
|
||||
for (let i = 0; i < 60; i++) { if (await this.isUp(v)) { this.started.add(id); return; } await sleep(500); }
|
||||
throw fail(i18n.t('timeout'));
|
||||
}
|
||||
if (!(await this.nmExists(v))) await this.nmImport(v);
|
||||
const r = await this.nm(['--wait', '45', 'connection', 'up', 'id', this.nmName(v)]);
|
||||
if (r.code) throw fail(lastLine(r.stderr));
|
||||
this.started.add(id);
|
||||
}
|
||||
|
||||
// Beim Beenden: von MrTerm aufgebaute VPNs mit Option "beim Beenden trennen" wieder abbauen
|
||||
async downOnQuit() {
|
||||
for (const id of this.started) {
|
||||
const v = this.get(id);
|
||||
if (v && v.disconnectOnQuit !== false) await this.down(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async down(id) {
|
||||
const v = this.get(id);
|
||||
if (!v) return;
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type === 'openvpn') spawn(this.ovpnGui, ['--command', 'disconnect', this.winOvpnName(v)], { detached: true, stdio: 'ignore' }).unref();
|
||||
else if (await this.isUp(v)) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||
return;
|
||||
}
|
||||
await this.nm(['connection', 'down', 'id', this.nmName(v)]);
|
||||
}
|
||||
|
||||
// Nach Änderung/Löschen: im System hinterlegte Verbindung entfernen, beim nächsten Verbinden wird neu importiert
|
||||
async forget(v) {
|
||||
if (!v) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
if (v.type !== 'openvpn' && (await this.isUp(v))) await this.elevate(this.wgExe, ['/uninstalltunnelservice', this.ifName(v)]);
|
||||
fs.rmSync(path.join(this.dir, this.ifName(v) + '.conf'), { force: true });
|
||||
fs.rmSync(path.join(os.homedir(), 'OpenVPN', 'config', this.ifName(v)), { recursive: true, force: true });
|
||||
} else if (await this.nmExists(v)) await this.nm(['connection', 'delete', 'id', this.nmName(v)]);
|
||||
} catch { /* VPN-Werkzeuge fehlen – nichts aufzuräumen */ }
|
||||
}
|
||||
|
||||
// Alle VPNs eines Hosts (inkl. Jump-Host-Kette) aufbauen, bevor verbunden wird
|
||||
async ensureForHost(host, onStatus = () => {}) {
|
||||
const ids = [];
|
||||
for (let h = host, n = 0; h && n < 10; h = h.jumpHostId ? this.store.resolveHost(h.jumpHostId) : null, n++) {
|
||||
if (h.vpnId && !ids.includes(h.vpnId)) ids.push(h.vpnId);
|
||||
}
|
||||
for (const id of ids.reverse()) {
|
||||
const v = this.get(id);
|
||||
if (!v) continue;
|
||||
if (await this.isUp(v)) continue;
|
||||
onStatus(i18n.t('Connecting VPN “{name}” …', { name: v.label }));
|
||||
await this.up(id);
|
||||
}
|
||||
return ids.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { VpnManager };
|
||||
+357
-13
@@ -40,6 +40,8 @@ const ICONS = {
|
||||
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||||
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
|
||||
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
|
||||
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
|
||||
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||
};
|
||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||
function colorFor(h) {
|
||||
@@ -137,6 +139,9 @@ const S = {
|
||||
const settings = () => S.vault.settings;
|
||||
|
||||
async function reload() {
|
||||
S.lock = await call('lock:status');
|
||||
$('#lockBtn').style.display = S.lock.enabled ? '' : 'none';
|
||||
if (S.lock.locked) return showLock();
|
||||
S.vault = await call('vault:get');
|
||||
I18N.setLanguage(settings().language, navigator.language);
|
||||
applyStatic();
|
||||
@@ -145,6 +150,7 @@ async function reload() {
|
||||
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
||||
applyAppTheme();
|
||||
S.activeForwards = new Set(await call('fw:active'));
|
||||
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -269,7 +275,7 @@ function renderTree() {
|
||||
|
||||
function render() {
|
||||
const page = $('#homeView');
|
||||
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
|
||||
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, vpns: viewVpns, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
|
||||
const scroll = page.scrollTop;
|
||||
page.innerHTML = '';
|
||||
views[S.view](page);
|
||||
@@ -383,7 +389,7 @@ function hostMenu(hst) {
|
||||
return [
|
||||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
||||
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }] : []),
|
||||
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }] : []),
|
||||
'-',
|
||||
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
|
||||
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
|
||||
@@ -530,6 +536,7 @@ function editHost(hst = {}) {
|
||||
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField),
|
||||
field(T('Label'), 'label', hst.label, { placeholder: T('My server') }),
|
||||
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
|
||||
...(S.vault.vpns.length ? [field(T('VPN'), 'vpnId', hst.vpnId, { type: 'select', options: [['', T('— No VPN —')], ...S.vault.vpns.map((v) => [v.id, v.label])], hint: T('Connected automatically before connecting to this host.') })] : []),
|
||||
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
|
||||
heading(T('Authentication')),
|
||||
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
|
||||
@@ -545,7 +552,7 @@ function editHost(hst = {}) {
|
||||
address: v.address.trim(),
|
||||
port: v.port ? Number(v.port) : undefined,
|
||||
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null,
|
||||
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null, vpnId: v.vpnId || null,
|
||||
};
|
||||
await call('vault:upsert', 'hosts', item);
|
||||
reload();
|
||||
@@ -726,6 +733,100 @@ function editForward(f = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================ VPN (WireGuard / OpenVPN)
|
||||
S.vpnStatus = {};
|
||||
const VPN_TYPES = { wireguard: 'WireGuard', openvpn: 'OpenVPN' };
|
||||
|
||||
function viewVpns(page) {
|
||||
page.append(toolbar(T('Search VPNs …'), [
|
||||
{ label: T('Import'), icon: 'download', run: () => editVpn({}, true) },
|
||||
{ label: T('New VPN'), icon: 'plus', cls: 'primary', run: () => editVpn() },
|
||||
]));
|
||||
const c = h('<div class="content"></div>'); page.append(c);
|
||||
const list = S.vault.vpns.filter((v) => matches(v.label, VPN_TYPES[v.type]));
|
||||
if (!list.length) return c.append(emptyState('shield', T('No VPNs'), T('Add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host.'), T('New VPN'), () => editVpn()));
|
||||
const l = h('<div class="list"></div>');
|
||||
for (const v of list) {
|
||||
const on = !!S.vpnStatus[v.id];
|
||||
const n = S.vault.hosts.filter((x) => x.vpnId === v.id).length;
|
||||
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.shield}</div><div class="meta"><div class="title">${esc(v.label)} <span class="status-pill ${on ? 'on' : ''}">${on ? T('connected') : T('disconnected')}</span></div><div class="sub">${VPN_TYPES[v.type] || v.type} · ${n} Host${n === 1 ? '' : 's'}</div></div>
|
||||
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + T('Disconnect') : ICONS.play + T('Connect')}</button><div class="actions"><button data-a="edit" title="${T('Edit')}">${ICONS.edit}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
|
||||
card.onclick = async (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'toggle') {
|
||||
const b = e.target.closest('button'); b.disabled = true; b.innerHTML = `<span class="spinner sm"></span>${on ? T('Disconnect') : T('Connecting …')}`;
|
||||
try { await call(on ? 'vpn:down' : 'vpn:up', v.id); if (!on) toast(T('VPN “{name}” connected', { name: v.label }), 'ok'); } catch {}
|
||||
S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||
render();
|
||||
} else if (a === 'del') {
|
||||
if (await confirmBox(T('Delete VPN?'), T('“{name}” will be removed. Assigned hosts will connect without VPN.', { name: v.label }))) { await call('vault:remove', 'vpns', v.id); reload(); }
|
||||
} else editVpn(v);
|
||||
};
|
||||
l.append(card);
|
||||
}
|
||||
c.append(l);
|
||||
}
|
||||
|
||||
function editVpn(v = {}, pick = false) {
|
||||
let type = v.type || 'wireguard';
|
||||
const seg = h('<div class="seg"><button data-t="wireguard">WireGuard</button><button data-t="openvpn">OpenVPN</button></div>');
|
||||
const ovpnBox = h('<div></div>');
|
||||
ovpnBox.append(
|
||||
heading(T('Authentication')),
|
||||
field(T('Username'), 'username', v.username, { placeholder: T('optional') }),
|
||||
field(T('Password'), 'password', v.password, { type: 'password', placeholder: T('stored encrypted') }),
|
||||
);
|
||||
const cfg = field(T('Configuration'), 'config', v.config, { type: 'textarea', placeholder: '[Interface]\nPrivateKey = …', hint: '' });
|
||||
$('textarea', cfg).style.minHeight = '180px';
|
||||
const setType = (t) => {
|
||||
type = t;
|
||||
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.t === t));
|
||||
ovpnBox.style.display = t === 'openvpn' ? '' : 'none';
|
||||
$('textarea', cfg).placeholder = t === 'openvpn' ? 'client\ndev tun\nremote vpn.example.com 1194\n<ca>…</ca>' : '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\n\n[Peer]\n…';
|
||||
const hint = $('.hint', cfg) || cfg.appendChild(h('<div class="hint"></div>'));
|
||||
hint.textContent = t === 'openvpn' ? T('Certificates and keys must be embedded in the .ovpn file (<ca>, <cert>, <key> …).') : T('Contents of a WireGuard .conf file.');
|
||||
};
|
||||
$$('button', seg).forEach((b) => (b.onclick = () => setType(b.dataset.t)));
|
||||
|
||||
// Hosts zuordnen
|
||||
const hostBox = h(`<div class="vpn-hosts"></div>`);
|
||||
const sorted = [...S.vault.hosts].sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
|
||||
if (!sorted.length) hostBox.append(h(`<div class="hint">${T('No hosts yet')}</div>`));
|
||||
sorted.forEach((x) => hostBox.append(h(`<label class="check"><input type="checkbox" data-host="${x.id}" ${x.vpnId && x.vpnId === v.id ? 'checked' : ''}/>${esc(x.label || x.address)}<span style="color:var(--faint);margin-left:6px;font-size:11px">${esc(hostSub(x))}</span></label>`)));
|
||||
|
||||
const form = openDrawer(v.id ? T('Edit VPN') : T('New VPN'), [
|
||||
seg,
|
||||
field(T('Name'), 'label', v.label, { placeholder: T('Office VPN') }),
|
||||
cfg,
|
||||
ovpnBox,
|
||||
check(T('Disconnect when MrTerm closes'), 'disconnectOnQuit', v.disconnectOnQuit !== false),
|
||||
heading(T('Assigned hosts')),
|
||||
hostBox,
|
||||
], async () => {
|
||||
const f = formValues(form);
|
||||
if (!f.config.trim()) throw new Error(T('Configuration is missing.'));
|
||||
if (type === 'wireguard' && !/\[Interface\]/i.test(f.config)) throw new Error(T('This does not look like a WireGuard configuration.'));
|
||||
const item = { ...v, type, label: f.label.trim() || VPN_TYPES[type], config: f.config, disconnectOnQuit: f.disconnectOnQuit,
|
||||
username: type === 'openvpn' ? f.username : '', password: type === 'openvpn' ? f.password : '' };
|
||||
const saved = await call('vault:upsert', 'vpns', item);
|
||||
for (const cb of $$('[data-host]', hostBox)) {
|
||||
const hst = S.vault.hosts.find((x) => x.id === cb.dataset.host);
|
||||
const want = cb.checked ? saved.id : (hst.vpnId === saved.id ? null : hst.vpnId || null);
|
||||
if ((hst.vpnId || null) !== want) await call('vault:upsert', 'hosts', { ...hst, vpnId: want });
|
||||
}
|
||||
reload();
|
||||
}, (() => { const b = h(`<button class="btn">${ICONS.folder}${T('File…')}</button>`); b.onclick = loadFile; return b; })());
|
||||
async function loadFile() {
|
||||
const f = await call('import:pickFile', T('VPN configuration'), ['conf', 'ovpn']);
|
||||
if (!f) return;
|
||||
$('[name=config]', form).value = f.content;
|
||||
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name.replace(/\.(conf|ovpn)$/i, '');
|
||||
setType(/\.ovpn$/i.test(f.name) || /^\s*(client|remote)\b/m.test(f.content) ? 'openvpn' : 'wireguard');
|
||||
}
|
||||
setType(type);
|
||||
if (pick) loadFile();
|
||||
}
|
||||
|
||||
// ============================================================ Known Hosts & Verlauf
|
||||
function viewKnown(page) {
|
||||
page.append(toolbar(T('Search known hosts …')));
|
||||
@@ -838,6 +939,10 @@ async function viewSettings(page) {
|
||||
rdmBtn.onclick = importRdm;
|
||||
importCard.append(rdmBtn);
|
||||
|
||||
// ---- App-Sperre
|
||||
const secCard = h(`<div class="settings-card"><h3>${T('App lock')}</h3><p style="color:var(--muted);margin-top:0">${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}</p><div class="lock-rows"></div></div>`);
|
||||
renderLockSettings($('.lock-rows', secCard));
|
||||
|
||||
// ---- Updates
|
||||
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">${T('Installed version: {v}', { v: '…' })}</p></div>`);
|
||||
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
|
||||
@@ -868,8 +973,121 @@ async function viewSettings(page) {
|
||||
<span>${C}+<kbd>1..9</kbd></span><span>${T('Switch to tab')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>${T('Copy / paste in terminal')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span></div></div>`);
|
||||
c.append(langCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||||
c.append(langCard, secCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
}
|
||||
|
||||
// ============================================================ App-Sperre
|
||||
function showLock() {
|
||||
if ($('.lock-screen')) return;
|
||||
const m = S.lock.meta || {};
|
||||
I18N.setLanguage(m.language, navigator.language);
|
||||
applyStatic();
|
||||
applyAppTheme(m);
|
||||
closeDrawer();
|
||||
$$('.ctx').forEach((x) => x.remove());
|
||||
$('.palette')?.closest('.modal-bg')?.remove();
|
||||
const el = h(`<div class="lock-screen"><div class="lock-box">
|
||||
<img class="lock-logo" src="logo.png" alt=""/><h2>${T('MrTerm is locked')}</h2>
|
||||
${S.lock.hasPassword ? `<form class="lock-pw"><input type="password" name="pw" placeholder="${esc(T('Password'))}" autocomplete="off"/><button class="btn primary">${ICONS.unlock}${T('Unlock')}</button></form>` : ''}
|
||||
${S.lock.fido.length ? `<button class="btn lock-fido">${ICONS.key}${T('Unlock with security key')}</button>` : ''}
|
||||
<div class="lock-err"></div></div></div>`);
|
||||
const err = $('.lock-err', el);
|
||||
const done = async () => { el.remove(); await reload(); };
|
||||
const form = $('.lock-pw', el);
|
||||
if (form) form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const inp = $('input', form);
|
||||
err.textContent = '';
|
||||
try { await api.call('lock:unlockPassword', inp.value); done(); } catch (x) { err.textContent = x.message; inp.select(); }
|
||||
};
|
||||
const fb = $('.lock-fido', el);
|
||||
if (fb) fb.onclick = async () => {
|
||||
err.textContent = ''; fb.disabled = true;
|
||||
try { await api.call('lock:unlockFido'); done(); } catch (x) { err.textContent = x.message; }
|
||||
fb.disabled = false;
|
||||
};
|
||||
document.body.append(el);
|
||||
($('input', el) || fb)?.focus();
|
||||
}
|
||||
|
||||
async function lockNow() {
|
||||
if (!S.lock?.enabled) return;
|
||||
S.lock = await call('lock:lock');
|
||||
if (S.lock.locked) showLock();
|
||||
}
|
||||
$('#lockBtn').onclick = lockNow;
|
||||
|
||||
// Automatische Sperre nach Inaktivität (Einstellung autoLock in Minuten, 0 = aus)
|
||||
let lastActivity = Date.now();
|
||||
['keydown', 'mousedown', 'mousemove', 'wheel'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { capture: true, passive: true }));
|
||||
setInterval(() => {
|
||||
const min = Number(S.vault?.settings.autoLock) || 0;
|
||||
if (!min || !S.lock?.enabled || S.lock.locked || $('.lock-screen')) return;
|
||||
// In einem eingebetteten RDP-Fenster sieht MrTerm keine Eingaben – dort nicht automatisch sperren
|
||||
if (S.tabs.some((t) => t.id === S.active && t.kind === 'rdp')) { lastActivity = Date.now(); return; }
|
||||
if (Date.now() - lastActivity > min * 60000) lockNow();
|
||||
}, 15000);
|
||||
|
||||
function renderLockSettings(box) {
|
||||
const L = S.lock;
|
||||
box.innerHTML = '';
|
||||
const refresh = (st) => { S.lock = st; $('#lockBtn').style.display = st.enabled ? '' : 'none'; renderLockSettings(box); };
|
||||
const disableWarn = async () => (L.hasPassword ? 1 : 0) + L.fido.length > 1 || confirmBox(T('Disable app lock?'), T('This is the last unlock method. MrTerm will no longer be locked.'), T('Disable'));
|
||||
|
||||
// Passwort
|
||||
const pwRow = h(`<div class="lock-row"><div class="grow"><b>${T('Password')}</b><div class="sub">${L.hasPassword ? T('Set') : T('Not set')}</div></div></div>`);
|
||||
const pwBtn = h(`<button class="btn">${L.hasPassword ? T('Change password') : T('Set password')}</button>`);
|
||||
pwBtn.onclick = async () => {
|
||||
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
|
||||
body: `<div class="field"><label>${T('New password')}</label><input name="a" type="password" autocomplete="off"/></div><div class="field"><label>${T('Repeat password')}</label><input name="b" type="password" autocomplete="off"/></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
|
||||
try { refresh(await call('lock:setPassword', r.a)); toast(T('Password saved'), 'ok'); } catch {}
|
||||
};
|
||||
pwRow.append(pwBtn);
|
||||
if (L.hasPassword) {
|
||||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||||
rm.onclick = async () => { if (await disableWarn()) refresh(await call('lock:removePassword')); };
|
||||
pwRow.append(rm);
|
||||
}
|
||||
box.append(pwRow);
|
||||
|
||||
// Sicherheitsschlüssel
|
||||
for (const k of L.fido) {
|
||||
const row = h(`<div class="lock-row"><div class="grow"><b>${ICONS.key}${esc(k.label)}</b><div class="sub">${T('Security key (FIDO2)')}</div></div></div>`);
|
||||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||||
rm.onclick = async () => { if (await disableWarn() && await confirmBox(T('Remove security key?'), k.label, T('Remove'))) refresh(await call('lock:removeFido', k.id)); };
|
||||
row.append(rm);
|
||||
box.append(row);
|
||||
}
|
||||
const add = h(`<button class="btn">${ICONS.plus}${T('Add security key')}</button>`);
|
||||
add.onclick = async () => {
|
||||
const label = await promptBox(T('Add security key'), T('Name'), 'YubiKey');
|
||||
if (label == null) return;
|
||||
add.disabled = true;
|
||||
try { refresh(await call('lock:addFido', label)); toast(T('Security key added'), 'ok'); } catch {}
|
||||
add.disabled = false;
|
||||
};
|
||||
const actions = h('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
|
||||
actions.append(add);
|
||||
if (L.enabled) {
|
||||
const now = h(`<button class="btn primary">${ICONS.lock}${T('Lock now')}</button>`);
|
||||
now.onclick = lockNow;
|
||||
actions.append(now);
|
||||
}
|
||||
box.append(actions);
|
||||
|
||||
if (L.enabled) {
|
||||
const al = field(T('Lock automatically after inactivity'), 'autoLock', String(settings().autoLock || 0), { type: 'select',
|
||||
options: [['0', T('Never')], ...[1, 5, 10, 15, 30, 60].map((n) => [String(n), T('{n} minutes', { n })])] });
|
||||
al.style.marginTop = '14px';
|
||||
al.onchange = async () => { const v = Number($('select', al).value); await call('vault:settings', { autoLock: v }); S.vault.settings.autoLock = v; };
|
||||
box.append(al);
|
||||
box.append(h(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}</div>`));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Design
|
||||
@@ -878,8 +1096,7 @@ function termTheme() {
|
||||
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
|
||||
return TERM_THEMES[id] || TERM_THEMES.mrterm;
|
||||
}
|
||||
function applyAppTheme() {
|
||||
const st = settings();
|
||||
function applyAppTheme(st = settings()) {
|
||||
const root = document.documentElement;
|
||||
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
|
||||
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
|
||||
@@ -943,7 +1160,7 @@ function addTab(session) {
|
||||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
||||
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []),
|
||||
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker }[session.kind] || openTerminal)(session.host) }] : []),
|
||||
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
|
||||
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
|
||||
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||
@@ -990,6 +1207,7 @@ class TerminalSession {
|
||||
this.el = h(`<div class="session">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
|
||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button>`}
|
||||
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||
</div>
|
||||
@@ -1018,6 +1236,7 @@ class TerminalSession {
|
||||
this.term.loadAddon(this.search);
|
||||
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_e, url) => api.call('shell:open', url)));
|
||||
this.term.open($('.term', this.el));
|
||||
$('.term', this.el).style.background = termTheme().background; // Innenabstand in Terminalfarbe
|
||||
this.term.onData((d) => { if (this.alive) api.ssh.write(this.id, d); else if (d === '\r' && this.ended) this.reconnect(); });
|
||||
this.term.onResize(({ cols, rows }) => { if (this.alive) api.ssh.resize(this.id, cols, rows); });
|
||||
this.term.onSelectionChange(() => { if (settings().copyOnSelect && this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); });
|
||||
@@ -1027,7 +1246,7 @@ class TerminalSession {
|
||||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
|
||||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
|
||||
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
|
||||
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW'].includes(e.code)) return false;
|
||||
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'KeyL'].includes(e.code)) return false;
|
||||
if (e.ctrlKey && e.code === 'Tab') return false;
|
||||
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
|
||||
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
|
||||
@@ -1046,6 +1265,7 @@ class TerminalSession {
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||
if (a === 'sftp') openSftp(this.host);
|
||||
if (a === 'docker') openDocker(this.host);
|
||||
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||
if (a === 'snipnew') editSnippet();
|
||||
if (a === 'find') this.toggleFind();
|
||||
@@ -1091,6 +1311,7 @@ class TerminalSession {
|
||||
applySettings() {
|
||||
const st = settings();
|
||||
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
|
||||
$('.term', this.el).style.background = termTheme().background;
|
||||
this.doFit();
|
||||
}
|
||||
|
||||
@@ -1114,7 +1335,7 @@ class TerminalSession {
|
||||
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
||||
this.doFit();
|
||||
try {
|
||||
await api.call('ssh:open', this.id, this.host.id || this.host, { cols: this.term.cols, rows: this.term.rows });
|
||||
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
|
||||
this.alive = true;
|
||||
this.hideOverlay();
|
||||
setTabState(this, 'on');
|
||||
@@ -1152,7 +1373,7 @@ function openTerminal(host) { return new TerminalSession(host); }
|
||||
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
||||
S.covered = false;
|
||||
function updateCover() {
|
||||
const covered = !!document.querySelector('.modal-bg, .ctx');
|
||||
const covered = !!document.querySelector('.modal-bg, .ctx, .lock-screen');
|
||||
if (covered === S.covered) return;
|
||||
S.covered = covered;
|
||||
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
|
||||
@@ -1447,6 +1668,125 @@ class SftpSession {
|
||||
}
|
||||
function openSftp(host) { return new SftpSession(host); }
|
||||
|
||||
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
|
||||
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
|
||||
|
||||
// ============================================================ Docker (Container über SSH)
|
||||
class DockerSession {
|
||||
constructor(host) {
|
||||
this.kind = 'docker'; this.id = uid(); this.host = host;
|
||||
this.title = `Docker · ${host.label || host.address}`;
|
||||
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
||||
this.el = h(`<div class="session docker">
|
||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
||||
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
|
||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
||||
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
</div>
|
||||
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
|
||||
this.body = $('.docker-body', this.el);
|
||||
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
||||
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
|
||||
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
|
||||
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||
addTab(this);
|
||||
this.open();
|
||||
}
|
||||
|
||||
async open() {
|
||||
try {
|
||||
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
|
||||
setTabState(this, 'on');
|
||||
this.loadStats();
|
||||
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
|
||||
} catch (e) {
|
||||
setTabState(this, 'err');
|
||||
this.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
error(msg) {
|
||||
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:520px;text-align:center">${esc(msg)}</div></div>`;
|
||||
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
|
||||
b.onclick = () => { closeTab(this.id); openDocker(this.host); };
|
||||
$('.pane-empty', this.body).append(b);
|
||||
}
|
||||
|
||||
apply(r) {
|
||||
this.containers = r.containers;
|
||||
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
async refresh(withStats) {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
async loadStats() {
|
||||
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
|
||||
}
|
||||
|
||||
draw() {
|
||||
const running = (c) => c.state === 'running';
|
||||
const list = this.containers
|
||||
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
||||
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
if (!this.containers.length) { this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--muted)">${T('No containers on this host.')}</div></div>`; return; }
|
||||
const scroll = this.body.scrollTop;
|
||||
this.body.innerHTML = `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Ports')}</th><th class="num">CPU</th><th class="num">${T('Memory')}</th><th></th></tr></thead><tbody>
|
||||
${list.map((c) => {
|
||||
const st = this.stats[c.id];
|
||||
const on = running(c);
|
||||
return `<tr data-id="${esc(c.id)}"><td><span class="dot ${on ? 'on' : c.state === 'paused' || c.state === 'restarting' ? 'wait' : ''}"></span></td>
|
||||
<td class="name"><div><b>${esc(c.name)}</b><span class="img">${esc(c.image)}</span></div></td>
|
||||
<td class="muted">${esc(c.status)}</td><td class="muted ports" title="${esc(c.ports)}">${esc(c.ports)}</td>
|
||||
<td class="num">${on && st ? esc(st.cpu) : ''}</td><td class="num">${on && st ? esc(st.mem.split('/')[0].trim()) : ''}</td>
|
||||
<td class="acts">
|
||||
${on ? `<button class="btn ghost sm" data-c="shell" title="${T('Open shell')}">${ICONS.term}</button>` : ''}
|
||||
<button class="btn ghost sm" data-c="logs" title="${T('Logs')}">${ICONS.logs}</button>
|
||||
${on ? `<button class="btn ghost sm" data-c="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-c="stop" title="${T('Stop')}">${ICONS.stop}</button>`
|
||||
: `<button class="btn ghost sm" data-c="start" title="${T('Start')}">${ICONS.play}</button><button class="btn ghost sm" data-c="remove" title="${T('Delete')}">${ICONS.trash}</button>`}
|
||||
</td></tr>`;
|
||||
}).join('')}</tbody></table>`;
|
||||
this.body.scrollTop = scroll;
|
||||
$$('tr[data-id]', this.body).forEach((tr) => {
|
||||
const c = this.containers.find((x) => x.id === tr.dataset.id);
|
||||
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
|
||||
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
|
||||
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
||||
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
|
||||
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
|
||||
'-',
|
||||
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
|
||||
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
|
||||
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
|
||||
'-',
|
||||
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async act(a, c, btn) {
|
||||
if (a === 'shell' || a === 'logs') {
|
||||
const cmd = await call('docker:command', this.id, a, c.id);
|
||||
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
|
||||
return;
|
||||
}
|
||||
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
|
||||
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner sm"></span>'; }
|
||||
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
|
||||
await this.refresh(true);
|
||||
}
|
||||
|
||||
onShow() { if (this.containers.length) this.refresh(); }
|
||||
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||||
}
|
||||
function openDocker(host) { return new DockerSession(host); }
|
||||
|
||||
// ============================================================ Command Palette / Quick Connect
|
||||
function openPalette() {
|
||||
if ($('.palette')) return;
|
||||
@@ -1465,6 +1805,7 @@ function openPalette() {
|
||||
hosts.slice(0, 12).forEach((x) => {
|
||||
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
|
||||
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
|
||||
});
|
||||
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
|
||||
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||||
@@ -1505,7 +1846,9 @@ if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
const inTerm = !!e.target.closest?.('.xterm');
|
||||
if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
|
||||
if ($('.lock-screen')) return;
|
||||
if (e.shiftKey && e.code === 'KeyL') { e.preventDefault(); lockNow(); }
|
||||
else if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
|
||||
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
|
||||
else if (e.code === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -1530,8 +1873,9 @@ api.on('secret:request', async (req) => {
|
||||
api.replySecret(req.reqId, r ? r.v : null);
|
||||
});
|
||||
api.on('fw:event', () => reload());
|
||||
api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch(() => ({})); if (S.view === 'vpns' && S.active === 'home') render(); });
|
||||
api.on('toast', (m, t) => toast(m, t));
|
||||
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
|
||||
|
||||
reload();
|
||||
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
|
||||
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
||||
<div class="drag-fill"></div>
|
||||
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
||||
<div class="win-ctrls">
|
||||
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
||||
<button id="winMax" title="Maximize" data-i18n-title="Maximize"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button>
|
||||
@@ -33,6 +34,7 @@
|
||||
<button class="nav" data-view="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
|
||||
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
||||
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
||||
<button class="nav" data-view="vpns"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12h6M12 9v6"/></svg><span>VPN</span></button>
|
||||
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
||||
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
||||
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</span></button>
|
||||
|
||||
@@ -238,6 +238,7 @@ body.in-session #sidebar { display: none; }
|
||||
.session .term { flex: 1; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
/* Innenabstand am .xterm-Element: FitAddon zieht nur dessen Padding ab (am Elternelement würde es mitgezählt) */
|
||||
.session .term .xterm { height: 100%; padding: 8px 0 4px 10px; }
|
||||
.session .term .xterm-viewport { background-color: transparent !important; } /* sonst schwarzer Rand im Innenabstand */
|
||||
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
|
||||
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
||||
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
|
||||
@@ -342,3 +343,46 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
||||
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||
|
||||
/* App-Sperre */
|
||||
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
||||
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
|
||||
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
||||
.lock-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||
.lock-logo { width: 64px; height: 64px; }
|
||||
.lock-pw { display: flex; gap: 8px; width: 100%; }
|
||||
.lock-pw input { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; color: var(--text); outline: none; }
|
||||
.lock-pw input:focus { border-color: var(--accent); }
|
||||
.lock-fido { width: 100%; justify-content: center; }
|
||||
.lock-err { color: var(--red); min-height: 18px; font-size: 13px; }
|
||||
.lock-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.lock-row .grow { flex: 1; min-width: 0; }
|
||||
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||
.lock-row b svg { width: 14px; height: 14px; }
|
||||
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
/* VPN */
|
||||
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
|
||||
|
||||
/* Docker */
|
||||
.session.docker .sbar .search.sm { flex: 0 1 260px; height: 28px; margin: 0; }
|
||||
.session.docker .rt { color: var(--faint); }
|
||||
.docker-body { flex: 1; overflow: auto; position: relative; }
|
||||
.docker-body .pane-empty { height: 100%; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
|
||||
.docker-table { width: 100%; border-collapse: collapse; }
|
||||
.docker-table th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 8px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); z-index: 1; }
|
||||
.docker-table td { padding: 7px 10px; border-bottom: 1px solid var(--row-line); white-space: nowrap; vertical-align: middle; }
|
||||
.docker-table tr:hover td { background: rgb(var(--tint) / 0.024); }
|
||||
.docker-table td.name { width: 30%; max-width: 0; }
|
||||
.docker-table td.name > div { display: flex; flex-direction: column; min-width: 0; }
|
||||
.docker-table td.name b, .docker-table td.name .img { overflow: hidden; text-overflow: ellipsis; }
|
||||
.docker-table td.name .img { color: var(--faint); font-size: 11px; }
|
||||
.docker-table td.muted { color: var(--muted); font-size: 12px; }
|
||||
.docker-table td.ports { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.docker-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.docker-table td.acts { text-align: right; }
|
||||
.docker-table td.acts .btn { padding: 4px 6px; }
|
||||
.docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
|
||||
.docker-table .dot.on { background: var(--green); }
|
||||
.docker-table .dot.wait { background: var(--orange); }
|
||||
|
||||
Reference in New Issue
Block a user