openvibers/openvibe.host Commits

Root runs a command as another user through setpriv, not runuser: no PAM session, so the 5-minute reconcile stops writing ~700 pam_unix lines a run (#147)

OP OpenVibers committed 21 days ago · 9012b0cd39 · 2 files, +29 −8

Changes

ddiff --git a/lib/executor.js b/lib/executor.js
iindex c6c46fc..3c9ef0a 100644
---- a/lib/executor.js
++++ b/lib/executor.js
@@@ -6,7 +6,7 @@
* Interface (all async):
* run(cmd, args, { as, privileged, cwd, timeoutMs, input }) -> { code, stdout, stderr }
* as run as this user (git/npm as the checkout owner);
-- * root uses runuser with the user's HOME, the same user runs directly, anyone else
++ * root uses setpriv (no PAM session) with the user's HOME, the same user runs directly, anyone else
* goes through `sudo -n -H -u`.
* privileged run as root (systemctl, nginx -t, reading 0600 env files); `sudo -n` unless root.
* readFile(path, { privileged }) -> string | null
@@@ -62,17 +62,29 @@ function userById(uid) {
const RUNUSER = ['/usr/sbin/runuser', '/sbin/runuser', '/usr/bin/runuser', '/bin/runuser']
.find((p) => { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } }) || 'runuser';
++// setpriv, not runuser, when root runs a command as another user: runuser opens a PAM session, and every git call
++// of the 5-minute reconcile wrote a pam_unix "session opened" and "session closed" pair (718 lines a run on
++// 2026-10-23, most of the journal). setpriv only changes uid, gid and groups. runuser stays for a user /etc/passwd
++// does not list (setpriv needs the numbers).
++const SETPRIV = ['/usr/bin/setpriv', '/bin/setpriv']
++ .find((p) => { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch { return false; } }) || null;
++
++/** The argv root runs `cmd args` with as user `as` (pw: its passwd entry or null), with that user's HOME and the PATH. */
++function asUserArgv(as, pw, cmd, args, { path = '/usr/local/bin:/usr/bin:/bin', setpriv = SETPRIV, runuser = RUNUSER } = {}) {
++ const home = pw ? pw.home : `/home/${as}`;
++ if (setpriv && pw) {
++ return [setpriv, [`--reuid=${pw.uid}`, `--regid=${pw.gid}`, '--init-groups', '--', 'env', `HOME=${home}`, `USER=${as}`, `LOGNAME=${as}`, `PATH=${path}`, cmd, ...args]];
++ }
++ return [runuser, ['-u', as, '--', 'env', `HOME=${home}`, `PATH=${path}`, cmd, ...args]];
++}
++
function createSystemExecutor({ env = process.env } = {}) {
const isRoot = () => typeof process.getuid === 'function' && process.getuid() === 0;
const me = () => { try { return os.userInfo().username; } catch { return env.USER || 'unknown'; } };
function wrap(cmd, args, { as, privileged } = {}) {
if (as && as !== me()) {
-- if (isRoot()) {
-- const pw = passwdEntry(as);
-- const home = pw ? pw.home : `/home/${as}`;
-- return [RUNUSER, ['-u', as, '--', 'env', `HOME=${home}`, `PATH=${env.PATH || '/usr/local/bin:/usr/bin:/bin'}`, cmd, ...args]];
-- }
++ if (isRoot()) return asUserArgv(as, passwdEntry(as), cmd, args, { path: env.PATH || '/usr/local/bin:/usr/bin:/bin' });
return ['sudo', ['-n', '-H', '-u', as, '--', cmd, ...args]];
}
if (privileged && !isRoot()) return ['sudo', ['-n', '--', cmd, ...args]];
@@@ -301,4 +313,4 @@ function createSystemExecutor({ env = process.env } = {}) {
};
}
--module.exports = { createSystemExecutor };
++module.exports = { createSystemExecutor, asUserArgv };
ddiff --git a/test/executor.test.js b/test/executor.test.js
iindex 3bada5a..cbd69d2 100644
---- a/test/executor.test.js
++++ b/test/executor.test.js
@@@ -7,7 +7,7 @@ const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
--const { createSystemExecutor } = require('../lib/executor');
++const { createSystemExecutor, asUserArgv } = require('../lib/executor');
const { test, runTests } = require('./helpers');
const exec = createSystemExecutor();
@@@ -40,6 +40,15 @@ runTests([
assert.deepStrictEqual([r.code, r.stdout], [0, 'hi']);
}),
++ test('root runs a command as another user through setpriv (no PAM session), runuser only without a passwd entry', async () => {
++ const pw = { name: 'ubuntu', uid: 1000, gid: 1000, home: '/home/ubuntu' };
++ assert.deepStrictEqual(asUserArgv('ubuntu', pw, 'git', ['-C', '/opt/x', 'fetch'], { path: '/usr/bin:/bin', setpriv: '/usr/bin/setpriv', runuser: '/usr/sbin/runuser' }),
++ ['/usr/bin/setpriv', ['--reuid=1000', '--regid=1000', '--init-groups', '--', 'env', 'HOME=/home/ubuntu', 'USER=ubuntu', 'LOGNAME=ubuntu', 'PATH=/usr/bin:/bin', 'git', '-C', '/opt/x', 'fetch']]);
++ assert.deepStrictEqual(asUserArgv('ghost', null, 'git', ['status'], { path: '/usr/bin', setpriv: '/usr/bin/setpriv', runuser: '/usr/sbin/runuser' }),
++ ['/usr/sbin/runuser', ['-u', 'ghost', '--', 'env', 'HOME=/home/ghost', 'PATH=/usr/bin', 'git', 'status']]);
++ assert.deepStrictEqual(asUserArgv('ubuntu', pw, 'git', [], { path: '/usr/bin', setpriv: null, runuser: '/usr/sbin/runuser' })[0], '/usr/sbin/runuser', 'no setpriv: runuser');
++ }),
++
test('the backup unit can find runuser: /usr/sbin is on its PATH (the 2026-09-24/25 nightly backups failed without it)', async () => {
const unit = require('fs').readFileSync(path.join(__dirname, '..', 'deploy', 'systemd', 'openvibe-backup.service'), 'utf8');
const m = /^Environment=PATH=(.+)$/m.exec(unit);