Files
MrTerm/src/main/docker.js
T

251 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/;
const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; };
const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/';
const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64');
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
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'`;
}
// ---------- Compose-Stacks ----------
// Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls`
// gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort.
async composeCmd(s) {
if (s.compose) return s.compose;
const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose'];
for (const c of cands) {
const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`);
if (!r.code) return (s.compose = c);
}
throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).'));
}
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
async sh(s, script) {
let r = await execOn(s.conn, `sh -c ${q(script)}`);
if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) {
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`);
}
return r;
}
async stacks(id, stacksDir) {
const s = this.get(id);
const dir = absPath(stacksDir);
const compose = await this.composeCmd(s);
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; `
+ `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; `
+ `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); `
+ `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; `
+ `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`;
const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]);
const lines = r.out.split('\n').filter(Boolean);
let running = [];
try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ }
const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }]));
const list = lines.filter((l) => !l.startsWith('#')).map((l) => {
const [name, file, git] = l.split('\t');
return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' };
});
for (const [name, st] of status) {
if (list.some((x) => x.name === name) || !st.files[0]) continue;
list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status });
}
return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) };
}
runCompose(s, compose, args) {
const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
}
async readStack(id, file) {
const s = this.get(id);
const f = absPath(file);
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
const env = q(dirname(f) + '/.env');
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
const i = r.out.lastIndexOf('\n\0');
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
}
// { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning }
async saveStack(id, { stacksDir, name, file, compose, env }) {
const s = this.get(id);
let f;
if (file) f = absPath(file);
else {
if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`;
}
const d = dirname(f);
const envFile = `${d}/.env`;
const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && `
+ (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`);
const r = await this.sh(s, script);
if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name }));
if (r.code) {
if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d }));
throw new Error(lastLine(r.err) || 'Error');
}
const compose2 = await this.composeCmd(s);
const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`);
return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' };
}
// Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern)
async stackCommand(id, action, st, opts = {}) {
const s = this.get(id);
const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`;
const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `;
if (action === 'setup') {
const d = absPath(st.dir);
return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`;
}
if (action === 'clone') {
if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' }));
const parent = absPath(st.stacksDir).replace(/\/$/, '');
const d = `${parent}/${st.name}`;
const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : '';
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`;
return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && `
+ `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"`
+ (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : '');
}
const f = absPath(st.file);
const cd = `cd ${q(dirname(f))} && `;
const p = `${compose} -p ${q(st.name)} -f ${q(f)}`;
switch (action) {
case 'up': return `${cd}${p} up -d --remove-orphans`;
case 'stop': return `${cd}${p} stop`;
case 'restart': return `${cd}${p} restart`;
case 'down': return `${cd}${p} down`;
case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`;
case 'logs': return `${cd}${p} logs -f --tail 200`;
case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`;
case 'delete': {
if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted');
const d = absPath(st.dir);
return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`;
}
default: throw new Error('Invalid action');
}
}
close(id) {
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, execOn };