5bc30c950c
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
8.0 KiB
JavaScript
190 lines
8.0 KiB
JavaScript
#!/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).');
|