← back to Secrets Manager
scripts/push-remote.js
109 lines
#!/usr/bin/env node
// push-remote.js — fan a rotated secret out to REMOTE servers (the piece routes.json can't do).
//
// routes.json fan-out is local-file only. This script reads remote-routes.json
// ({ KEY: [{host, path, pm2}] }), takes the current master value from
// ~/Projects/secrets-manager/.env, and for each remote destination:
// 1. backs up the remote .env (.env.bak.<ts>)
// 2. replaces-or-appends the KEY= line in place (chmod 600)
// 3. `pm2 reload <pm2> --update-env && pm2 save`
//
// SECURITY: the remote SCRIPT is passed as the ssh command argument (it's just code),
// and the secret VALUE is piped over stdin, read by `read -r line`. So the value never
// appears in the remote argv / process list, and stdin is NOT the script (no bash -s).
//
// Usage:
// node scripts/push-remote.js # push every key in remote-routes.json
// node scripts/push-remote.js --key CLOUDFLARE_API_TOKEN
// node scripts/push-remote.js --dry-run # print what would happen, touch nothing
//
// Called best-effort at the end of `cli.js sync` so prod rotation is hands-off.
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const MASTER_ENV = path.join(ROOT, '.env');
const REMOTE_ROUTES = path.join(ROOT, 'remote-routes.json');
const args = process.argv.slice(2);
const DRY = args.includes('--dry-run');
const onlyKey = (() => { const i = args.indexOf('--key'); return i >= 0 ? args[i + 1] : null; })();
function last4(v) { return v ? '…' + String(v).slice(-4) : '(empty)'; }
function loadEnv(p) {
const kv = {};
if (!fs.existsSync(p)) return kv;
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (m) kv[m[1]] = m[2];
}
return kv;
}
// POSIX-sh script run as the ssh COMMAND ARG. Reads the secret line from stdin (`read`),
// so the value is never in argv. envPath/pm2 are literals baked into the code (safe).
function remoteScript(envPath, pm2Name) {
const q = JSON.stringify; // safe single-token quoting for the literal path/name
return [
'read -r line || exit 9', // secret arrives here via stdin
'key=${line%%=*}',
`f=${q(envPath)}`,
'[ -f "$f" ] || { echo "MISSING_ENV $f"; exit 3; }',
'cur=$(grep "^${key}=" "$f" 2>/dev/null | head -1)',
// No-op when unchanged: no backup, no rewrite, no pm2 reload.
'if [ "$cur" = "$line" ]; then echo "UNCHANGED ${key}"; exit 0; fi',
'cp "$f" "$f.bak.$(date +%s)"',
'grep -v "^${key}=" "$f" > "$f.tmp" 2>/dev/null || true',
'printf "%s\\n" "$line" >> "$f.tmp"',
'mv "$f.tmp" "$f"',
'chmod 600 "$f"',
pm2Name
? `pm2 reload ${q(pm2Name)} --update-env >/dev/null 2>&1 && pm2 save >/dev/null 2>&1 || echo "PM2_WARN ${pm2Name}"`
: ':',
`echo "OK ${'${key}'} -> ${envPath}${pm2Name ? ` (pm2 ${pm2Name} reloaded)` : ''}"`,
].join('; ');
}
function main() {
if (!fs.existsSync(REMOTE_ROUTES)) return; // no remote routes → silent no-op
const routes = JSON.parse(fs.readFileSync(REMOTE_ROUTES, 'utf8'));
const master = loadEnv(MASTER_ENV);
const keys = Object.keys(routes).filter(k => k !== '_comment' && (!onlyKey || k === onlyKey));
let pushed = 0, skipped = 0, failed = 0;
for (const key of keys) {
const val = master[key];
if (!val) { console.error(`push-remote: ${key} not in master — skipped`); skipped++; continue; }
for (const dest of routes[key]) {
const { host, path: envPath, pm2: pm2Name } = dest;
if (!host || !envPath) { console.error(`push-remote: ${key} bad dest (need host+path)`); failed++; continue; }
if (DRY) {
console.log(`[dry-run] ${key} ${last4(val)} -> ${host}:${envPath}${pm2Name ? ` (reload ${pm2Name})` : ''}`);
continue;
}
const r = spawnSync('ssh', ['-o', 'ConnectTimeout=15', '-o', 'BatchMode=yes', host, remoteScript(envPath, pm2Name)], {
input: `${key}=${val}\n`, // ONLY the secret line goes over stdin
encoding: 'utf8',
});
const out = ((r.stdout || '') + (r.stderr || '')).trim();
if (r.status === 0 && /^(OK|UNCHANGED) /m.test(out)) {
const line = out.split('\n').find(l => /^(OK|UNCHANGED) /.test(l)) || '';
console.log(`push-remote: ${key} ${last4(val)} -> ${host} ✓ ${line}`);
pushed++;
} else {
console.error(`push-remote: ${key} -> ${host} FAILED (status ${r.status}) ${out || r.error || ''}`);
failed++;
}
}
}
if (!DRY) console.log(`push-remote: ${pushed} pushed, ${skipped} skipped, ${failed} failed`);
}
// Only run when invoked directly (`node scripts/push-remote.js`), never on require().
if (require.main === module) main();
module.exports = { main };