A backup is data/backups/selfphoneng-YYYYMMDD-HHMM.tar.gz (mode 0600): pg_dump, voicemail, custom sounds, music on hold, recordings if asked, and a manifest. No generated Asterisk config: it holds plaintext passwords and comes back from the database. The api makes one a day after backup.time (once-a-minute check, one try a day) and the dashboard warns when today's is missing. Settings -> Backups sets the schedule, lists them for download and has Back up now. scripts/restore.sh <file> refuses a backup made under another APP_SECRET (the manifest carries a fingerprint, not the secret), asks for yes, then replaces the database and the folders the backup has. Tested on two throwaway servers (docker:dind running the real images): back up on one, restore on the other. That found the api image crashing on start since M1: tsc doesn't copy auth/common-passwords.txt into dist. The Dockerfile copies it now and CI loads the app inside the image. Task: M11-T3
247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M11-T3: back up one server and restore it on another. Each "server" is a privileged docker:dind
|
|
// container running the real compose stack (images loaded from this host, no build): A gets an
|
|
// admin with a new password, user Dan with extension 201, a trunk with a password, voicemail, a
|
|
// custom sound, music on hold and a recording; "Back up now" makes the file (mode 0600, no
|
|
// generated config, no recordings). B is a fresh install with the same .env; restore.sh refuses
|
|
// a different APP_SECRET and a "no", then restores: the admin signs in with A's password, 201 and
|
|
// the trunk are back, the trunk password decrypts into Asterisk's config, the files match.
|
|
// Needs the images built (docker compose build). KEEP=1 leaves the two servers running.
|
|
// Usage: node scripts/e2e/m11-t3.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const REPO = fileURLToPath(new URL('../..', import.meta.url));
|
|
const IMAGES = ['selfphoneng-asterisk:latest', 'selfphoneng-api:latest', 'postgres:18-alpine'];
|
|
const [A, B] = ['sp-vm-a', 'sp-vm-b'];
|
|
const EMAIL = 'admin@example.com';
|
|
const [INITIAL_PW, ADMIN_PW, TRUNK_PW] = [
|
|
'initial-admin-pw',
|
|
'restored-admin-pw-42',
|
|
'TrunkPass-42',
|
|
];
|
|
/** Files on A: these are restored, the recording is not (backup.includeRecordings is off). */
|
|
const FILES = [
|
|
'voicemail/default/201/INBOX/msg0000.txt',
|
|
'voicemail/default/201/INBOX/msg0000.wav',
|
|
'sounds-custom/welcome.wav',
|
|
'moh/default/song.wav',
|
|
];
|
|
const RECORDING = 'recordings/2026/10/19/call.wav';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const docker = (args, input) => {
|
|
const r = spawnSync('docker', args, { encoding: 'utf8', input, maxBuffer: 64 << 20 });
|
|
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
|
|
};
|
|
/** A shell command on a server, in its install folder /srv/sp. */
|
|
const sh = (vm, cmd, input) =>
|
|
docker(['exec', '-i', vm, 'sh', '-c', `cd /srv/sp && ${cmd}`], input);
|
|
const must = (vm, cmd, input) => {
|
|
const r = sh(vm, cmd, input);
|
|
assert.ok(r.ok, `${vm}: ${cmd}\n${r.out}`);
|
|
return r.out;
|
|
};
|
|
async function until(what, fn, ms = 180000) {
|
|
for (const end = Date.now() + ms; Date.now() < end; await sleep(1000)) if (await fn()) return;
|
|
throw new Error(`timed out: ${what}`);
|
|
}
|
|
|
|
/** Runs `body` with node from the api image, next to the server's api; returns what it returns. */
|
|
function apiOn(vm, body) {
|
|
const js = `
|
|
let cookie = '';
|
|
const call = async (method, path, b) => {
|
|
const r = await fetch('http://127.0.0.1:8080' + path, { method, body: b && JSON.stringify(b),
|
|
headers: { ...(b && { 'content-type': 'application/json' }), 'x-requested-with': 'selfphoneng', cookie } });
|
|
const set = r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
|
|
if (set) cookie = set;
|
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
let json = null;
|
|
try { json = JSON.parse(buf.toString()); } catch {}
|
|
return { status: r.status, json, size: buf.length };
|
|
};
|
|
const ok = async (method, path, b) => {
|
|
const r = await call(method, path, b);
|
|
if (r.status >= 300) throw new Error(method + ' ' + path + ': ' + r.status + ' ' + JSON.stringify(r.json));
|
|
return r.json;
|
|
};
|
|
console.log(JSON.stringify(await (async () => { ${body} })()));`;
|
|
const r = docker(
|
|
[
|
|
'exec',
|
|
'-i',
|
|
vm,
|
|
'docker',
|
|
'run',
|
|
'--rm',
|
|
'-i',
|
|
'--network',
|
|
'host',
|
|
'--entrypoint',
|
|
'node',
|
|
].concat([IMAGES[1], '--input-type=module', '-']),
|
|
js,
|
|
);
|
|
assert.ok(r.ok, `${vm} api script failed:\n${r.out}`);
|
|
return JSON.parse(r.out.split('\n').at(-1));
|
|
}
|
|
const signIn = (pw) =>
|
|
`await ok('POST', '/api/auth/login', { email: '${EMAIL}', password: '${pw}' });`;
|
|
|
|
/** A fresh server: docker:dind, the images, compose file, restore.sh, .env, data folders. */
|
|
async function server(vm, images, env) {
|
|
docker(['rm', '-f', vm]);
|
|
const run = docker([
|
|
'run',
|
|
'-d',
|
|
'--privileged',
|
|
'--name',
|
|
vm,
|
|
'-v',
|
|
`${images}:/images.tar:ro`,
|
|
'docker:28-dind',
|
|
]);
|
|
assert.ok(run.ok, run.out);
|
|
await until(`${vm} dockerd`, () => docker(['exec', vm, 'docker', 'info']).ok, 60000);
|
|
const load = docker(['exec', vm, 'docker', 'load', '-i', '/images.tar']);
|
|
assert.ok(load.ok, load.out);
|
|
docker(['exec', vm, 'mkdir', '-p', '/srv/sp/scripts']);
|
|
docker(['cp', join(REPO, 'docker-compose.yml'), `${vm}:/srv/sp/docker-compose.yml`]);
|
|
docker(['cp', join(REPO, 'scripts', 'restore.sh'), `${vm}:/srv/sp/scripts/restore.sh`]);
|
|
must(vm, 'cat > .env', env);
|
|
// As install.sh does: the app user (1000) owns what asterisk and the api share.
|
|
const shared =
|
|
'asterisk-generated voicemail recordings sounds-custom moh astdb asterisk-logs tls backups';
|
|
must(
|
|
vm,
|
|
`mkdir -p data/postgres && cd data && mkdir -p ${shared} && chown -R 1000:1000 ${shared}`,
|
|
);
|
|
must(vm, 'docker compose up -d 2>&1');
|
|
await until(`${vm} api up`, () => sh(vm, 'wget -qO- http://127.0.0.1:8080/healthz').ok);
|
|
}
|
|
const sums = (vm, files) => must(vm, `cd data && sha256sum ${files.join(' ')}`);
|
|
|
|
const work = mkdtempSync(join(tmpdir(), 'sp-m11t3-'));
|
|
const images = join(work, 'images.tar');
|
|
try {
|
|
for (const img of IMAGES)
|
|
assert.ok(docker(['image', 'inspect', img]).ok, `missing image ${img} (docker compose build)`);
|
|
console.log('saving images…');
|
|
const saved = docker(['save', '-o', images, ...IMAGES]);
|
|
assert.ok(saved.ok, saved.out);
|
|
const env = [
|
|
'PUBLIC_HOSTNAME=pbx.test',
|
|
`POSTGRES_PASSWORD=${randomBytes(12).toString('hex')}`,
|
|
`AMI_SECRET=${randomBytes(12).toString('hex')}`,
|
|
`APP_SECRET=${randomBytes(32).toString('base64')}`,
|
|
`INITIAL_ADMIN_EMAIL=${EMAIL}`,
|
|
`INITIAL_ADMIN_PASSWORD=${INITIAL_PW}`,
|
|
'TZ=America/Chicago',
|
|
'',
|
|
].join('\n');
|
|
|
|
// 1. Server A: data in the database and in the folders, then Back up now.
|
|
await server(A, images, env);
|
|
apiOn(
|
|
A,
|
|
`${signIn(INITIAL_PW)}
|
|
await ok('POST', '/api/auth/change-password', { currentPassword: '${INITIAL_PW}', newPassword: '${ADMIN_PW}' });
|
|
${signIn(ADMIN_PW)}
|
|
const { user } = await ok('POST', '/api/users', { email: 'dan@example.com', displayName: 'Dan Doe' });
|
|
await ok('POST', '/api/extensions', { number: '201', name: 'Dan Doe', userId: user.id });
|
|
return ok('POST', '/api/trunks', { name: 'voip.ms', serverHost: '127.0.0.1', username: '123456_pbx', password: '${TRUNK_PW}' });`,
|
|
);
|
|
for (const f of [...FILES, RECORDING])
|
|
must(
|
|
A,
|
|
`cd data && mkdir -p "$(dirname ${f})" && head -c 4096 /dev/urandom > ${f} && chown -R 1000:1000 "$(dirname ${f})"`,
|
|
);
|
|
const sumsA = sums(A, FILES);
|
|
const made = apiOn(
|
|
A,
|
|
`${signIn(ADMIN_PW)}
|
|
const b = await ok('POST', '/api/system/backups');
|
|
const list = await ok('GET', '/api/system/backups');
|
|
const dl = await call('GET', '/api/system/backups/' + b.name + '/download');
|
|
return { b, listed: list.map((x) => x.name), dl: [dl.status, dl.size] };`,
|
|
);
|
|
const name = made.b.name;
|
|
assert.match(name, /^selfphoneng-\d{8}-\d{4}\.tar\.gz$/);
|
|
assert.deepEqual(made.listed, [name]);
|
|
assert.deepEqual(made.dl, [200, made.b.size], 'download gives the whole file');
|
|
assert.match(must(A, `ls -l data/backups/${name}`), /^-rw------- /, 'mode 0600');
|
|
const members = must(A, `tar -tzf data/backups/${name}`).split('\n');
|
|
for (const f of ['db.sql', 'manifest.json', ...FILES]) assert.ok(members.includes(f), `has ${f}`);
|
|
assert.ok(
|
|
!members.some((m) => /^(recordings|asterisk-generated)/.test(m)),
|
|
'no recordings or generated config',
|
|
);
|
|
assert.doesNotMatch(
|
|
must(A, `tar -xzOf data/backups/${name} db.sql`),
|
|
new RegExp(TRUNK_PW),
|
|
'trunk password only encrypted',
|
|
);
|
|
console.log(`A: backup ${name} (${made.b.size} bytes, 0600, ${members.length} entries)`);
|
|
|
|
// 2. Server B: a fresh install with the same .env, and the backup copied over.
|
|
await server(B, images, env);
|
|
docker(['cp', `${A}:/srv/sp/data/backups/${name}`, join(work, name)]);
|
|
docker(['cp', join(work, name), `${B}:/srv/sp/${name}`]);
|
|
const other = env.replace(/^APP_SECRET=.*$/m, `APP_SECRET=${randomBytes(32).toString('base64')}`);
|
|
must(B, 'cp .env .env.good && cat > .env', other);
|
|
const wrong = sh(B, `sh scripts/restore.sh ${name}`, 'yes\n');
|
|
assert.ok(
|
|
!wrong.ok && /APP_SECRET in .env is not the one/.test(wrong.out),
|
|
`wrong secret refused: ${wrong.out}`,
|
|
);
|
|
must(B, 'mv .env.good .env');
|
|
const no = sh(B, `sh scripts/restore.sh ${name}`, 'no\n');
|
|
assert.ok(!no.ok && /stopped, nothing changed/.test(no.out), `"no" stops: ${no.out}`);
|
|
console.log('B: restore refuses another APP_SECRET and a "no"');
|
|
const restored = sh(B, `sh scripts/restore.sh ${name} 2>&1`, 'yes\n');
|
|
assert.ok(restored.ok, restored.out);
|
|
await until('B api up again', () => sh(B, 'wget -qO- http://127.0.0.1:8080/healthz').ok);
|
|
|
|
// 3. What came back.
|
|
const seen = apiOn(
|
|
B,
|
|
`const initial = await call('POST', '/api/auth/login', { email: '${EMAIL}', password: '${INITIAL_PW}' });
|
|
${signIn(ADMIN_PW)}
|
|
const exts = await ok('GET', '/api/extensions');
|
|
const trunks = await ok('GET', '/api/trunks');
|
|
return { initial: initial.status, exts: (exts.items ?? exts).map((e) => e.number), trunks: (trunks.items ?? trunks).map((t) => t.name) };`,
|
|
);
|
|
assert.equal(seen.initial, 401, "the fresh install's password is gone");
|
|
assert.deepEqual(seen.exts, ['201']);
|
|
assert.deepEqual(seen.trunks, ['voip.ms']);
|
|
await until(
|
|
'trunk password decrypted into the config',
|
|
() =>
|
|
sh(B, `grep -q "password = ${TRUNK_PW}" data/asterisk-generated/pjsip_selfphoneng.conf`).ok,
|
|
);
|
|
await until('201 in Asterisk', () =>
|
|
/Endpoint:\s+201\b/.test(
|
|
sh(B, "docker compose exec -T asterisk asterisk -rx 'pjsip show endpoints'").out,
|
|
),
|
|
);
|
|
assert.equal(sums(B, FILES), sumsA, 'files restored byte for byte');
|
|
assert.ok(!sh(B, `test -e data/${RECORDING}`).ok, 'recording not restored');
|
|
assert.match(
|
|
must(B, 'stat -c %u:%g data/voicemail/default/201/INBOX/msg0000.wav'),
|
|
/^1000:1000$/,
|
|
);
|
|
console.log('B: admin password, extension 201, trunk (password decrypted), files: restored');
|
|
console.log('M11-T3: PASS');
|
|
} catch (e) {
|
|
console.error('FAIL:', e.message);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (!process.env.KEEP) for (const vm of [A, B]) docker(['rm', '-f', '-v', vm]);
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|