[object Object]

← back to Secrets Manager

auto-save: 2026-07-09T18:11:36 (3 files) — cli.js remote-routes.json scripts/push-remote.js

a8646ea15fe992614a32b724357c27664ee5251d · 2026-07-09 18:11:37 -0700 · Steve Abrams

Files touched

Diff

commit a8646ea15fe992614a32b724357c27664ee5251d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 18:11:37 2026 -0700

    auto-save: 2026-07-09T18:11:36 (3 files) — cli.js remote-routes.json scripts/push-remote.js
---
 cli.js                 |  10 +++++
 remote-routes.json     |  11 +++++
 scripts/push-remote.js | 108 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 129 insertions(+)

diff --git a/cli.js b/cli.js
index 91f4d58..0c66aef 100755
--- a/cli.js
+++ b/cli.js
@@ -366,6 +366,16 @@ function cmdSync() {
     if (master[k]) total += fanOut(k, master[k]).length;
   }
   console.log(`sync: re-wrote ${total} destination entries from master`);
+  // Best-effort REMOTE fan-out: push any remote-routed secret to its server(s) + reload pm2.
+  // routes.json is local-file only; remote-routes.json + push-remote.js cover servers so
+  // prod rotation is hands-off. Never let a remote failure break the local sync.
+  try {
+    const pr = path.join(ROOT, 'scripts', 'push-remote.js');
+    if (fs.existsSync(pr) && fs.existsSync(path.join(ROOT, 'remote-routes.json'))) {
+      const r = spawnSync('node', [pr], { stdio: 'inherit' });
+      if (r.status !== 0) console.error('sync: remote push reported issues (see above) — local sync still OK');
+    }
+  } catch (e) { console.error(`sync: remote push skipped (${e.message}) — local sync still OK`); }
 }
 
 function cmdAudit() {
diff --git a/remote-routes.json b/remote-routes.json
new file mode 100644
index 0000000..7523e24
--- /dev/null
+++ b/remote-routes.json
@@ -0,0 +1,11 @@
+{
+  "_comment": "REMOTE destinations for secrets that live on a server (not just local .env files). routes.json fan-out is local-file only; this file drives push-remote.js, which SSHes the master value into a remote .env (in place, with .bak backup) and reloads the named pm2 process. Value travels over SSH via STDIN, never argv (so it never shows in the remote process list). Add a key here when a rotated secret must also land on a remote host.",
+  "CLOUDFLARE_API_TOKEN": [
+    {
+      "host": "root@45.61.58.125",
+      "path": "/root/Projects/all-designerwallcoverings/.env",
+      "pm2": "all-designerwallcoverings",
+      "note": "all.designerwallcoverings.com crawler CF-zone seed (Kamatera)"
+    }
+  ]
+}
diff --git a/scripts/push-remote.js b/scripts/push-remote.js
new file mode 100644
index 0000000..3603c6e
--- /dev/null
+++ b/scripts/push-remote.js
@@ -0,0 +1,108 @@
+#!/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 };

← 9c999a3 auto-save: 2026-07-09T17:11:25 (1 files) — routes.json  ·  back to Secrets Manager  ·  secrets: explicit remote-route push (push-remote.js) for CF 2c78874 →