251 lines
13 KiB
JavaScript
251 lines
13 KiB
JavaScript
// 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 };
|