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)
Changes
@@@ -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 };@@@ -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);
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* Interface (all async):
* Interface (all async):
* run(cmd, args, { as, privileged, cwd, timeoutMs, input }) -> { code, stdout, stderr }
* run(cmd, args, { as, privileged, cwd, timeoutMs, input }) -> { code, stdout, stderr }
* as run as this user (git/npm as the checkout owner);
* 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`.
* goes through `sudo -n -H -u`.
* privileged run as root (systemctl, nginx -t, reading 0600 env files); `sudo -n` unless root.
* privileged run as root (systemctl, nginx -t, reading 0600 env files); `sudo -n` unless root.
* readFile(path, { privileged }) -> string | null
* readFile(path, { privileged }) -> string | null
@@ -62,17 +62,29 @@ function userById(uid) {
@@ -62,17 +62,29 @@ function userById(uid) {
const RUNUSER = ['/usr/sbin/runuser', '/sbin/runuser', '/usr/bin/runuser', '/bin/runuser']
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';
.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 } = {}) {
function createSystemExecutor({ env = process.env } = {}) {
const isRoot = () => typeof process.getuid === 'function' && process.getuid() === 0;
const isRoot = () => typeof process.getuid === 'function' && process.getuid() === 0;
const me = () => { try { return os.userInfo().username; } catch { return env.USER || 'unknown'; } };
const me = () => { try { return os.userInfo().username; } catch { return env.USER || 'unknown'; } };
function wrap(cmd, args, { as, privileged } = {}) {
function wrap(cmd, args, { as, privileged } = {}) {
if (as && as !== me()) {
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]];
return ['sudo', ['-n', '-H', '-u', as, '--', cmd, ...args]];
}
}
if (privileged && !isRoot()) return ['sudo', ['-n', '--', cmd, ...args]];
if (privileged && !isRoot()) return ['sudo', ['-n', '--', cmd, ...args]];
@@ -301,4 +313,4 @@ function createSystemExecutor({ env = process.env } = {}) {
@@ -301,4 +313,4 @@ function createSystemExecutor({ env = process.env } = {}) {
};
};
}
}
-module.exports = { createSystemExecutor };
+module.exports = { createSystemExecutor, asUserArgv };
@@ -7,7 +7,7 @@ const fs = require('fs');
@@ -7,7 +7,7 @@ const fs = require('fs');
const http = require('http');
const http = require('http');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const { createSystemExecutor } = require('../lib/executor');
+const { createSystemExecutor, asUserArgv } = require('../lib/executor');
const { test, runTests } = require('./helpers');
const { test, runTests } = require('./helpers');
const exec = createSystemExecutor();
const exec = createSystemExecutor();
@@ -40,6 +40,15 @@ runTests([
@@ -40,6 +40,15 @@ runTests([
assert.deepStrictEqual([r.code, r.stdout], [0, 'hi']);
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 () => {
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 unit = require('fs').readFileSync(path.join(__dirname, '..', 'deploy', 'systemd', 'openvibe-backup.service'), 'utf8');
const m = /^Environment=PATH=(.+)$/m.exec(unit);
const m = /^Environment=PATH=(.+)$/m.exec(unit);