Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a608948823 | ||
|
|
70a15eddbc | ||
|
|
039d89b9f7 | ||
|
|
b38837d4e0 |
@@ -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
|
- **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
|
- **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
|
- **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)
|
- **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
|
- **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
|
- **Known hosts**: MrTerm warns you if a server's host key changes
|
||||||
- **History** of recent connections
|
- **History** of recent connections
|
||||||
@@ -66,6 +68,26 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r
|
|||||||
|
|
||||||
`Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual.
|
`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
|
## 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:
|
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:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||||
"main": "src/main/main.js",
|
"main": "src/main/main.js",
|
||||||
"author": "MrBlake",
|
"author": "MrBlake",
|
||||||
|
|||||||
+58
@@ -352,6 +352,64 @@
|
|||||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||||
|
|
||||||
|
// 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
|
// Main-Prozess
|
||||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||||
'Unknown host': 'Unbekannter Host',
|
'Unknown host': 'Unbekannter Host',
|
||||||
|
|||||||
@@ -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 };
|
||||||
+52
-7
@@ -11,6 +11,8 @@ const { Updater } = require('./updater');
|
|||||||
const { createEmbed, embedSupported } = require('./rdp-embed');
|
const { createEmbed, embedSupported } = require('./rdp-embed');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const fido = require('./fido');
|
const fido = require('./fido');
|
||||||
|
const { VpnManager } = require('./vpn');
|
||||||
|
const { DockerManager } = require('./docker');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -59,6 +61,8 @@ function askSecret(sessionId, req) {
|
|||||||
|
|
||||||
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
const ssh = new SshManager(store, confirmHostKey, askSecret);
|
||||||
const updater = new Updater(store, send);
|
const updater = new Updater(store, send);
|
||||||
|
const vpn = new VpnManager(store, app.getPath('userData'));
|
||||||
|
const docker = new DockerManager(ssh);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -176,8 +180,38 @@ handle('lock:removeFido', (id) => {
|
|||||||
|
|
||||||
// ---------- Vault ----------
|
// ---------- Vault ----------
|
||||||
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
||||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
handle('vault:upsert', async (col, item) => {
|
||||||
handle('vault:remove', (col, id) => store.remove(col, id));
|
// Geänderte VPN-Konfiguration: alte Systemverbindung entfernen, beim nächsten Verbinden neu importieren
|
||||||
|
if (col === 'vpns' && item.id) {
|
||||||
|
const old = store.get().vpns.find((v) => v.id === item.id);
|
||||||
|
if (old && ['type', 'config', 'username', 'password'].some((k) => (old[k] || '') !== (item[k] || ''))) await vpn.forget(old);
|
||||||
|
}
|
||||||
|
return store.upsert(col, item);
|
||||||
|
});
|
||||||
|
handle('vault:remove', async (col, id) => {
|
||||||
|
if (col === 'vpns') {
|
||||||
|
await vpn.forget(store.get().vpns.find((v) => v.id === id));
|
||||||
|
store.get().hosts.forEach((h) => { if (h.vpnId === id) h.vpnId = null; });
|
||||||
|
}
|
||||||
|
return store.remove(col, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Docker ----------
|
||||||
|
handle('docker:open', async (id, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return docker.open(id, host, () => send('docker:closed', id));
|
||||||
|
});
|
||||||
|
handle('docker:list', (id) => docker.list(id));
|
||||||
|
handle('docker:stats', (id) => docker.stats(id));
|
||||||
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
|
handle('docker:close', (id) => docker.close(id));
|
||||||
|
|
||||||
|
// ---------- VPN ----------
|
||||||
|
handle('vpn:status', () => vpn.status());
|
||||||
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
|
handle('vpn:down', (id) => vpn.down(id));
|
||||||
handle('vault:settings', (s) => {
|
handle('vault:settings', (s) => {
|
||||||
store.setSettings(s);
|
store.setSettings(s);
|
||||||
if ('language' in s) applyLanguage();
|
if ('language' in s) applyLanguage();
|
||||||
@@ -268,12 +302,16 @@ function hostWithOverrides(hostOrId) {
|
|||||||
if (!h) throw new Error(i18n.t('Host not found'));
|
if (!h) throw new Error(i18n.t('Host not found'));
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
// Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells
|
||||||
|
if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label };
|
||||||
return hostOrId; // Quick-Connect: temporärer Host
|
return hostOrId; // Quick-Connect: temporärer Host
|
||||||
}
|
}
|
||||||
|
|
||||||
handle('ssh:open', async (sessionId, hostRef, size) => {
|
handle('ssh:open', async (sessionId, hostRef, size) => {
|
||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
|
||||||
|
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed');
|
||||||
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -286,7 +324,11 @@ ipcMain.on('secret:reply', (_e, reqId, value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- SFTP ----------
|
// ---------- SFTP ----------
|
||||||
handle('sftp:open', (sessionId, hostRef) => ssh.openSftp(sessionId, hostWithOverrides(hostRef)));
|
handle('sftp:open', async (sessionId, hostRef) => {
|
||||||
|
const host = hostWithOverrides(hostRef);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
|
return ssh.openSftp(sessionId, host);
|
||||||
|
});
|
||||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||||
@@ -320,9 +362,10 @@ handle('local:op', (op, a, b) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Port-Forwarding ----------
|
// ---------- Port-Forwarding ----------
|
||||||
handle('fw:start', (id) => {
|
handle('fw:start', async (id) => {
|
||||||
const fw = store.get().forwards.find((f) => f.id === id);
|
const fw = store.get().forwards.find((f) => f.id === id);
|
||||||
if (!fw) throw new Error(i18n.t('Rule not found'));
|
if (!fw) throw new Error(i18n.t('Rule not found'));
|
||||||
|
if (await vpn.ensureForHost(store.resolveHost(fw.hostId))) send('vpn:changed');
|
||||||
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
return ssh.startForward(fw, (ev) => send('fw:event', id, ev));
|
||||||
});
|
});
|
||||||
handle('fw:stop', (id) => ssh.stopForward(id));
|
handle('fw:stop', (id) => ssh.stopForward(id));
|
||||||
@@ -330,8 +373,9 @@ handle('fw:active', () => ssh.activeForwards());
|
|||||||
|
|
||||||
// ---------- RDP ----------
|
// ---------- RDP ----------
|
||||||
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
handle('rdp:detect', () => rdp.detect(store.get().settings));
|
||||||
handle('rdp:launch', (hostId) => {
|
handle('rdp:launch', async (hostId) => {
|
||||||
const host = hostWithOverrides(hostId);
|
const host = hostWithOverrides(hostId);
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
const r = rdp.launch(host, store.get().settings);
|
const r = rdp.launch(host, store.get().settings);
|
||||||
if (r.process) {
|
if (r.process) {
|
||||||
@@ -351,6 +395,7 @@ handle('rdp:embedSupported', () => {
|
|||||||
handle('rdp:open', async (id, hostRef, bounds) => {
|
handle('rdp:open', async (id, hostRef, bounds) => {
|
||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
const emb = createEmbed(win.getNativeWindowHandle());
|
const emb = createEmbed(win.getNativeWindowHandle());
|
||||||
const sess = { emb, cancelled: false };
|
const sess = { emb, cancelled: false };
|
||||||
rdpSessions.set(id, sess);
|
rdpSessions.set(id, sess);
|
||||||
@@ -453,7 +498,7 @@ function scheduleUpdateCheck() {
|
|||||||
updateScheduled = true;
|
updateScheduled = true;
|
||||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||||
}
|
}
|
||||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
app.on('window-all-closed', async () => { ssh.closeAll(); await vpn.downOnQuit(); app.quit(); });
|
||||||
|
|
||||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||||
if (process.env.MRTERM_SMOKE) {
|
if (process.env.MRTERM_SMOKE) {
|
||||||
|
|||||||
+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;
|
const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined;
|
||||||
await new Promise((res, rej) => {
|
await new Promise((res, rej) => {
|
||||||
conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => {
|
const pty = { term: 'xterm-256color', cols, rows };
|
||||||
|
const onStream = (err, stream) => {
|
||||||
if (err) return rej(err);
|
if (err) return rej(err);
|
||||||
sess.stream = stream;
|
sess.stream = stream;
|
||||||
stream.on('data', (d) => send('data', d.toString('utf8')));
|
stream.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
|
||||||
stream.on('close', () => conn.end());
|
stream.on('close', () => conn.end());
|
||||||
if (host.startupCommand) stream.write(host.startupCommand + '\n');
|
if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
|
||||||
res();
|
res();
|
||||||
});
|
};
|
||||||
|
// execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …)
|
||||||
|
if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream);
|
||||||
|
else conn.shell(pty, { env }, onStream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const DEFAULTS = {
|
|||||||
keys: [],
|
keys: [],
|
||||||
snippets: [],
|
snippets: [],
|
||||||
forwards: [],
|
forwards: [],
|
||||||
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+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 };
|
||||||
+226
-5
@@ -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>',
|
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>',
|
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>',
|
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'];
|
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||||
function colorFor(h) {
|
function colorFor(h) {
|
||||||
@@ -148,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).');
|
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();
|
applyAppTheme();
|
||||||
S.activeForwards = new Set(await call('fw:active'));
|
S.activeForwards = new Set(await call('fw:active'));
|
||||||
|
if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({}));
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +275,7 @@ function renderTree() {
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const page = $('#homeView');
|
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;
|
const scroll = page.scrollTop;
|
||||||
page.innerHTML = '';
|
page.innerHTML = '';
|
||||||
views[S.view](page);
|
views[S.view](page);
|
||||||
@@ -386,7 +389,7 @@ function hostMenu(hst) {
|
|||||||
return [
|
return [
|
||||||
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
|
{ 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 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('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(); } },
|
{ 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(); } },
|
||||||
@@ -533,6 +536,7 @@ function editHost(hst = {}) {
|
|||||||
row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField),
|
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('Label'), 'label', hst.label, { placeholder: T('My server') }),
|
||||||
field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
|
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' }),
|
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
|
||||||
heading(T('Authentication')),
|
heading(T('Authentication')),
|
||||||
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
|
field(T('Username'), 'username', hst.username, { placeholder: 'root' }),
|
||||||
@@ -548,7 +552,7 @@ function editHost(hst = {}) {
|
|||||||
address: v.address.trim(),
|
address: v.address.trim(),
|
||||||
port: v.port ? Number(v.port) : undefined,
|
port: v.port ? Number(v.port) : undefined,
|
||||||
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
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);
|
await call('vault:upsert', 'hosts', item);
|
||||||
reload();
|
reload();
|
||||||
@@ -729,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
|
// ============================================================ Known Hosts & Verlauf
|
||||||
function viewKnown(page) {
|
function viewKnown(page) {
|
||||||
page.append(toolbar(T('Search known hosts …')));
|
page.append(toolbar(T('Search known hosts …')));
|
||||||
@@ -1062,7 +1160,7 @@ function addTab(session) {
|
|||||||
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
|
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.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
|
||||||
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
|
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() }] : []),
|
...(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('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) },
|
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
|
||||||
@@ -1109,6 +1207,7 @@ class TerminalSession {
|
|||||||
this.el = h(`<div class="session">
|
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>
|
<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>
|
<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="snip" title="Snippets">${ICONS.code}Snippets</button>
|
||||||
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1166,6 +1265,7 @@ class TerminalSession {
|
|||||||
this.el.addEventListener('click', (e) => {
|
this.el.addEventListener('click', (e) => {
|
||||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||||
if (a === 'sftp') openSftp(this.host);
|
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 === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
|
||||||
if (a === 'snipnew') editSnippet();
|
if (a === 'snipnew') editSnippet();
|
||||||
if (a === 'find') this.toggleFind();
|
if (a === 'find') this.toggleFind();
|
||||||
@@ -1235,7 +1335,7 @@ class TerminalSession {
|
|||||||
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
|
||||||
this.doFit();
|
this.doFit();
|
||||||
try {
|
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.alive = true;
|
||||||
this.hideOverlay();
|
this.hideOverlay();
|
||||||
setTabState(this, 'on');
|
setTabState(this, 'on');
|
||||||
@@ -1568,6 +1668,125 @@ class SftpSession {
|
|||||||
}
|
}
|
||||||
function openSftp(host) { return new SftpSession(host); }
|
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
|
// ============================================================ Command Palette / Quick Connect
|
||||||
function openPalette() {
|
function openPalette() {
|
||||||
if ($('.palette')) return;
|
if ($('.palette')) return;
|
||||||
@@ -1586,6 +1805,7 @@ function openPalette() {
|
|||||||
hosts.slice(0, 12).forEach((x) => {
|
hosts.slice(0, 12).forEach((x) => {
|
||||||
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(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(--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) }));
|
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()]]
|
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
|
||||||
@@ -1653,6 +1873,7 @@ api.on('secret:request', async (req) => {
|
|||||||
api.replySecret(req.reqId, r ? r.v : null);
|
api.replySecret(req.reqId, r ? r.v : null);
|
||||||
});
|
});
|
||||||
api.on('fw:event', () => reload());
|
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('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>'; });
|
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>'; });
|
||||||
|
|
||||||
|
|||||||
@@ -34,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="sftp"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>SFTP</span></button>
|
||||||
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
<button class="nav" data-view="keys"><svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg><span>Keychain</span></button>
|
||||||
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
<button class="nav" data-view="forwards"><svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg><span>Port Forwarding</span></button>
|
||||||
|
<button class="nav" data-view="vpns"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12h6M12 9v6"/></svg><span>VPN</span></button>
|
||||||
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
|
||||||
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
|
||||||
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span data-i18n="History">History</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>
|
||||||
|
|||||||
@@ -360,3 +360,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||||
.lock-row b svg { width: 14px; height: 14px; }
|
.lock-row b svg { width: 14px; height: 14px; }
|
||||||
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
.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