#!/usr/bin/env node // 03-copy-secrets.mjs — merge the OLD server's /opt/supabase/.env into the NEW // server's .env, keeping every secret byte-identical, then force the public-URL // / redirect vars to the netralax.de host. Run from the dev laptop. // // WHY Node (not bash/sed): secret values (JWT tokens, base64 keys, SMTP // passwords) contain characters that wreck shell/sed escaping. Here the values // only ever travel over SSH stdin/stdout and are handled as plain JS strings — // never interpolated into a shell command. Nothing is written to the laptop disk. // // MERGE SEMANTICS (loss-free): // - base = the NEW .env (fresh upstream structure + comments + new-only keys) // - for every key that exists on BOTH sides -> take the OLD value // - for every key that exists ONLY on OLD -> append it (this is how the // custom edge secrets VAPID_*/PUSH_FANOUT_SHARED_SECRET/LIVEKIT_API_* // survive — they are not in the fresh upstream .env) // - keys ONLY on NEW -> keep their fresh default // - finally, the OVERRIDES below are upserted (public host = .de) // // Usage: // node scripts/migrate/03-copy-secrets.mjs --check # show plan, change nothing // node scripts/migrate/03-copy-secrets.mjs # back up + apply on NEW // // Pre-req: SSH works to BOTH hosts (prox@OLD, debian@NEW) and NEW's .env exists // (bootstrap step done). OLD/NEW are read from scripts/migrate/config.sh. import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { createHash } from 'node:crypto'; const here = dirname(fileURLToPath(import.meta.url)); // --- read hosts from config.sh (single source of truth) -------------------- const cfg = readFileSync(join(here, 'config.sh'), 'utf8'); const cfgVal = (name) => { const m = cfg.match(new RegExp(`^export ${name}="([^"]*)"`, 'm')); if (!m) throw new Error(`could not find ${name} in config.sh`); return m[1]; }; const OLD_USER = cfgVal('OLD_USER'); const OLD_HOST = cfgVal('OLD_HOST'); const NEW_USER = cfgVal('NEW_USER'); const NEW_HOST = cfgVal('NEW_HOST'); const SUPABASE_DIR = cfgVal('SUPABASE_DIR'); const ENV_PATH = `${SUPABASE_DIR}/.env`; const OLD_SSH = `${OLD_USER}@${OLD_HOST}`; const NEW_SSH = `${NEW_USER}@${NEW_HOST}`; const SSH_OPTS = ['-o', 'StrictHostKeyChecking=accept-new']; if (NEW_HOST === '__NETRALAX_DE_SERVER_IP__' || !NEW_HOST) { console.error('NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first.'); process.exit(1); } // --- public-host overrides (forced to .de AFTER the merge) ----------------- // NOTE: SUPABASE_URL is deliberately NOT overridden — for the edge-runtime it // is the INTERNAL gateway URL and is handled by the compose env in §8, not here. const NEW_SITE = 'https://supabase.netralax.de'; const NEW_LIVEKIT = 'wss://livekit.netralax.de'; const OVERRIDES = { SITE_URL: NEW_SITE, API_EXTERNAL_URL: NEW_SITE, SUPABASE_PUBLIC_URL: NEW_SITE, LIVEKIT_URL: NEW_LIVEKIT, ADDITIONAL_REDIRECT_URLS: 'chatapp://auth/callback,netralax://auth/callback,' + 'https://supabase.netralax.de,https://supabase.netralax.cloud', }; // Continuity-critical keys: their value MUST end up identical to OLD. const CRITICAL = [ 'JWT_SECRET', 'ANON_KEY', 'SERVICE_ROLE_KEY', 'POSTGRES_PASSWORD', 'VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'PUSH_FANOUT_SHARED_SECRET', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET', ]; const check = process.argv.includes('--check'); // --- ssh helpers (values flow via stdio, never via argv) ------------------- function ssh(target, remoteCmd, input) { return execFileSync('ssh', [...SSH_OPTS, target, remoteCmd], { encoding: 'utf8', input: input ?? undefined, maxBuffer: 16 * 1024 * 1024, }); } const readOldEnv = () => ssh(OLD_SSH, `sudo cat ${ENV_PATH} 2>/dev/null || cat ${ENV_PATH}`); const readNewEnv = () => ssh(NEW_SSH, `sudo cat ${ENV_PATH}`); // --- env parsing (split on FIRST '='; keep comments/blank lines as raw) ----- const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/; function parse(text) { const map = new Map(); for (const line of text.split('\n')) { const m = line.match(KEY_RE); if (m) map.set(m[1], m[2]); } return map; } const sha = (s) => createHash('sha256').update(s ?? '').digest('hex').slice(0, 12); // --- main ------------------------------------------------------------------ console.log(`OLD: ${OLD_SSH} NEW: ${NEW_SSH} file: ${ENV_PATH}\n`); let oldText, newText; try { oldText = readOldEnv(); } catch (e) { console.error(`Failed to read OLD .env via ssh ${OLD_SSH}.\n${e.message}`); process.exit(1); } try { newText = readNewEnv(); } catch (e) { console.error(`Failed to read NEW .env via ssh ${NEW_SSH} (bootstrap done?).\n${e.message}`); process.exit(1); } const oldMap = parse(oldText); const newMap = parse(newText); const onlyOld = [...oldMap.keys()].filter((k) => !newMap.has(k)).sort(); const onlyNew = [...newMap.keys()].filter((k) => !oldMap.has(k)).sort(); const shared = [...oldMap.keys()].filter((k) => newMap.has(k)).sort(); console.log(`shared keys (value taken from OLD): ${shared.length}`); console.log(`OLD-only keys (appended — incl. custom edge secrets): ${onlyOld.length}`); onlyOld.forEach((k) => console.log(` + ${k}`)); console.log(`NEW-only keys (kept at fresh default): ${onlyNew.length}`); onlyNew.forEach((k) => console.log(` . ${k}`)); console.log(`\noverrides forced to the .de host:`); for (const [k, v] of Object.entries(OVERRIDES)) console.log(` ${k}=${v}`); // sanity: warn if a continuity-critical key is missing on OLD const missingCrit = CRITICAL.filter((k) => !oldMap.has(k)); if (missingCrit.length) { console.log(`\n⚠ NOTE: these critical keys are absent on OLD (verify they aren't named differently): ${missingCrit.join(', ')}`); } // --- build merged content (preserve NEW order/comments) -------------------- const used = new Set(); let lines = newText.split('\n').map((line) => { const m = line.match(KEY_RE); if (m && oldMap.has(m[1])) { used.add(m[1]); return `${m[1]}=${oldMap.get(m[1])}`; } return line; }); // append OLD-only keys if (onlyOld.length) { if (lines.length && lines[lines.length - 1] !== '') lines.push(''); lines.push('# --- merged from OLD server (keys not present in fresh upstream .env) ---'); for (const k of onlyOld) { lines.push(`${k}=${oldMap.get(k)}`); used.add(k); } } // upsert overrides for (const [k, v] of Object.entries(OVERRIDES)) { let hit = false; lines = lines.map((line) => { const m = line.match(KEY_RE); if (m && m[1] === k) { hit = true; return `${k}=${v}`; } return line; }); if (!hit) lines.push(`${k}=${v}`); } const merged = lines.join('\n'); if (check) { console.log('\n--check: nothing written. Re-run without --check to apply.'); process.exit(0); } // --- apply on NEW: backup, then write via `sudo tee` (content via stdin) ---- console.log('\nbacking up NEW .env and writing merged result...'); ssh(NEW_SSH, `sudo cp ${ENV_PATH} ${ENV_PATH}.bak.$(date +%s)`); ssh(NEW_SSH, `sudo tee ${ENV_PATH} > /dev/null`, merged.endsWith('\n') ? merged : merged + '\n'); // --- verify continuity: critical values identical OLD vs NEW --------------- const newAfter = parse(readNewEnv()); console.log('\nverifying continuity (OLD value == NEW value):'); let fail = 0; for (const k of CRITICAL) { if (!oldMap.has(k)) { console.log(` skip ${k} (not on OLD)`); continue; } const ok = oldMap.get(k) === newAfter.get(k); console.log(` ${ok ? 'OK ' : 'FAIL'} ${k} (sha ${sha(oldMap.get(k))} vs ${sha(newAfter.get(k))})`); if (!ok) fail++; } console.log('\noverrides now on NEW:'); for (const k of Object.keys(OVERRIDES)) console.log(` ${k}=${newAfter.get(k)}`); if (fail) { console.error(`\n✗ ${fail} critical key(s) did not match — DO NOT proceed. Restore from the .bak.* backup and investigate.`); process.exit(1); } console.log('\n✓ secrets merged; JWT_SECRET + VAPID + LiveKit keys are identical to OLD. Continue with runbook §6 (LiveKit/coturn) and §5 (data).');