Add Docker/Podman container management over SSH: container list with status, ports, CPU and memory; shell and live logs in terminal tabs; start, stop, restart and remove

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 22:11:26 +02:00
co-authored by Claude Opus 5.5
parent 039d89b9f7
commit 70a15eddbc
7 changed files with 322 additions and 7 deletions
+114
View File
@@ -0,0 +1,114 @@
// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec).
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
const i18n = require('../i18n');
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
function execOn(conn, cmd, stdin) {
return new Promise((resolve, reject) => {
conn.exec(cmd, (err, stream) => {
if (err) return reject(err);
let out = '', errOut = '';
stream.on('data', (d) => { out += d; });
stream.stderr.on('data', (d) => { errOut += d; });
stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut }));
stream.end(stdin ?? '');
});
});
}
class DockerManager {
constructor(ssh) {
this.ssh = ssh;
this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo }
}
async open(id, host, onClose) {
const { conn, jumps } = await this.ssh.connect(host, id);
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
conn.on('error', () => {});
return this.list(id);
}
get(id) {
const s = this.sessions.get(id);
if (!s) throw new Error(i18n.t('Docker session not found'));
return s;
}
run(s, args) {
const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
}
async list(id) {
const s = this.get(id);
if (!s.runtime) {
const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127');
if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.'));
s.runtime = r.out.trim();
}
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code && denied(r.err) && !s.sudo) {
s.sudo = true;
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
if (r.code) s.sudo = false;
}
if (r.code) {
if (denied(r.err) || /sudo/i.test(r.err)) {
throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' }));
}
throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
}
const containers = r.out.split('\n').filter(Boolean).map((l) => {
const [cid, name, image, state, status, ports] = l.split('\t');
return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports };
});
return { runtime: s.runtime, sudo: s.sudo, containers };
}
// CPU/RAM der laufenden Container (dauert ~2 s)
async stats(id) {
const s = this.get(id);
const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`);
if (r.code) return {};
return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => {
const [cid, cpu, mem] = l.split('\t');
return [cid.slice(0, 12), { cpu, mem }];
}));
}
async action(id, action, cid) {
const s = this.get(id);
if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action');
const r = await this.run(s, `${ACTIONS[action]} ${cid}`);
if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code }));
return true;
}
// Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort)
command(id, kind, cid) {
const s = this.get(id);
if (!SAFE_ID.test(cid)) throw new Error('Invalid container');
const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`;
if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`;
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
}
close(id) {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
try { s.conn.end(); } catch {}
s.jumps?.forEach((c) => { try { c.end(); } catch {} });
}
}
module.exports = { DockerManager };
+17 -1
View File
@@ -12,6 +12,7 @@ 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;
@@ -61,6 +62,7 @@ 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({
@@ -194,6 +196,18 @@ handle('vault:remove', async (col, id) => {
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));
@@ -288,12 +302,14 @@ 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));
+7 -3
View File
@@ -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);
});
}