Files
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
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>
2026-06-02 19:39:04 +02:00

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).');