openvibers/openvibe.network Commits

secrets-out-of-db.js --copy-to-env [--apply]: moves each provider secret the database holds and the env file does not set (an empty VAR= counts as unset) into /etc/openvibe/network.env as VAR=value, after keeping the previous file as network.env.bak-<time> with its mode, so the operator never has to read a value out of sqlite. The database is read by a child process running as its owner and the values reach the file through a pipe, never a terminal; a value that would need quoting is left to add by hand, and a variable the file already sets is left as it is (the dry run says whether it matches). README: the full procedure

OP OpenVibers committed 24 Sept 2026 · 11e716d04e · 3 files, +101 −16


Changes

ddiff --git a/README.md b/README.md
iindex 6f9b1ed7..0ab18eb2 100644
---- a/README.md
++++ b/README.md
@@@ -293,7 +293,8 @@ Moving them out of the database (operator):
```bash
cd /opt/openvibe.network
sudo node scripts/secrets-out-of-db.js # dry run: names, env file and running service, per secret
--# copy each value the dry run lists into /etc/openvibe/network.env (VAR=value), then
++sudo node scripts/secrets-out-of-db.js --copy-to-env # dry run of the copy into /etc/openvibe/network.env
++sudo node scripts/secrets-out-of-db.js --copy-to-env --apply # appends VAR=value lines (backup: network.env.bak-<time>)
sudo systemctl restart openvibe-network # the boot line shows <key>=env
sudo node scripts/secrets-out-of-db.js --apply --backup data/network-pre-secrets-$(date +%F).db
# rollback: sudo node scripts/secrets-out-of-db.js --restore-from data/network-pre-secrets-<date>.db --apply
@@@ -302,7 +303,9 @@ sudo node scripts/secrets-out-of-db.js --apply --backup data/network-pre-secrets
`--apply` blanks a database copy only when the env file sets the variable to the same value (a
different one only with `--allow-different`) and the running service already has it, plus the secrets
Network never reads; everything else is kept and the dry run says why. The script reads the env file
--and the service's environment as root, then becomes the database owner. It never prints a value.
++and the service's environment as root, then becomes the database owner. It never prints a value:
++`--copy-to-env` reads the database in a child process running as its owner and hands the values to the
++env file through a pipe (a value that would need quoting is left for the operator to add by hand).
---
ddiff --git a/scripts/secrets-out-of-db.js b/scripts/secrets-out-of-db.js
iindex 4383a818..e697be36 100644
---- a/scripts/secrets-out-of-db.js
++++ b/scripts/secrets-out-of-db.js
@@@ -8,6 +8,11 @@
* sudo node scripts/secrets-out-of-db.js
* dry run: every secret setting by name, whether network.db holds a value, its variable, whether
* the env file sets it (and to the same value), and whether the running service has it
++ * sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
++ * for each secret the database holds and the env file does not set, append VAR=value to the env
++ * file (after copying it to <env file>.bak-<time>, same mode), so the values move without anyone
++ * seeing them: the database is read by a child process running as its owner and the values go to
++ * the file through a pipe, never to the terminal. Dry run without --apply. Restart the service after.
* sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
* takes an online backup (0600, verified), then blanks the database copy of
* - each secret whose variable the env file sets to the same value (a different value only with
@@@ -36,6 +41,7 @@ const secrets = require('../server/secrets');
const { isSensitiveSettingKey } = require('../server/auth/owner-guard');
const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--db <path>] [--unit <name> | --no-service-check]
++ sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
@@@ -88,13 +94,68 @@ function classify(db) {
return out;
}
++// A value systemd's EnvironmentFile and util.parseEnv read back unchanged without quoting.
++const PLAIN_VALUE = /^[A-Za-z0-9._\-+/=:~]+$/;
++
++/** Child side of --copy-to-env: the provider values as JSON on stdout (a pipe to the parent only). */
++function emitValues(file) {
++ const db = new Database(file, { readonly: true, fileMustExist: true });
++ try {
++ const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');
++ const out = {};
++ for (const s of secrets.SECRETS) { const r = get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }
++ process.stdout.write(JSON.stringify(out));
++ } finally { db.close(); }
++}
++
++/** The database's provider values, read by a child process running as the database owner. */
++function readValuesAsOwner(file) {
++ const st = fs.statSync(file);
++ const asOwner = typeof process.getuid === 'function' && process.getuid() === 0 && st.uid !== 0 ? { uid: st.uid, gid: st.gid } : {};
++ const res = require('child_process').spawnSync(process.execPath, [__filename, '--db', file], {
++ ...asOwner, env: { PATH: process.env.PATH, OV_SECRETS_EMIT: '1' }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000, maxBuffer: 1 << 20,
++ });
++ if (res.status !== 0) throw new Error(`reading the database failed: ${String(res.stderr || '').trim().split('\n').pop()}`);
++ return JSON.parse(String(res.stdout));
++}
++
++function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {
++ if (envf.error) { log(`error: the env file ${envFile} is unreadable (${envf.error}); run with sudo`); return 2; }
++ const values = readValuesAsOwner(file);
++ const add = [];
++ for (const s of secrets.SECRETS) {
++ const v = values[s.env];
++ if (!v) { log(` ${s.env}: the database holds no ${s.key}; nothing to copy`); continue; }
++ const have = envf.vars[s.env] && String(envf.vars[s.env]).trim();
++ if (have) { log(` ${s.env}: already in the env file (${digest(have) === digest(v) ? 'same value' : 'DIFFERENT value, left as it is'})`); continue; }
++ if (!PLAIN_VALUE.test(v)) { log(` ${s.env}: the value has characters that would need quoting; add it by hand`); continue; }
++ add.push([s.env, v]);
++ log(` ${s.env}: ${apply ? 'appended' : 'would be appended'} (from ${s.key})`);
++ }
++ if (!apply) { log(`dry run: ${add.length} variable(s) to append. Re-run with --copy-to-env --apply.`); return 0; }
++ if (!add.length) { log('nothing to append.'); return 0; }
++ const stamp = now.toISOString().replace(/[:.]/g, '-');
++ const bak = `${envFile}.bak-${stamp}`;
++ fs.copyFileSync(envFile, bak, fs.constants.COPYFILE_EXCL);
++ fs.chmodSync(bak, fs.statSync(envFile).mode & 0o777);
++ const text = fs.readFileSync(envFile, 'utf8');
++ fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from network.db site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);
++ log(`env file ${envFile}: appended ${add.map(([k]) => k).join(', ')} (previous version: ${bak}). Restart openvibe-network, then run --apply --backup <new file>.`);
++ return 0;
++}
++
async function main(argv, log = console.log, { readService = serviceEnv } = {}) {
let args;
-- try { args = ops.parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check'], values: ['db', 'backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ try { args = ops.parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['db', 'backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
if (args.help) { log(USAGE); return 0; }
const file = ops.dbPath(args);
if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
const envFile = args['env-file'] || '/etc/openvibe/network.env';
++ if (args['copy-to-env']) {
++ if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply, --db and --env-file'); return 2; }
++ log(`database ${file}\nenv file ${envFile}`);
++ return copyToEnv(file, envFile, readEnvFile(envFile), !!args.apply, log);
++ }
// Root-only reads first, then drop to the database owner.
const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
@@@ -166,6 +227,12 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})
} finally { db.close(); }
}
--if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
++if (require.main === module) {
++ // The --copy-to-env child: values go to the parent through a pipe, never to a terminal.
++ if (process.env.OV_SECRETS_EMIT === '1' && !process.stdout.isTTY) {
++ try { emitValues(ops.dbPath(ops.parseArgs(process.argv.slice(2), { values: ['db'] }))); process.exit(0); } catch (err) { console.error(err.message); process.exit(1); }
++ }
++ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
++}
module.exports = { main, classify, parseEnvText };
ddiff --git a/test/provider-secrets.test.js b/test/provider-secrets.test.js
iindex 50dcd9f8..f425e792 100644
---- a/test/provider-secrets.test.js
++++ b/test/provider-secrets.test.js
@@@ -128,27 +128,42 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
// ── scripts/secrets-out-of-db.js ──
const script = require('../scripts/secrets-out-of-db');
const envFile = path.join(dir, 'network.env');
-- // The lead copied three values into the env file: two equal to the database, one different.
-- fs.writeFileSync(envFile, [
-- 'NODE_ENV=production',
-- 'RESEND_API_KEY=re_db_key_111',
-- 'DISCORD_BOT_TOKEN=db.bot.token.333',
-- 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret',
-- '',
-- ].join('\n'), { mode: 0o600 });
++ // The env file before the move: one rotated value already there, one variable present but empty.
++ fs.writeFileSync(envFile, ['NODE_ENV=production', 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret', 'RESEND_WEBHOOK_SECRET=', ''].join('\n'), { mode: 0o600 });
const out = [];
const running = { vars: { RESEND_API_KEY: 're_db_key_111', DISCORD_BOT_TOKEN: 'db.bot.token.333', DISCORD_OAUTH_CLIENT_SECRET: 'rotated-secret' }, pid: 4242 };
const run = (argv, service = running) => script.main(['--db', dbPath, '--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service });
const values = ['re_db_key_111', 'whsec_db_222', 'db.bot.token.333', 'db-oauth-secret-444', 'ipinfo-555', 'ses-666', 'unknown-777', 'rotated-secret'];
const noValues = () => { const text = out.join('\n'); for (const v of values) assert.ok(!text.includes(v), `the script never prints a value (${v.slice(0, 3)}…)`); };
++ // --copy-to-env: the values move from the database into the env file without being shown.
++ assert.strictEqual(await run(['--copy-to-env']), 0);
++ let text = out.join('\n');
++ assert.match(text, /RESEND_API_KEY: would be appended/);
++ assert.match(text, /RESEND_WEBHOOK_SECRET: would be appended/, 'an empty variable counts as unset');
++ assert.match(text, /DISCORD_BOT_TOKEN: would be appended/);
++ assert.match(text, /DISCORD_OAUTH_CLIENT_SECRET: already in the env file \(DIFFERENT value, left as it is\)/);
++ assert.match(text, /VAPID_PRIVATE_KEY: the database holds no vapid_private_key/);
++ assert.ok(!fs.readFileSync(envFile, 'utf8').includes('RESEND_API_KEY'), 'the dry run writes nothing');
++ assert.strictEqual(await run(['--copy-to-env', '--apply']), 0);
++ const envText = fs.readFileSync(envFile, 'utf8');
++ assert.ok(envText.includes('\nRESEND_API_KEY=re_db_key_111\n') && envText.includes('\nDISCORD_BOT_TOKEN=db.bot.token.333\n') && envText.includes('\nRESEND_WEBHOOK_SECRET=whsec_db_222\n'), 'appended');
++ assert.ok(envText.startsWith('NODE_ENV=production\nDISCORD_OAUTH_CLIENT_SECRET=rotated-secret\n'), 'the existing lines are untouched');
++ assert.strictEqual(fs.statSync(envFile).mode & 0o777, 0o600, 'the env file keeps its mode');
++ const baks = fs.readdirSync(dir).filter(f => f.startsWith('network.env.bak-'));
++ assert.strictEqual(baks.length, 1, 'the previous env file is kept');
++ assert.ok(!fs.readFileSync(path.join(dir, baks[0]), 'utf8').includes('RESEND_API_KEY'));
++ assert.strictEqual(require('util').parseEnv(envText).RESEND_WEBHOOK_SECRET, 'whsec_db_222', 'the later assignment wins');
++ noValues();
++
++ out.length = 0;
assert.strictEqual(await run([]), 0);
noValues();
-- let text = out.join('\n');
++ text = out.join('\n');
for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'net.ipinfo_token', 'ses_secret_access_key', 'some_new_api_key']) assert.ok(text.includes(` ${k}\n`), `lists ${k}`);
assert.ok(!text.includes(' discord_oauth_client_id\n') && !text.includes(' vapid_public_key\n'), 'non-secrets are not listed');
assert.match(text, /resend_api_key\n.*env file: set, same value · service: has it\n\s+--apply: BLANK/);
-- assert.match(text, /resend_webhook_secret\n.*env file: not set.*\n\s+--apply: keep: put RESEND_WEBHOOK_SECRET in the env file first/);
++ assert.match(text, /resend_webhook_secret\n.*env file: set, same value · service: not set \(restart it\)\n\s+--apply: keep: the running service does not have this RESEND_WEBHOOK_SECRET yet/);
assert.match(text, /discord_oauth_client_secret\n.*DIFFERENT value.*\n\s+--apply: keep: the env file value differs/);
assert.match(text, /net\.ipinfo_token\n.*\n\s+--apply: BLANK \(Network never reads it/);
assert.match(text, /some_new_api_key\n.*\n\s+--apply: keep: not classified/);
@@@ -163,7 +178,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
assert.strictEqual(dbVal('resend_api_key'), '', 'blanked: env file and running service have it');
assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');
assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');
-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: not in the env file');
++ assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');
assert.strictEqual(dbVal('net.ipinfo_token'), '', 'unused secret blanked');
assert.strictEqual(dbVal('ses_secret_access_key'), '', 'unused secret blanked');
assert.strictEqual(dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');
@@@ -174,7 +189,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.db'), '--allow-different']), 0);
assert.strictEqual(dbVal('discord_bot_token'), '');
assert.strictEqual(dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222');
++ assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');
noValues();
// An unreadable env file refuses --apply.
out.length = 0;