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
Changes
@@@ -293,7 +293,8 @@ Moving them out of the database (operator):```bashcd /opt/openvibe.networksudo 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>=envsudo 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 (adifferent one only with `--allow-different`) and the running service already has it, plus the secretsNetwork 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).---@@@ -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 };@@@ -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.lassert.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.lassert.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;
@@ -293,7 +293,8 @@ Moving them out of the database (operator):
@@ -293,7 +293,8 @@ Moving them out of the database (operator):
```bash
```bash
cd /opt/openvibe.network
cd /opt/openvibe.network
sudo node scripts/secrets-out-of-db.js # dry run: names, env file and running service, per secret
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 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
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
# 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
@@ -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
`--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
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
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).
---
---
@@ -8,6 +8,11 @@
@@ -8,6 +8,11 @@
* sudo node scripts/secrets-out-of-db.js
* sudo node scripts/secrets-out-of-db.js
* dry run: every secret setting by name, whether network.db holds a value, its variable, whether
* 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
* 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]
* 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
* 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
* - 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');
@@ -36,6 +41,7 @@ const secrets = require('../server/secrets');
const { isSensitiveSettingKey } = require('../server/auth/owner-guard');
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]
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 --apply --backup <new file> [--allow-different]
sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
@@ -88,13 +94,68 @@ function classify(db) {
@@ -88,13 +94,68 @@ function classify(db) {
return out;
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 } = {}) {
async function main(argv, log = console.log, { readService = serviceEnv } = {}) {
let args;
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; }
if (args.help) { log(USAGE); return 0; }
const file = ops.dbPath(args);
const file = ops.dbPath(args);
if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
const envFile = args['env-file'] || '/etc/openvibe/network.env';
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.
// Root-only reads first, then drop to the database owner.
const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
@@ -166,6 +227,12 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})
@@ -166,6 +227,12 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})
} finally { db.close(); }
} 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 };
module.exports = { main, classify, parseEnvText };
@@ -128,27 +128,42 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -128,27 +128,42 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
// ── scripts/secrets-out-of-db.js ──
// ── scripts/secrets-out-of-db.js ──
const script = require('../scripts/secrets-out-of-db');
const script = require('../scripts/secrets-out-of-db');
const envFile = path.join(dir, 'network.env');
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 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 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 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 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)}…)`); };
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);
assert.strictEqual(await run([]), 0);
noValues();
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}`);
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.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_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, /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, /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/);
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
@@ -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('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_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('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('net.ipinfo_token'), '', 'unused secret blanked');
assert.strictEqual(dbVal('ses_secret_access_key'), '', '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');
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
@@ -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(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.db'), '--allow-different']), 0);
assert.strictEqual(dbVal('discord_bot_token'), '');
assert.strictEqual(dbVal('discord_bot_token'), '');
assert.strictEqual(dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
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();
noValues();
// An unreadable env file refuses --apply.
// An unreadable env file refuses --apply.
out.length = 0;
out.length = 0;