From c3a4d06310b29e74cd257cca35461dc5fbcfca0a Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 14 Oct 2026 21:35:59 +0200 Subject: [PATCH] sudo for firewall, network and Docker: try passwordless sudo first, then the saved host password, otherwise ask for the sudo password (up to 3 attempts, kept only for the open tab) instead of failing with "root privileges required" Co-Authored-By: Claude Opus 5.5 --- src/i18n.js | 5 ++++- src/main/docker.js | 19 +++++++++++++------ src/main/firewall.js | 7 ++++--- src/main/network.js | 7 ++++--- src/main/sudo.js | 25 +++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/main/sudo.js diff --git a/src/i18n.js b/src/i18n.js index 73cc011..b50e4a3 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -152,6 +152,10 @@ 'Command is missing.': 'Befehl fehlt.', 'No open terminal': 'Kein offenes Terminal', 'Upload files …': 'Dateien hochladen …', + 'Root privileges are required. Log in as root or enter the password of a user with sudo rights.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder gib das Passwort eines Benutzers mit sudo-Rechten ein.', + 'sudo password': 'sudo-Passwort', + 'sudo password for {user}': 'sudo-Passwort für {user}', + 'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}', // Support-Assistent 'Delete chat': 'Chat löschen', 'Delete chat?': 'Chat löschen?', @@ -579,7 +583,6 @@ 'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}', 'Firewall session not found': 'Firewall-Sitzung nicht gefunden', 'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}', - 'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.', 'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.', 'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.', 'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).', diff --git a/src/main/docker.js b/src/main/docker.js index 76df610..46bcf17 100644 --- a/src/main/docker.js +++ b/src/main/docker.js @@ -2,6 +2,7 @@ // 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 { acquireSudo } = require('./sudo'); const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' }; @@ -37,12 +38,18 @@ class DockerManager { 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 }); + this.sessions.set(id, { 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); } + // sudo-Passwort einmal pro Sitzung ermitteln (ohne Passwort, gespeichertes Host-Passwort oder Nachfrage) + async sudo(id, s) { + if (s.sudoPw === undefined) s.sudoPw = await acquireSudo(this.ssh, id, s.host, (c, stdin) => execOn(s.conn, c, stdin)); + return s.sudoPw != null; + } + get(id) { const s = this.sessions.get(id); if (!s) throw new Error(i18n.t('Docker session not found')); @@ -51,7 +58,7 @@ class DockerManager { run(s, args) { const sudo = s.sudo ? "sudo -S -p '' " : ''; - return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); + return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : ''); } async list(id) { @@ -62,7 +69,7 @@ class DockerManager { s.runtime = r.out.trim(); } let r = await this.run(s, `ps -a --format ${LIST_FMT}`); - if (r.code && denied(r.err) && !s.sudo) { + if (r.code && denied(r.err) && !s.sudo && await this.sudo(id, s)) { s.sudo = true; r = await this.run(s, `ps -a --format ${LIST_FMT}`); if (r.code) s.sudo = false; @@ -124,8 +131,8 @@ class DockerManager { // 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`); + if (r.code && /permission denied|operation not permitted/i.test(r.err) && await this.sudo(s.id, s)) { + r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.sudoPw}\n`); } return r; } @@ -158,7 +165,7 @@ class DockerManager { runCompose(s, compose, args) { const sudo = s.sudo ? "sudo -S -p '' " : ''; - return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); + return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : ''); } async readStack(id, file) { diff --git a/src/main/firewall.js b/src/main/firewall.js index 5ae0b62..79e469d 100644 --- a/src/main/firewall.js +++ b/src/main/firewall.js @@ -3,6 +3,7 @@ // Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen. const i18n = require('../i18n'); const { execOn } = require('./docker'); +const { acquireSudo } = require('./sudo'); const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; const RX = { @@ -58,7 +59,7 @@ class FirewallManager { // Befehl mit root-Rechten ausführen run(s, cmd) { if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`); - return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); + return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`); } async runOk(s, cmd) { const r = await this.run(s, cmd); @@ -75,8 +76,8 @@ class FirewallManager { const uid = (await execOn(conn, 'id -u')).out.trim(); if (uid !== '0') { s.sudo = true; - const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); - if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); + s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin)); + if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.')); } const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\''); s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean); diff --git a/src/main/network.js b/src/main/network.js index dbcc75e..bd94239 100644 --- a/src/main/network.js +++ b/src/main/network.js @@ -4,6 +4,7 @@ // nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann. const i18n = require('../i18n'); const { execOn } = require('./docker'); +const { acquireSudo } = require('./sudo'); const nc = require('./netconf'); const STATE = '/var/lib/mrterm-net'; @@ -127,7 +128,7 @@ class NetworkConfigManager { root(s, script, conn = s.conn) { const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`; if (!s.sudo) return execOn(conn, cmd); - return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`); + return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`); } async rootOk(s, script) { const r = await this.root(s, script); @@ -142,8 +143,8 @@ class NetworkConfigManager { this.watch(id, s); if ((await execOn(conn, 'id -u')).out.trim() !== '0') { s.sudo = true; - const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`); - if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.')); + s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin)); + if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.')); } return this.read(id); } diff --git a/src/main/sudo.js b/src/main/sudo.js new file mode 100644 index 0000000..5b70b23 --- /dev/null +++ b/src/main/sudo.js @@ -0,0 +1,25 @@ +// sudo für Firewall, Netzwerk und Docker: erst ohne Passwort (NOPASSWD), dann mit dem am Host gespeicherten Passwort, +// sonst den Nutzer fragen (bis zu 3 Versuche). Das Passwort gilt nur für die offene Sitzung. +// run(cmd, stdin) → Promise<{ code, out, err }> +// → Passwort ('' bei NOPASSWD) oder null, wenn sudo nicht möglich ist bzw. abgebrochen wurde +const i18n = require('../i18n'); + +async function acquireSudo(ssh, sessionId, host, run) { + if ((await run('sudo -n true')).code === 0) return ''; + const works = async (pw) => (await run("sudo -S -p '' -v", `${pw}\n`)).code === 0; + if (host.password && await works(host.password)) return host.password; + const user = `${host.username || ''}@${host.address}`.replace(/^@/, ''); + for (let i = 0; i < 3; i++) { + const pw = await ssh.askSecret(sessionId, { + title: i18n.t('sudo password'), + prompt: i ? i18n.t('Wrong password. sudo password for {user}', { user }) : i18n.t('sudo password for {user}', { user }), + echo: false, + host: host.label || host.address, + }); + if (pw == null) return null; + if (await works(pw)) return pw; + } + return null; +} + +module.exports = { acquireSudo };