generator: core apply loop, sanitizers, system config routes
render() is pure and gives every file the header (bodies arrive per task). apply() runs one at a time, debounces requestApply, writes changed files atomically (mode 0640), keeps .prev, sends each needed reload once, skips pjsip reload and sets restartRequired when transports change, and records generator_runs. AMI connects on startup and every reconnect forces a full apply. Admin routes: config apply/rollback and a masked generated-files view. Task: M2-T2
This commit is contained in:
1 parent
70b0a92666
commit
a5de247948
13 files changed
+725
-6
No files matched your search
@@ -7,13 +7,17 @@ import fastifyStatic from '@fastify/static';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
|
||||
import { authRoutes } from './routes/auth.ts';
|
||||
import { userRoutes } from './routes/users.ts';
|
||||
import { systemRoutes } from './routes/system.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: Db;
|
||||
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
|
||||
generator: Generator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(userRoutes);
|
||||
app.register(systemRoutes);
|
||||
|
||||
if (existsSync(publicDir)) {
|
||||
app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
||||
|
||||
@@ -8,6 +8,7 @@ const env = {
|
||||
DATABASE_URL: 'postgres://u:p@127.0.0.1:5433/db',
|
||||
INITIAL_ADMIN_EMAIL: 'admin@example.com',
|
||||
INITIAL_ADMIN_PASSWORD: 'x',
|
||||
AMI_SECRET: 'ami-secret-123',
|
||||
};
|
||||
|
||||
describe('config', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { z } from 'zod';
|
||||
|
||||
// ponytail: only the vars read so far; AMI vars join in M2 with the code that reads them.
|
||||
const EnvSchema = z.object({
|
||||
APP_SECRET: z
|
||||
.string()
|
||||
@@ -10,6 +9,12 @@ const EnvSchema = z.object({
|
||||
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
|
||||
INITIAL_ADMIN_EMAIL: z.email(),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(1),
|
||||
AMI_HOST: z.string().default('127.0.0.1'),
|
||||
AMI_PORT: z.coerce.number().int().min(1).max(65535).default(5038),
|
||||
AMI_USERNAME: z.string().default('selfphoneng'),
|
||||
AMI_SECRET: z.string().min(8),
|
||||
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
|
||||
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
TRUST_PROXY: z.stringbool().default(true),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// DB → Asterisk config (03). render() is pure; apply() writes changed files and reloads Asterisk.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { AmiClient } from '../ami/client.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { generatorRuns } from '../db/schema.ts';
|
||||
import { getSetting, setSetting } from '../services/settings.ts';
|
||||
import { loadSnapshot, type Snapshot } from './load.ts';
|
||||
|
||||
export const HEADER = '; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.\n';
|
||||
|
||||
/** Generated file → the AMI Command that makes Asterisk load it (03 §3). Order = reload order. */
|
||||
export const RELOADS = {
|
||||
'pjsip_selfphoneng.conf': 'pjsip reload',
|
||||
'rtp_selfphoneng.conf': 'module reload res_rtp_asterisk.so',
|
||||
'extensions_globals_selfphoneng.conf': 'dialplan reload',
|
||||
'extensions_selfphoneng.conf': 'dialplan reload',
|
||||
'voicemail_selfphoneng.conf': 'voicemail reload',
|
||||
'queues_selfphoneng.conf': 'queue reload all',
|
||||
'musiconhold_selfphoneng.conf': 'moh reload',
|
||||
'confbridge_selfphoneng.conf': 'module reload app_confbridge.so',
|
||||
} as const;
|
||||
|
||||
export type GeneratedFileName = keyof typeof RELOADS;
|
||||
export const GENERATED_FILES = Object.keys(RELOADS) as GeneratedFileName[];
|
||||
export type RenderedFiles = Record<GeneratedFileName, string>;
|
||||
|
||||
// Each file's body; tasks fill these in (M2-T3 pjsip/rtp, M2-T4 dialplan, ...).
|
||||
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {};
|
||||
|
||||
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
|
||||
export function render(snap: Snapshot): RenderedFiles {
|
||||
return Object.fromEntries(
|
||||
GENERATED_FILES.map((f) => [f, HEADER + (BODIES[f]?.(snap) ?? '')]),
|
||||
) as RenderedFiles;
|
||||
}
|
||||
|
||||
/** The transport block: [global], [system] and every type=transport section (03 §3). */
|
||||
export function renderTransports(_snap: Snapshot): string {
|
||||
return ''; // M2-T3
|
||||
}
|
||||
|
||||
export type ApplyResult = {
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
filesChanged: GeneratedFileName[];
|
||||
commands: string[];
|
||||
restartRequired: boolean;
|
||||
};
|
||||
|
||||
export type GeneratorDeps = {
|
||||
db: Db;
|
||||
ami: Pick<AmiClient, 'isConnected' | 'command'>;
|
||||
/** /etc/asterisk/selfphoneng */
|
||||
dir: string;
|
||||
log: Pick<FastifyBaseLogger, 'info' | 'error'>;
|
||||
onApplied?: (r: ApplyResult) => void;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
export function createGenerator(deps: GeneratorDeps) {
|
||||
const { db, ami, dir, log } = deps;
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
/** One at a time: each call waits for the previous one (process-wide mutex). */
|
||||
function locked<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = chain.then(fn);
|
||||
chain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function writeAtomic(name: string, content: string) {
|
||||
const path = join(dir, name);
|
||||
await writeFile(`${path}.tmp`, content, { mode: 0o640 });
|
||||
await rename(`${path}.tmp`, path);
|
||||
}
|
||||
|
||||
async function sendReloads(
|
||||
commands: string[],
|
||||
): Promise<{ sent: string[]; error: string | null }> {
|
||||
if (!ami.isConnected()) return { sent: [], error: 'ami-disconnected' };
|
||||
const sent: string[] = [];
|
||||
for (const c of commands) {
|
||||
try {
|
||||
await ami.command(c);
|
||||
sent.push(c);
|
||||
} catch (e) {
|
||||
return { sent, error: `${c}: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
return { sent, error: null };
|
||||
}
|
||||
|
||||
async function record(trigger: string, r: ApplyResult, t0: number) {
|
||||
await db.insert(generatorRuns).values({
|
||||
trigger,
|
||||
filesChanged: r.filesChanged,
|
||||
commands: r.commands,
|
||||
ok: r.ok,
|
||||
error: r.error,
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
log[r.ok ? 'info' : 'error'](
|
||||
{ trigger, filesChanged: r.filesChanged, commands: r.commands, error: r.error },
|
||||
'generator apply',
|
||||
);
|
||||
deps.onApplied?.(r);
|
||||
}
|
||||
|
||||
async function doApply(trigger: string, force: boolean): Promise<ApplyResult> {
|
||||
const t0 = performance.now();
|
||||
const r: ApplyResult = {
|
||||
ok: true,
|
||||
error: null,
|
||||
filesChanged: [],
|
||||
commands: [],
|
||||
restartRequired: false,
|
||||
};
|
||||
try {
|
||||
const snap = await loadSnapshot(db);
|
||||
const files = render(snap);
|
||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||
const current = new Map<GeneratedFileName, string | null>();
|
||||
for (const f of GENERATED_FILES) {
|
||||
current.set(f, await readFile(join(dir, f), 'utf8').catch(() => null));
|
||||
if (current.get(f) !== files[f]) r.filesChanged.push(f);
|
||||
}
|
||||
if (r.filesChanged.length === 0 && !force) {
|
||||
await record(trigger, r, t0);
|
||||
return r;
|
||||
}
|
||||
|
||||
if (r.filesChanged.length) {
|
||||
// Keep exactly one previous version for rollback.
|
||||
for (const [f, content] of current)
|
||||
if (content !== null) await writeFile(join(dir, '.prev', f), content, { mode: 0o640 });
|
||||
for (const f of r.filesChanged) await writeAtomic(f, files[f]);
|
||||
}
|
||||
|
||||
// PJSIP transports can't be reloaded safely (01 §4.3). The first apply ever (no stored hash)
|
||||
// only adds transports to an Asterisk that booted with none, so a reload is fine then.
|
||||
const hash = sha256(renderTransports(snap));
|
||||
const prevHash = await getSetting(db, 'system.transportHash');
|
||||
const transportsChanged = prevHash !== null && prevHash !== hash;
|
||||
if (prevHash !== hash) await setSetting(db, 'system.transportHash', hash);
|
||||
if (transportsChanged) await setSetting(db, 'system.restartRequired', true);
|
||||
r.restartRequired = await getSetting(db, 'system.restartRequired');
|
||||
|
||||
const reloadFor = force ? GENERATED_FILES : r.filesChanged;
|
||||
const commands = [...new Set(reloadFor.map((f) => RELOADS[f]))].filter(
|
||||
(c) => !(transportsChanged && c === 'pjsip reload'),
|
||||
);
|
||||
// TODO(M8): after `queue reload all`, re-send QueuePause for paused members (03 §1 step 9).
|
||||
const { sent, error } = await sendReloads(commands);
|
||||
r.commands = sent;
|
||||
if (error) Object.assign(r, { ok: false, error });
|
||||
} catch (e) {
|
||||
Object.assign(r, { ok: false, error: (e as Error).message });
|
||||
}
|
||||
await record(trigger, r, t0);
|
||||
return r;
|
||||
}
|
||||
|
||||
return {
|
||||
dir,
|
||||
apply: (opts: { trigger: string; force?: boolean }) =>
|
||||
locked(() => doApply(opts.trigger, opts.force ?? false)),
|
||||
|
||||
/** Debounced (1 s by default): many quick changes become one apply. */
|
||||
requestApply(trigger: string) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
void locked(() => doApply(trigger, false));
|
||||
}, deps.debounceMs ?? 1000);
|
||||
},
|
||||
|
||||
/** POST /api/system/config/rollback: put .prev back and reload everything. */
|
||||
rollback: () =>
|
||||
locked(async () => {
|
||||
const t0 = performance.now();
|
||||
const r: ApplyResult = {
|
||||
ok: true,
|
||||
error: null,
|
||||
filesChanged: [],
|
||||
commands: [],
|
||||
restartRequired: false,
|
||||
};
|
||||
for (const f of GENERATED_FILES) {
|
||||
const prev = join(dir, '.prev', f);
|
||||
const restored = await copyFile(prev, join(dir, `${f}.tmp`)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (restored) {
|
||||
await rename(join(dir, `${f}.tmp`), join(dir, f));
|
||||
r.filesChanged.push(f);
|
||||
}
|
||||
}
|
||||
if (r.filesChanged.length === 0)
|
||||
Object.assign(r, { ok: false, error: 'no previous version' });
|
||||
else {
|
||||
const { sent, error } = await sendReloads([...new Set(Object.values(RELOADS))]);
|
||||
r.commands = sent;
|
||||
if (error) Object.assign(r, { ok: false, error });
|
||||
}
|
||||
r.restartRequired = await getSetting(db, 'system.restartRequired');
|
||||
await record('rollback', r, t0);
|
||||
return r;
|
||||
}),
|
||||
|
||||
/** Cancels a pending debounced apply (shutdown, tests). */
|
||||
cancelPending: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
export type Generator = ReturnType<typeof createGenerator>;
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Everything render() needs, loaded in one go (03 §1). render() itself does no I/O.
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { getAllSettings } from '../services/settings.ts';
|
||||
|
||||
export type Snapshot = {
|
||||
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
|
||||
today: string;
|
||||
settings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
};
|
||||
|
||||
export function todayIn(timezone: string, now = new Date()): string {
|
||||
// en-CA formats as YYYY-MM-DD.
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
|
||||
}
|
||||
|
||||
export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot> {
|
||||
const settings = await getAllSettings(db);
|
||||
return { today: todayIn(settings['general.timezone'], now), settings };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as s from './sanitize.ts';
|
||||
|
||||
const HOSTILE = [
|
||||
'101); System(rm -rf /',
|
||||
'101\n',
|
||||
'101\r\nexten => 1,1,System(x)',
|
||||
'',
|
||||
' 101',
|
||||
'${SHELL(id)}',
|
||||
'101', // full-width digits
|
||||
'१२३', // Devanagari digits
|
||||
'101;comment',
|
||||
'1'.repeat(300),
|
||||
];
|
||||
|
||||
describe('sanitizers reject hostile input', () => {
|
||||
const strict = [
|
||||
'digits',
|
||||
'dialable',
|
||||
'pattern',
|
||||
'sound',
|
||||
'ident',
|
||||
'secret',
|
||||
'pin',
|
||||
'host',
|
||||
'ipv4',
|
||||
'cidr',
|
||||
] as const;
|
||||
for (const fn of strict) {
|
||||
it.each(HOSTILE)(`${fn}(%j) throws`, (v) => {
|
||||
expect(() => s[fn](v)).toThrow(s.SanitizeError);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('sanitizers accept good input', () => {
|
||||
it.each([
|
||||
['digits', '13125551234'],
|
||||
['dialable', '+4420794609*#'],
|
||||
['pattern', '_NXXNXXXXXX'.slice(1)],
|
||||
['pattern', '011.'],
|
||||
['pattern', '1[2-9]XX!'],
|
||||
['sound', 'custom/3f2a-bc_d'],
|
||||
['ident', '3f2a1b4c-0000-4000-8000-000000000000'],
|
||||
['secret', 'Ab3dEf9hIj2kLm4nOp6qRs8t'],
|
||||
['pin', '1234'],
|
||||
['pin', '0123456789'],
|
||||
['host', 'chicago1.voip.ms'],
|
||||
['host', '10.0.0.1'],
|
||||
['ipv4', '203.0.113.7'],
|
||||
['cidr', '192.168.0.0/16'],
|
||||
] as const)('%s(%s)', (fn, v) => {
|
||||
expect(s[fn](v)).toBe(v);
|
||||
});
|
||||
|
||||
it('rejects near misses', () => {
|
||||
expect(() => s.pin('123')).toThrow();
|
||||
expect(() => s.pin('12345678901')).toThrow();
|
||||
expect(() => s.ipv4('256.1.1.1')).toThrow();
|
||||
expect(() => s.cidr('10.0.0.0/33')).toThrow();
|
||||
expect(() => s.host('-bad.example')).toThrow();
|
||||
expect(() => s.sound('Custom/X')).toThrow();
|
||||
expect(() => s.ident('UPPER')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('name()', () => {
|
||||
it.each([
|
||||
['Dan Doe', 'Dan Doe'],
|
||||
[' Dan ', 'Dan'],
|
||||
['x"); System(rm -rf /)', 'x Systemrm -rf /'],
|
||||
["Robert'); DROP TABLE;--", 'Robert DROP TABLE--'],
|
||||
['line\nbreak\r\nexten => 1,1,Hangup', 'linebreakexten => 11Hangup'.replace('=>', '=')],
|
||||
['${SHELL(id)}', 'SHELLid'],
|
||||
['a\u202Eb\u200Bc', 'abc'], // bidi override and zero-width space
|
||||
['Zoë Ångström 李', 'Zoë Ångström 李'],
|
||||
['x'.repeat(60), 'x'.repeat(40)],
|
||||
])('%j → %j', (input, expected) => {
|
||||
expect(s.name(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Every value that goes into generated Asterisk config passes through one of these (03 §2).
|
||||
// The API validates with the same rules first, so a throw here means a bug: apply fails loudly.
|
||||
|
||||
export class SanitizeError extends Error {}
|
||||
|
||||
function check(re: RegExp, kind: string, max = 200) {
|
||||
return (v: string): string => {
|
||||
if (typeof v !== 'string' || v.length > max || !re.test(v))
|
||||
throw new SanitizeError(`${kind}: invalid value ${JSON.stringify(String(v).slice(0, 40))}`);
|
||||
return v;
|
||||
};
|
||||
}
|
||||
|
||||
/** Internal numbers, DIDs. */
|
||||
export const digits = check(/^[0-9]+$/, 'digits', 32);
|
||||
/** Forward targets, external numbers. */
|
||||
export const dialable = check(/^[0-9*#+]+$/, 'dialable', 32);
|
||||
/** Outbound route patterns. */
|
||||
export const pattern = check(/^[0-9XZN.![\]-]+$/, 'pattern', 32);
|
||||
/** Sound file names for Playback/Background. */
|
||||
export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
|
||||
/** Context-name parts (uuids), MOH class names. */
|
||||
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
|
||||
/** Generated SIP passwords. */
|
||||
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
|
||||
/** Voicemail / conference PINs. */
|
||||
export const pin = check(/^[0-9]{4,10}$/, 'pin', 10);
|
||||
/** Trunk server host: a hostname or an IPv4 address. */
|
||||
export const host = check(
|
||||
/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/,
|
||||
'host',
|
||||
253,
|
||||
);
|
||||
const octet = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
|
||||
/** IPv4 address. */
|
||||
export const ipv4 = check(new RegExp(`^${octet}(?:\\.${octet}){3}$`), 'ipv4', 15);
|
||||
/** IPv4 CIDR, e.g. local nets. */
|
||||
export const cidr = check(
|
||||
new RegExp(`^${octet}(?:\\.${octet}){3}/(?:3[0-2]|[12]?\\d)$`),
|
||||
'cidr',
|
||||
18,
|
||||
);
|
||||
|
||||
/**
|
||||
* Caller ID names and labels: removes characters that mean something in dialplan or config
|
||||
* (" ' \ ; , ( ) [ ] { } $ < > |) and control characters, trims, max 40. Never throws.
|
||||
*/
|
||||
export function name(v: string): string {
|
||||
return String(v)
|
||||
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, '') // controls, incl. CR/LF, and invisible format chars
|
||||
.replace(/["'\\;,()[\]{}$<>|]/g, '')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
}
|
||||
+31
-4
@@ -1,9 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Startup order (04 §1).
|
||||
import { resolve } from 'node:path';
|
||||
import { AmiClient } from './ami/client.ts';
|
||||
import { buildApp } from './app.ts';
|
||||
import { ConfigError, loadConfig } from './config.ts';
|
||||
import { setSecretKey } from './crypto/secrets.ts';
|
||||
import { createDb, runMigrations } from './db/client.ts';
|
||||
import { createGenerator } from './generator/index.ts';
|
||||
import { ensureInitialAdmin } from './services/bootstrap.ts';
|
||||
|
||||
let config;
|
||||
@@ -18,17 +21,41 @@ setSecretKey(config.APP_SECRET);
|
||||
|
||||
const { db, pool } = createDb(config.DATABASE_URL);
|
||||
const app = buildApp(config, db); // creates the pino logger
|
||||
const ami = new AmiClient({
|
||||
host: config.AMI_HOST,
|
||||
port: config.AMI_PORT,
|
||||
username: config.AMI_USERNAME,
|
||||
secret: config.AMI_SECRET,
|
||||
logger: app.log,
|
||||
});
|
||||
const generator = createGenerator({
|
||||
db,
|
||||
ami,
|
||||
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
||||
log: app.log,
|
||||
});
|
||||
app.decorate('generator', generator);
|
||||
|
||||
await runMigrations(db);
|
||||
app.log.info('database migrated');
|
||||
if (
|
||||
(await ensureInitialAdmin(db, config.INITIAL_ADMIN_EMAIL, config.INITIAL_ADMIN_PASSWORD)) ===
|
||||
'created'
|
||||
)
|
||||
const admin = await ensureInitialAdmin(
|
||||
db,
|
||||
config.INITIAL_ADMIN_EMAIL,
|
||||
config.INITIAL_ADMIN_PASSWORD,
|
||||
);
|
||||
if (admin === 'created')
|
||||
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
|
||||
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
|
||||
|
||||
// Every (re)connect: Asterisk may have restarted, so push the full config again.
|
||||
// TODO(M2-T6): load live state; TODO(M6): sync AstDB first (04 §5.5).
|
||||
ami.on('connected', () => void generator.apply({ trigger: 'startup', force: true }));
|
||||
ami.start();
|
||||
|
||||
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(sig, () => {
|
||||
generator.cancelPending();
|
||||
ami.stop();
|
||||
void app
|
||||
.close()
|
||||
.then(() => pool.end())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { maskSecrets } from './system.ts';
|
||||
|
||||
describe('maskSecrets', () => {
|
||||
it('hides SIP passwords, AMI secrets and voicemail PINs, nothing else', () => {
|
||||
const conf = [
|
||||
'[101-auth]',
|
||||
'type = auth',
|
||||
'username = 101',
|
||||
'password = Ab3dEf9hIj2kLm4nOp6qRs8t',
|
||||
'secret=abc',
|
||||
'[default]',
|
||||
'101 => 482913,Dan Doe,,,tz=local|attach=no',
|
||||
'exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))',
|
||||
].join('\n');
|
||||
const out = maskSecrets(conf);
|
||||
expect(out).not.toMatch(/Ab3dEf9h|=abc|482913/);
|
||||
expect(out).toContain('password = ********');
|
||||
expect(out).toContain('101 => ********,Dan Doe,,,tz=local|attach=no');
|
||||
expect(out).toContain('exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))');
|
||||
expect(out).toContain('username = 101');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/system/* (04 §7.3). Admin only.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { GENERATED_FILES } from '../generator/index.ts';
|
||||
|
||||
/** Generated files hold plaintext SIP passwords and PINs: mask them in the debug view. */
|
||||
export function maskSecrets(conf: string): string {
|
||||
return conf
|
||||
.replace(/^(\s*(?:password|secret)\s*=\s*).+$/gim, '$1********')
|
||||
.replace(/^(\s*\d+\s*=>\s*)[^,\n]*/gm, '$1********'); // voicemail.conf: "101 => PIN,Name,..."
|
||||
}
|
||||
|
||||
export async function systemRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.post('/api/system/config/apply', async (req) => {
|
||||
const r = await app.generator.apply({ trigger: 'manual', force: true });
|
||||
await audit(app.db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'config_apply',
|
||||
summary: `Applied Asterisk config (${r.ok ? 'ok' : r.error})`,
|
||||
});
|
||||
return r;
|
||||
});
|
||||
|
||||
app.post('/api/system/config/rollback', async (req) => {
|
||||
const r = await app.generator.rollback();
|
||||
await audit(app.db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'config_rollback',
|
||||
summary: `Rolled back Asterisk config (${r.ok ? 'ok' : r.error})`,
|
||||
});
|
||||
return r;
|
||||
});
|
||||
|
||||
app.get('/api/system/generated-files', async () => ({
|
||||
files: await Promise.all(
|
||||
GENERATED_FILES.map(async (name) => ({
|
||||
name,
|
||||
content: maskSecrets(await readFile(join(app.generator.dir, name), 'utf8').catch(() => '')),
|
||||
})),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -12,8 +12,25 @@ import { users } from '../src/db/schema.ts';
|
||||
import { hashPassword } from '../src/auth/passwords.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
|
||||
import { createGenerator } from '../src/generator/index.ts';
|
||||
import { createTestDb } from './db.ts';
|
||||
|
||||
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
|
||||
export function fakeAmi() {
|
||||
const commands: string[] = [];
|
||||
return {
|
||||
connected: true,
|
||||
commands,
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
},
|
||||
async command(c: string) {
|
||||
commands.push(c);
|
||||
return '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
|
||||
export const H = { 'x-requested-with': 'selfphoneng' };
|
||||
|
||||
@@ -25,12 +42,19 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
DATABASE_URL: t.url,
|
||||
INITIAL_ADMIN_EMAIL: ADMIN.email,
|
||||
INITIAL_ADMIN_PASSWORD: ADMIN.password,
|
||||
AMI_SECRET: 'ami-secret-123',
|
||||
LOG_LEVEL: 'fatal',
|
||||
});
|
||||
setSecretKey(config.APP_SECRET);
|
||||
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
|
||||
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
|
||||
const app = buildApp(config, t.db, pub);
|
||||
const ami = fakeAmi();
|
||||
const genDir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
|
||||
app.decorate(
|
||||
'generator',
|
||||
createGenerator({ db: t.db, ami, dir: genDir, log: app.log, debounceMs: 20 }),
|
||||
);
|
||||
extend?.(app);
|
||||
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
|
||||
resetLoginLimiter();
|
||||
@@ -65,6 +89,8 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
return {
|
||||
app,
|
||||
db: t.db,
|
||||
ami,
|
||||
genDir,
|
||||
login,
|
||||
addUser,
|
||||
clearMustChange,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M2-T2: apply() writes files atomically and sends only the reloads it needs (03 §1, §3).
|
||||
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { generatorRuns } from '../src/db/schema.ts';
|
||||
import {
|
||||
GENERATED_FILES,
|
||||
HEADER,
|
||||
createGenerator,
|
||||
render,
|
||||
type Generator,
|
||||
} from '../src/generator/index.ts';
|
||||
import { loadSnapshot, todayIn } from '../src/generator/load.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
import { fakeAmi } from './app.ts';
|
||||
|
||||
const silent = { info() {}, error() {} };
|
||||
|
||||
describe('loadSnapshot / render', () => {
|
||||
it('todayIn uses the company timezone, not UTC', () => {
|
||||
const lateNightUtc = new Date('2026-03-01T03:30:00Z'); // still Feb 28 in Chicago
|
||||
expect(todayIn('America/Chicago', lateNightUtc)).toBe('2026-02-28');
|
||||
expect(todayIn('UTC', lateNightUtc)).toBe('2026-03-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('generator apply', () => {
|
||||
let db: Db;
|
||||
let drop: () => Promise<void>;
|
||||
let dir: string;
|
||||
let ami: ReturnType<typeof fakeAmi>;
|
||||
let gen: Generator;
|
||||
const applied: unknown[] = [];
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
|
||||
ami = fakeAmi();
|
||||
gen = createGenerator({
|
||||
db,
|
||||
ami,
|
||||
dir,
|
||||
log: silent,
|
||||
debounceMs: 30,
|
||||
onApplied: (r) => applied.push(r),
|
||||
});
|
||||
});
|
||||
|
||||
it('render() gives every file the header and is deterministic', async () => {
|
||||
const snap = await loadSnapshot(db);
|
||||
const a = render(snap);
|
||||
expect(Object.keys(a).sort()).toEqual([...GENERATED_FILES].sort());
|
||||
for (const f of GENERATED_FILES) expect(a[f].startsWith(HEADER)).toBe(true);
|
||||
expect(render(structuredClone(snap))).toEqual(a);
|
||||
});
|
||||
|
||||
it('first apply writes every file and reloads everything once', async () => {
|
||||
const r = await gen.apply({ trigger: 'startup' });
|
||||
expect(r).toMatchObject({ ok: true, error: null });
|
||||
expect(r.filesChanged.sort()).toEqual([...GENERATED_FILES].sort());
|
||||
expect(ami.commands).toEqual([
|
||||
'pjsip reload',
|
||||
'module reload res_rtp_asterisk.so',
|
||||
'dialplan reload', // two dialplan files, one reload
|
||||
'voicemail reload',
|
||||
'queue reload all',
|
||||
'moh reload',
|
||||
'module reload app_confbridge.so',
|
||||
]);
|
||||
const names = readdirSync(dir).sort();
|
||||
expect(names).toEqual(['.prev', ...GENERATED_FILES].sort()); // no leftover .tmp files
|
||||
expect(readFileSync(join(dir, 'pjsip_selfphoneng.conf'), 'utf8')).toBe(HEADER);
|
||||
expect(await getSetting(db, 'system.restartRequired')).toBe(false);
|
||||
});
|
||||
|
||||
it('nothing changed → no writes, no reloads, but a run is recorded', async () => {
|
||||
await gen.apply({ trigger: 'startup' });
|
||||
ami.commands.length = 0;
|
||||
const before = (await db.select().from(generatorRuns)).length;
|
||||
const r = await gen.apply({ trigger: 'api-change' });
|
||||
expect(r).toMatchObject({ ok: true, filesChanged: [], commands: [] });
|
||||
expect(ami.commands).toEqual([]);
|
||||
const runs = await db.select().from(generatorRuns).orderBy(asc(generatorRuns.id));
|
||||
expect(runs).toHaveLength(before + 1);
|
||||
expect(runs.at(-1)).toMatchObject({
|
||||
trigger: 'api-change',
|
||||
ok: true,
|
||||
filesChanged: [],
|
||||
commands: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('only the changed file is rewritten and only its module reloaded; .prev keeps the old one', async () => {
|
||||
await gen.apply({ trigger: 'startup' });
|
||||
ami.commands.length = 0;
|
||||
writeFileSync(join(dir, 'queues_selfphoneng.conf'), '; hand edit\n'); // drift on disk
|
||||
const r = await gen.apply({ trigger: 'api-change' });
|
||||
expect(r.filesChanged).toEqual(['queues_selfphoneng.conf']);
|
||||
expect(ami.commands).toEqual(['queue reload all']);
|
||||
expect(readFileSync(join(dir, 'queues_selfphoneng.conf'), 'utf8')).toBe(HEADER);
|
||||
expect(readFileSync(join(dir, '.prev', 'queues_selfphoneng.conf'), 'utf8')).toBe(
|
||||
'; hand edit\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('force reloads everything even when no file changed', async () => {
|
||||
await gen.apply({ trigger: 'startup' });
|
||||
ami.commands.length = 0;
|
||||
const r = await gen.apply({ trigger: 'startup', force: true });
|
||||
expect(r.filesChanged).toEqual([]);
|
||||
expect(ami.commands).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('AMI down → files written, run recorded as ami-disconnected', async () => {
|
||||
ami.connected = false;
|
||||
const r = await gen.apply({ trigger: 'api-change' });
|
||||
expect(r).toMatchObject({ ok: false, error: 'ami-disconnected', commands: [] });
|
||||
expect(r.filesChanged.length).toBeGreaterThan(0);
|
||||
const [last] = await db
|
||||
.select()
|
||||
.from(generatorRuns)
|
||||
.orderBy(asc(generatorRuns.id))
|
||||
.limit(1)
|
||||
.offset((await db.select().from(generatorRuns)).length - 1);
|
||||
expect(last).toMatchObject({ ok: false, error: 'ami-disconnected' });
|
||||
});
|
||||
|
||||
it('applies run one at a time and requestApply debounces bursts into one apply', async () => {
|
||||
const order: string[] = [];
|
||||
const slowAmi = {
|
||||
isConnected: () => true,
|
||||
command: async (c: string) => {
|
||||
order.push(`start ${c}`);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
order.push(`end ${c}`);
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const g = createGenerator({ db, ami: slowAmi, dir, log: silent });
|
||||
await Promise.all([
|
||||
g.apply({ trigger: 'a', force: true }),
|
||||
g.apply({ trigger: 'b', force: true }),
|
||||
]);
|
||||
for (let i = 0; i < order.length; i += 2) {
|
||||
expect(order[i]!.replace('start', '')).toBe(order[i + 1]!.replace('end', '')); // never interleaved
|
||||
}
|
||||
|
||||
applied.length = 0;
|
||||
for (let i = 0; i < 5; i++) gen.requestApply('api-change');
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('transport change → no pjsip reload, restartRequired set', async () => {
|
||||
// Simulate a previous apply that recorded a different transport block.
|
||||
await setSetting(db, 'system.transportHash', 'an-old-hash');
|
||||
await setSetting(db, 'system.restartRequired', false);
|
||||
writeFileSync(join(dir, 'pjsip_selfphoneng.conf'), '; old\n');
|
||||
const r = await gen.apply({ trigger: 'api-change' });
|
||||
expect(r.restartRequired).toBe(true);
|
||||
expect(ami.commands).not.toContain('pjsip reload');
|
||||
expect(await getSetting(db, 'system.restartRequired')).toBe(true);
|
||||
await setSetting(db, 'system.restartRequired', false);
|
||||
});
|
||||
|
||||
it('rollback restores .prev and reloads everything', async () => {
|
||||
await gen.apply({ trigger: 'startup' });
|
||||
writeFileSync(join(dir, 'moh_x'), ''); // unrelated files are ignored
|
||||
writeFileSync(join(dir, '.prev', 'musiconhold_selfphoneng.conf'), '; previous\n');
|
||||
ami.commands.length = 0;
|
||||
const r = await gen.rollback();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(readFileSync(join(dir, 'musiconhold_selfphoneng.conf'), 'utf8')).toBe('; previous\n');
|
||||
expect(ami.commands).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('a render error fails the apply loudly without touching the files', async () => {
|
||||
await gen.apply({ trigger: 'startup' });
|
||||
await db.execute(
|
||||
// Bypass the settings service validation the way a bug would.
|
||||
(await import('drizzle-orm'))
|
||||
.sql`insert into settings (key, value) values ('general.timezone', '"Not/AZone"')
|
||||
on conflict (key) do update set value = excluded.value`,
|
||||
);
|
||||
const r = await gen.apply({ trigger: 'api-change' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toMatch(/time ?zone/i);
|
||||
await db.execute(
|
||||
(await import('drizzle-orm')).sql`delete from settings where key = 'general.timezone'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -8,7 +8,10 @@ export default tseslint.config(
|
||||
{ ignores: ['**/dist/', 'data/', '**/node_modules/', '.pnpm-store/'] },
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{ languageOptions: { globals: globals.node } },
|
||||
{
|
||||
languageOptions: { globals: globals.node },
|
||||
rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] },
|
||||
},
|
||||
{
|
||||
files: ['apps/web/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: globals.browser },
|
||||
|
||||
Reference in new issue
Block a user