← back to Secrets Manager

rotate-dw-admin-full.sh

182 lines

#!/usr/bin/env bash
# rotate-dw-admin-full.sh — complete the dw_admin rotation (the FORWARD path).
# Finishes what ROTATION-PRESTAGE.md half-did on 2026-06-03 (prod ALTER ran but the
# fan + restart never did). Run FROM Mac2, in a console, supervised.
#
# WHAT IT DOES
#   1. Mints a new 32-char password (never echoed, never in argv/history).
#   2. ALTER ROLE dw_admin on Mac2 local PG AND prod Kamatera (each gated by y/N).
#   3. Fans the new value to Mac2 consumers via the canonical secrets-manager CLI.
#   4. On Kamatera: rewrites the dw_admin DSN password in-place wherever it lives
#      (app .env / ecosystem / pm2 dump), backing each file up to *.pre-rot.bak,
#      then restarts ONLY the apps actually affected, in batches of <=6
#      (NEVER `pm2 restart all` — that OOM-kills the daemon: 186 apps).
#   5. Starts the 2 split-brain workers (am-recrawl, vendor-review-worker-enhanced)
#      which now carry the new pw, converging the fleet on one password.
#   6. Verifies: 0 dw_admin auth failures, pm2 health, secrets-cli check.
#
# SECRET HYGIENE (per Steve's standing rules)
#   The new pw exists only in: this process memory, the ALTER SQL piped via stdin,
#   the secrets registry, and the rewritten consumer envs. It is NEVER printed
#   (unless --show-once), never an argv, never in shell history.
#
# USAGE
#   ./rotate-dw-admin-full.sh --dry-run     # print the plan, change NOTHING
#   ./rotate-dw-admin-full.sh               # execute (confirms before each write)
#   ./rotate-dw-admin-full.sh --show-once   # also print the new pw ONCE at the end
#
# ROLLBACK
#   Every rewritten file has a sibling *.pre-rot.bak on Kamatera. To revert prod
#   dw_admin to the prior pw, re-run the 2026-06-03 recovery (extract from any
#   *.pre-rot.bak DSN, ALTER via stdin). Mac2 PG + registry similarly.

set -uo pipefail

DRY=0; SHOW=0
for a in "$@"; do case "$a" in
  --dry-run) DRY=1 ;; --show-once) SHOW=1 ;;
  *) echo "unknown arg: $a" >&2; exit 2 ;;
esac; done

KAM="root@kamatera"
SECRETS_CLI="$HOME/Projects/secrets-manager/cli.js"
REG_KEY="PG_DW_ADMIN_PASSWORD"
SPLIT_BRAIN=(am-recrawl vendor-review-worker-enhanced)

