Files
selfphoneng/scripts/e2e/m11-t3.mjs
T
iamdoubz 164a5f0990
ci / check (push) Successful in 54s
ci / images (push) Successful in 20s
backups: daily backup, Back up now, download, restore.sh
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
2026-10-19 17:07:58 -05:00

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 });
}