← 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 };