say(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
confirm(){ [ "$DRY" = 1 ] && { echo "[dry-run] would prompt: $1"; return 0; }
           read -r -p "$1 [y/N] " r; [ "$r" = y ] || { echo "aborted."; exit 1; }; }

command -v openssl >/dev/null || { echo "need openssl" >&2; exit 1; }
command -v node    >/dev/null || { echo "need node"    >&2; exit 1; }
[ -f "$SECRETS_CLI" ] || { echo "missing $SECRETS_CLI" >&2; exit 1; }

say "dw_admin FULL rotation $([ "$DRY" = 1 ] && echo '(DRY RUN — no changes)')"

# ---- 1. mint -------------------------------------------------------------
NEW_PG="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32)"
[ "${#NEW_PG}" -ge 20 ] || { echo "pw-gen failed" >&2; exit 1; }
echo "[1] minted a new 32-char password (hidden)"

# Emit the ALTER statement on stdout, dollar-quoted so any char is safe.
alter_sql(){ python3 - "$NEW_PG" <<'PY'
import sys
pw=sys.argv[1]; tag="$dwrot$"
assert tag not in pw, "tag collision — regenerate"
print(f"ALTER ROLE dw_admin PASSWORD {tag}{pw}{tag};")
PY
}

# ---- 2. Mac2 local PG ----------------------------------------------------
say "[2] ALTER ROLE dw_admin on Mac2 local PG"
if [ "$DRY" = 1 ]; then
  echo "[dry-run] alter_sql | psql -d postgres -f -   (superuser = \$USER; adjust -U if needed)"
else
  confirm "  apply to Mac2 local PG (psql -d postgres as \$USER)?"
  alter_sql | psql -d postgres -v ON_ERROR_STOP=1 -f - && echo "  Mac2: ALTER ok"
fi

# ---- 3. prod Kamatera PG -------------------------------------------------
say "[3] ALTER ROLE dw_admin on prod Kamatera"
if [ "$DRY" = 1 ]; then
  echo "[dry-run] alter_sql | ssh $KAM 'sudo -n -u postgres psql -d postgres -f -'"
else
  confirm "  apply to PROD Kamatera?"
  alter_sql | ssh "$KAM" "sudo -n -u postgres psql -d postgres -v ON_ERROR_STOP=1 -f -" && echo "  prod: ALTER ok"
fi

# ---- 4. fan to Mac2 consumers via secrets-manager ------------------------
say "[4] route new pw to Mac2 consumers (secrets-manager import-paste, stdin)"
if [ "$DRY" = 1 ]; then
  echo "[dry-run] printf '$REG_KEY=<new>' | node $SECRETS_CLI import-paste"
else
  printf '%s=%s\n' "$REG_KEY" "$NEW_PG" | node "$SECRETS_CLI" import-paste && echo "  registry + Mac2 .envs updated"
fi

# ---- 5. fan to Kamatera (in-place DSN rewrite) + targeted batched restart -
say "[5] fan to prod Kamatera (rewrite dw_admin DSNs in place, restart affected apps <=6/batch)"
if [ "$DRY" = 1 ]; then
  echo "[dry-run] push pw via stdin to remote rewriter; restart only apps whose cwd contains a rewritten file"
else
  confirm "  rewrite prod app envs + restart affected dw_admin apps (batched)?"
  printf '%s' "$NEW_PG" | ssh "$KAM" 'bash -s' <<'REMOTE'
set -uo pipefail
NEW_PG="$(cat)"            # pw arrives on stdin — never in argv
export NEW_PG
# 5a. rewrite every file carrying a dw_admin DSN; record which dirs changed.
CHANGED_DIRS="$(python3 - <<'PY'
import os,re,sys
new=os.environ["NEW_PG"]
roots=["/root/DW-Agents","/root/public-projects","/root/Projects","/etc/environment","/root/.pm2/dump.pm2"]
pat=re.compile(r'(postgres(?:ql)?://dw_admin:)[^@]+(@)')
files=set()
for r in roots:
    if os.path.isfile(r): files.add(r)
    elif os.path.isdir(r):
        for dp,_,fs in os.walk(r):
            if "node_modules" in dp or "/.git/" in dp: continue
            for f in fs:
                if f.endswith((".env",".local",".js",".cjs",".json")): files.add(os.path.join(dp,f))
dirs=set()
for f in files:
    try: s=open(f,encoding="utf-8",errors="replace").read()
    except Exception: continue
    if "dw_admin:" not in s: continue
    ns=pat.sub(r'\g<1>'+new+r'\g<2>', s)
    if ns!=s:
        try:
            open(f+".pre-rot.bak","w").write(s); open(f,"w").write(ns)
            dirs.add(os.path.dirname(f))
        except Exception as e:
            sys.stderr.write(f"skip {f}: {e}\n")
sys.stderr.write(f"rewrote files in {len(dirs)} dirs\n")
print("\n".join(sorted(dirs)))
PY
)"
# 5b. restart ONLY online apps whose cwd is under a changed dir, in batches of 6.
mapfile -t TARGETS < <(pm2 jlist | python3 -c '
import sys,json,os
dirs=[d for d in os.environ.get("CHG","").splitlines() if d]
for p in json.load(sys.stdin):
    e=p["pm2_env"]
    if e.get("status")!="online": continue
    cwd=e.get("pm_cwd","") or ""
    if any(cwd==d or cwd.startswith(d+"/") or d.startswith(cwd+"/") for d in dirs):
        print(p["name"])
' CHG="$CHANGED_DIRS")
echo "affected apps: ${#TARGETS[@]}"
batch=()
for a in "${TARGETS[@]}"; do
  batch+=("$a")
  if [ "${#batch[@]}" -ge 6 ]; then pm2 restart "${batch[@]}" --update-env >/dev/null 2>&1; sleep 4; batch=(); fi
done
[ "${#batch[@]}" -gt 0 ] && pm2 restart "${batch[@]}" --update-env >/dev/null 2>&1
# 5c. converge the split-brain workers (now carry the new pw) + persist.
pm2 start am-recrawl vendor-review-worker-enhanced >/dev/null 2>&1 || true
pm2 save >/dev/null 2>&1
echo "remote fan + targeted restart done"
REMOTE
fi

# ---- 6. verify -----------------------------------------------------------
say "[6] verify"
if [ "$DRY" = 1 ]; then
  echo "[dry-run] would: check 0 dw_admin auth failures, pm2 health, secrets-cli check"
else
  ssh "$KAM" 'bash -s' <<'REMOTE'
LOG=/var/log/postgresql/postgresql-14-main.log
sleep 12
f=$(awk -v c="$(date -u -d "12 seconds ago" "+%Y-%m-%d %H:%M:%S")" '/authentication failed for user "dw_admin"/ && $1" "$2 > c {n++} END{print n+0}' "$LOG" 2>/dev/null)
echo "  dw_admin auth failures (last 12s): $f  (want 0)"
pm2 jlist | python3 -c "import sys,json;from collections import Counter;print('  pm2:',dict(Counter(p['pm2_env'].get('status') for p in json.load(sys.stdin))))"
sudo -n -u postgres psql -d dw_unified -tAc "SELECT '  dw_unified rows: '||count(*) FROM products;" 2>&1 | head -1
REMOTE
  node "$SECRETS_CLI" check 2>&1 | grep -iE "dw_admin|PG_DW|FAIL|VALID" | head -5 || true
fi

say "DONE.  After verifying 0 failures + apps online:"
echo "  • flip CNCP prod flag (server.js:1718 status:'compromised' -> 'ok')"
echo "  • the box is now on a FRESH password (compromised-era pw retired)"
[ "$SHOW" = 1 ] && printf '  new dw_admin pw (record in your vault, then clear): %s\n' "$NEW_PG"
unset NEW_PG