generator: PJSIP transports, extension endpoints and rtp config

Golden cases empty, one-user, webrtc-disabled and hostile-names; each also
renders identically from shuffled input. pnpm test:asterisk boots the
asterisk image with every golden case and fails on a missing endpoint or
context or any ERROR log line (checked against a deliberately broken case).
On 24.0.0-rc2: 101 and 101-web load with every set_var.

Task: M2-T3
This commit is contained in:
iamdoubz committed 2026-10-17 16:23:14 -05:00
1 parent a5de247948
commit 06ac99b9f4
44 files changed
+1040 -12

No files matched your search

@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,27 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
{}
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,237 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 103 (Dan Systemrm -rf /eviltype = endpoint) ──
[103]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 103-auth
aors = 103
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Systemrm -rf /eviltype = endpoint" <103>
mailboxes = 103@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=103
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[103-auth]
type = auth
auth_type = userpass
username = 103
password = Hostile00000000000000001
[103]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 103@default
[103-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 103-web-auth
aors = 103-web
callerid = "Dan Systemrm -rf /eviltype = endpoint" <103>
mailboxes = 103@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=103
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[103-web-auth]
type = auth
auth_type = userpass
username = 103-web
password = Hostile00000000000000001
[103-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 104 (104) ──
[104]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 104-auth
aors = 104
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "104" <104>
mailboxes = 104@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=104
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[104-auth]
type = auth
auth_type = userpass
username = 104
password = Hostile00000000000000002
[104]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 104@default
[104-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 104-web-auth
aors = 104-web
callerid = "104" <104>
mailboxes = 104@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=104
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[104-web-auth]
type = auth
auth_type = userpass
username = 104-web
password = Hostile00000000000000002
[104-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── extension 105 (Zoë Ångström 李 SHELLid evil) ──
[105]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 105-auth
aors = 105
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Zoë Ångström 李 SHELLid evil" <105>
mailboxes = 105@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=105
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[105-auth]
type = auth
auth_type = userpass
username = 105
password = Hostile00000000000000003
[105]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 105@default
[105-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 105-web-auth
aors = 105-web
callerid = "Zoë Ångström 李 SHELLid evil" <105>
mailboxes = 105@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=105
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[105-web-auth]
type = auth
auth_type = userpass
username = 105-web
password = Hostile00000000000000003
[105-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,19 @@
{
"extensions": [
{
"number": "103",
"name": "Dan\"); System(rm -rf /)\n[evil]\ntype = endpoint",
"sipPassword": "Hostile00000000000000001"
},
{
"number": "104",
"name": "();,\"'\\$<>|",
"sipPassword": "Hostile00000000000000002"
},
{
"number": "105",
"name": "Zoë Ångström 李 ${SHELL(id)} \u202Eevil\u200B",
"sipPassword": "Hostile00000000000000003"
}
]
}
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,115 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
external_media_address = 203.0.113.10
external_signaling_address = 203.0.113.10
local_net = 192.168.0.0/16
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
external_media_address = 203.0.113.10
external_signaling_address = 203.0.113.10
local_net = 192.168.0.0/16
local_net = 10.0.0.0/8
local_net = 172.16.0.0/12
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 101 (Dan Doe) ──
[101]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 101-auth
aors = 101
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Doe" <101>
mailboxes = 101@default
dtmf_mode = rfc4733
allow_subscribe = yes
named_call_group = sales
named_pickup_group = sales
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_OUTCID=13125551234
set_var = SP_E911CID=13125551234
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 101@default
[101-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
callerid = "Dan Doe" <101>
mailboxes = 101@default
dtmf_mode = rfc4733
allow_subscribe = yes
named_call_group = sales
named_pickup_group = sales
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_OUTCID=13125551234
set_var = SP_E911CID=13125551234
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
stunaddr = stun.example.org:3478
[ice_host_candidates]
192.168.1.20 => 203.0.113.10
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,18 @@
{
"settings": {
"network.publicIp": "203.0.113.10",
"network.lanIp": "192.168.1.20",
"network.stunServer": "stun.example.org:3478"
},
"extensions": [
{
"number": "101",
"name": "Dan Doe",
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t",
"pickupGroup": "sales",
"outboundCallerId": "13125551234",
"emergencyCallerId": "13125551234",
"isAdmin": true
}
]
}
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,96 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5070
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 99 (Lobby phone) ──
[99]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 99-auth
aors = 99
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Lobby phone" <99>
mailboxes = 99@default
dtmf_mode = rfc4733
allow_subscribe = yes
language = fr
moh_suggest = default
tone_zone = us
set_var = SP_EXT=99
set_var = SP_CLASS=0
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[99-auth]
type = auth
auth_type = userpass
username = 99
password = Lobby00000000000000000AB
[99]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 99@default
; ── extension 102 (Ann Lee) ──
[102]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 102-auth
aors = 102
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Ann Lee" <102>
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=102
set_var = SP_CLASS=1
set_var = SP_RECMODE=all
set_var = __SP_DIRECTION=internal
[102-auth]
type = auth
auth_type = userpass
username = 102
password = Zz9yXx8wVv7uTt6sRr5qPp4o
[102]
type = aor
max_contacts = 2
remove_existing = yes
qualify_frequency = 30
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,23 @@
{
"settings": { "sip.tcpEnabled": false, "sip.udpPort": 5070 },
"extensions": [
{
"number": "102",
"name": "Ann Lee",
"sipPassword": "Zz9yXx8wVv7uTt6sRr5qPp4o",
"webrtcEnabled": false,
"voicemailEnabled": false,
"maxContacts": 2,
"outboundClass": "local",
"recordMode": "all"
},
{
"number": "99",
"name": "Lobby phone",
"sipPassword": "Lobby00000000000000000AB",
"webrtcEnabled": false,
"outboundClass": "internal",
"language": "fr"
}
]
}
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Golden tests (03 §12.1–12.2): render(snapshot.json) must equal expected/*.conf byte for byte.
// `UPDATE_GOLDEN=1 pnpm test` rewrites the expected files; a human reads the diff before committing.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { GENERATED_FILES, render } from './index.ts';
import type { Snapshot } from './load.ts';
const ROOT = fileURLToPath(new URL('./__golden__', import.meta.url));
const UPDATE = process.env.UPDATE_GOLDEN === '1';
/** snapshot.json holds only what differs from the defaults, to keep cases readable. */
export function loadCase(name: string): Snapshot {
const raw = JSON.parse(readFileSync(join(ROOT, name, 'snapshot.json'), 'utf8')) as {
today?: string;
settings?: Record<string, unknown>;
extensions?: Partial<Snapshot['extensions'][number]>[];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(raw.settings?.[k])]),
) as Snapshot['settings'];
return {
today: raw.today ?? '2026-10-17',
settings,
extensions: (raw.extensions ?? []).map((e, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
number: '101',
name: 'User',
sipPassword: 'TestPassword0000000000AB',
webrtcEnabled: true,
maxContacts: 5,
ringSeconds: 25,
voicemailEnabled: true,
outboundClass: 'national',
outboundCallerId: null,
emergencyCallerId: null,
recordMode: 'never',
pickupGroup: null,
language: 'en',
mohClass: 'default',
isAdmin: false,
...e,
})),
};
}
const cases = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d, 'snapshot.json')));
describe('golden', () => {
it('has the required cases', () => {
// 03 §12.1; the rest arrive with their features (M3, M6, M8).
expect(cases).toEqual(
expect.arrayContaining(['empty', 'one-user', 'webrtc-disabled', 'hostile-names']),
);
});
describe.each(cases)('%s', (name) => {
const snap = loadCase(name);
const files = render(snap);
const dir = join(ROOT, name, 'expected');
if (UPDATE) {
mkdirSync(dir, { recursive: true });
for (const f of GENERATED_FILES) writeFileSync(join(dir, f), files[f]);
}
it.each(GENERATED_FILES)('%s', (f) => {
expect(files[f]).toBe(readFileSync(join(dir, f), 'utf8'));
});
it('is deterministic: shuffled input order gives identical output', () => {
const shuffled = { ...snap, extensions: [...snap.extensions].reverse() };
expect(render(shuffled)).toEqual(files);
});
});
});
+7 -6
View File
@@ -9,6 +9,9 @@ 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';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
export { renderTransports };
export const HEADER = '; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.\n';
@@ -29,7 +32,10 @@ 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>> = {};
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
'pjsip_selfphoneng.conf': renderPjsip,
'rtp_selfphoneng.conf': renderRtp,
};
/** PURE: no I/O, no clock, no randomness. Same snapshot → byte-identical files. */
export function render(snap: Snapshot): RenderedFiles {
@@ -38,11 +44,6 @@ export function render(snap: Snapshot): RenderedFiles {
) 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;
+64 -1
View File
@@ -1,12 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Everything render() needs, loaded in one go (03 §1). render() itself does no I/O.
import { eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts';
import { dids, extensions, users } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getAllSettings } from '../services/settings.ts';
export type SnapExtension = {
id: string;
number: string;
name: string;
sipPassword: string; // decrypted
webrtcEnabled: boolean;
maxContacts: number;
ringSeconds: number;
voicemailEnabled: boolean;
outboundClass: 'internal' | 'local' | 'national' | 'international';
outboundCallerId: string | null; // DID number
emergencyCallerId: string | null; // DID number
recordMode: 'never' | 'inbound' | 'outbound' | 'all';
pickupGroup: string | null;
language: string;
mohClass: string;
isAdmin: boolean; // the extension's user is an admin (time-condition override codes)
};
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
settings: Awaited<ReturnType<typeof getAllSettings>>;
extensions: SnapExtension[];
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -16,5 +40,44 @@ export function todayIn(timezone: string, now = new Date()): string {
export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot> {
const settings = await getAllSettings(db);
return { today: todayIn(settings['general.timezone'], now), settings };
const outDid = alias(dids, 'out_did');
const e911Did = alias(dids, 'e911_did');
const rows = await db
.select({
ext: extensions,
role: users.role,
outCid: outDid.number,
e911Cid: e911Did.number,
})
.from(extensions)
.leftJoin(users, eq(users.id, extensions.userId))
.leftJoin(outDid, eq(outDid.id, extensions.outboundCallerIdDidId))
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
return {
today: todayIn(settings['general.timezone'], now),
settings,
extensions: rows.map(({ ext, role, outCid, e911Cid }) => ({
id: ext.id,
number: ext.number,
name: ext.name,
sipPassword: decrypt(ext.sipPasswordEnc),
webrtcEnabled: ext.webrtcEnabled,
maxContacts: ext.maxContacts,
ringSeconds: ext.ringSeconds,
voicemailEnabled: ext.voicemailEnabled,
outboundClass: ext.outboundClass,
outboundCallerId: outCid,
emergencyCallerId: e911Cid,
recordMode: ext.recordMode,
pickupGroup: ext.pickupGroup,
language: ext.language,
mohClass: ext.mohClass,
isAdmin: role === 'admin',
})),
};
}
/** Numeric order for internal numbers ("99" < "100"), then text as a tiebreak. */
export const byNumber = (a: { number: string }, b: { number: string }) =>
Number(a.number) - Number(b.number) || a.number.localeCompare(b.number);
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
import { byNumber, type SnapExtension, type Snapshot } from './load.ts';
import * as s from './sanitize.ts';
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
const section = (title: string, lines: (string | false | null | undefined)[]) =>
[`[${title}]`, ...lines.filter((l): l is string => typeof l === 'string')].join('\n');
/** "stun.example.org:3478" → sanitized host[:port]. */
function hostPort(v: string): string {
const [h, p, ...rest] = v.split(':');
if (rest.length) throw new s.SanitizeError(`host:port: invalid value ${JSON.stringify(v)}`);
return p === undefined ? s.host(h!) : `${s.host(h!)}:${s.digits(p)}`;
}
function natLines(snap: Snapshot): string[] {
const ip = snap.settings['network.publicIp'];
if (!ip) return [];
return [
`external_media_address = ${s.ipv4(ip)}`,
`external_signaling_address = ${s.ipv4(ip)}`,
...snap.settings['network.localNets'].map((n) => `local_net = ${s.cidr(n)}`),
];
}
/** [global], [system] and the transports: hashed to detect changes that need a restart. */
export function renderTransports(snap: Snapshot): string {
const st = snap.settings;
const port = s.digits(String(st['sip.udpPort']));
const nat = natLines(snap);
return [
section('global', [
'type = global',
'user_agent = SelfPhoneNG',
'endpoint_identifier_order = ip,username,anonymous',
'keep_alive_interval = 90',
'max_initial_qualify_time = 4',
]),
section('system', ['type = system', 'timer_t1 = 500', 'timer_b = 32000']),
section('transport-udp', [
'type = transport',
'protocol = udp',
`bind = 0.0.0.0:${port}`,
...nat,
]),
st['sip.tcpEnabled'] &&
section('transport-tcp', [
'type = transport',
'protocol = tcp',
`bind = 0.0.0.0:${port}`,
...nat,
]),
st['sip.tlsEnabled'] &&
section('transport-tls', [
'type = transport',
'protocol = tls',
`bind = 0.0.0.0:${s.digits(String(st['sip.tlsPort']))}`,
'cert_file = /var/lib/selfphoneng/tls/fullchain.pem',
'priv_key_file = /var/lib/selfphoneng/tls/privkey.pem',
'method = tlsv1_2',
...nat,
]),
// The bind address is ignored: browsers arrive through Asterisk's HTTP server (01 §3.5).
section('transport-ws', ['type = transport', 'protocol = ws', 'bind = 0.0.0.0']),
]
.filter(Boolean)
.join('\n\n');
}
function extensionSections(e: SnapExtension): string {
const num = s.digits(e.number);
const name = s.name(e.name) || num;
const groups = e.pickupGroup ? s.ident(e.pickupGroup) : null;
const mailbox = e.voicemailEnabled ? `mailboxes = ${num}@default` : null;
const vars = [
`set_var = SP_EXT=${num}`,
`set_var = SP_CLASS=${CLASS_NUMBER[e.outboundClass]}`,
e.outboundCallerId && `set_var = SP_OUTCID=${s.digits(e.outboundCallerId)}`,
e.emergencyCallerId && `set_var = SP_E911CID=${s.digits(e.emergencyCallerId)}`,
`set_var = SP_RECMODE=${e.recordMode}`,
'set_var = __SP_DIRECTION=internal',
];
const common = [
`callerid = "${name}" <${num}>`,
mailbox,
'dtmf_mode = rfc4733',
'allow_subscribe = yes',
groups && `named_call_group = ${groups}`,
groups && `named_pickup_group = ${groups}`,
`language = ${s.ident(e.language)}`,
`moh_suggest = ${s.ident(e.mohClass)}`,
'tone_zone = us',
...vars,
];
const password = s.secret(e.sipPassword);
const out = [
`; ── extension ${num} (${name}) ──
` +
section(num, [
'type = endpoint',
'context = sp-from-ext',
'disallow = all',
'allow = g722,ulaw,alaw',
`auth = ${num}-auth`,
`aors = ${num}`,
'direct_media = no',
'rtp_symmetric = yes',
'force_rport = yes',
'rewrite_contact = yes',
'send_pai = yes',
...common,
]),
section(`${num}-auth`, [
'type = auth',
'auth_type = userpass',
`username = ${num}`,
`password = ${password}`,
]),
section(num, [
'type = aor',
`max_contacts = ${s.digits(String(e.maxContacts))}`,
'remove_existing = yes',
'qualify_frequency = 30',
mailbox,
]),
];
if (e.webrtcEnabled)
out.push(
section(`${num}-web`, [
'type = endpoint',
'context = sp-from-ext',
'webrtc = yes', // ICE, AVPF, DTLS-SRTP, rtcp-mux
'disallow = all',
'allow = g722,ulaw', // no opus: no open source transcoder in Asterisk (AGENTS rule 4)
`auth = ${num}-web-auth`,
`aors = ${num}-web`,
...common,
]),
section(`${num}-web-auth`, [
'type = auth',
'auth_type = userpass',
`username = ${num}-web`,
`password = ${password}`,
]),
section(`${num}-web`, [
'type = aor',
'max_contacts = 3',
'remove_existing = yes',
'qualify_frequency = 0', // browsers don't answer OPTIONS reliably
]),
);
return out.join('\n\n');
}
export function renderPjsip(snap: Snapshot): string {
const exts = [...snap.extensions].sort(byNumber).map(extensionSections);
return [renderTransports(snap), ...exts].join('\n\n') + '\n';
}
export function renderRtp(snap: Snapshot): string {
const st = snap.settings;
const lines = ['icesupport = yes'];
if (st['network.stunServer']) lines.push(`stunaddr = ${hostPort(st['network.stunServer'])}`);
if (st['network.lanIp'] && st['network.publicIp'])
lines.push(
'',
'[ice_host_candidates]',
`${s.ipv4(st['network.lanIp'])} => ${s.ipv4(st['network.publicIp'])}`,
);
return lines.join('\n') + '\n';
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Asterisk load test (03 §12.3): boot the asterisk image with each golden case's generated files and
// fail if Asterisk logs ERROR while loading, or if an endpoint/context we generated is missing.
// Needs Docker and a built image (ASTERISK_IMAGE, default "selfphoneng-asterisk").
// Usage: pnpm test:asterisk [case ...]
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const GOLDEN = fileURLToPath(new URL('../../src/generator/__golden__', import.meta.url));
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NAME = `sp-golden-${process.pid}`;
const docker = (...args) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const cli = (cmd) => docker('exec', NAME, 'asterisk', '-rx', cmd);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** What the generated files promise Asterisk will have. */
function expectations(dir) {
const pjsip = readFileSync(join(dir, 'pjsip_selfphoneng.conf'), 'utf8');
const endpoints = [...pjsip.matchAll(/^\[([^\]]+)\]\ntype = endpoint$/gm)].map((m) => m[1]);
const ext = readFileSync(join(dir, 'extensions_selfphoneng.conf'), 'utf8');
const contexts = [...ext.matchAll(/^\[([^\]]+)\]$/gm)].map((m) => m[1]);
return { endpoints, contexts };
}
async function check(name) {
const tmp = mkdtempSync(join(tmpdir(), `sp-golden-${name}-`));
cpSync(join(GOLDEN, name, 'expected'), tmp, { recursive: true });
const problems = [];
const run = docker(
'run',
'-d',
'--rm',
'--name',
NAME,
'-e',
'AMI_SECRET=golden-test-secret',
'-v',
`${tmp}:/etc/asterisk/selfphoneng`,
IMAGE,
);
if (!run.ok) throw new Error(`docker run failed: ${run.out}`);
try {
let booted = false;
for (let i = 0; i < 60 && !booted; i++) {
await sleep(500);
booted = cli('core waitfullybooted').ok;
}
if (!booted) problems.push('Asterisk did not finish booting');
await sleep(500);
const { endpoints, contexts } = expectations(tmp);
const shown = cli('pjsip show endpoints').out;
for (const e of endpoints)
if (!new RegExp(`Endpoint:\\s+${e}[\\s/]`).test(shown))
problems.push(`endpoint ${e} not loaded`);
for (const c of contexts) {
const d = cli(`dialplan show ${c}`).out;
if (!d.includes(`'${c}'`)) problems.push(`context ${c} not loaded`);
}
// Asterisk colors its log: strip the escape codes before looking for ERROR[.
// eslint-disable-next-line no-control-regex -- matching ANSI escapes is the point
const log = docker('logs', NAME).out.replace(/\x1b\[[0-9;]*m/g, '');
const errors = log.split('\n').filter((l) => /ERROR\[/.test(l));
problems.push(...errors.map((l) => `log: ${l.trim()}`));
return { name, problems, endpoints: endpoints.length, contexts: contexts.length };
} finally {
docker('rm', '-f', NAME);
rmSync(tmp, { recursive: true, force: true });
}
}
const cases = process.argv.slice(2).length
? process.argv.slice(2)
: readdirSync(GOLDEN).filter((d) => existsSync(join(GOLDEN, d, 'expected')));
let failed = 0;
for (const c of cases) {
const r = await check(c);
const status = r.problems.length ? 'FAIL' : 'ok ';
console.log(`${status} ${c} (${r.endpoints} endpoints, ${r.contexts} contexts)`);
for (const p of r.problems) console.log(` ${p}`);
if (r.problems.length) failed++;
}
process.exit(failed ? 1 : 0);
+47 -1
View File
@@ -74,7 +74,9 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
]);
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(readFileSync(join(dir, 'pjsip_selfphoneng.conf'), 'utf8')).toBe(
render(await loadSnapshot(db))['pjsip_selfphoneng.conf'],
);
expect(await getSetting(db, 'system.restartRequired')).toBe(false);
});
@@ -195,3 +197,47 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
);
});
});
describe.skipIf(!hasTestDb)('loadSnapshot', () => {
it('decrypts SIP passwords and resolves caller ID DIDs and admin flags', async () => {
const { db, drop } = await createTestDb();
try {
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
} finally {
await drop();
}
});
});
+6 -3
View File
@@ -36,9 +36,11 @@ Rules for `render()`:
4. If nothing changed and not `force`: record a `generator_runs` row and return.
5. Copy the current files to `/etc/asterisk/selfphoneng/.prev/` (keep one previous version).
6. For each changed file, write `<name>.tmp` then `rename()` it over the old one (atomic).
7. Work out reload commands from the changed files (§3). If the PJSIP
7. Work out reload commands from the changed files (§3), or from every file when `force`. If the PJSIP
transport sections changed, don't reload PJSIP. Set
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3).
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3). Exception: the very first
apply (no stored `system.transportHash`) reloads PJSIP normally, since it only adds transports to an
Asterisk that booted with none.
8. Send each command as AMI `Action: Command`. If AMI is down, mark the run
`ok=false, error='ami-disconnected'`. The AMI reconnect handler calls `apply({force:true})` later.
9. After `queue reload`: re-send every paused queue member (`QueuePause`), because a reload resets pause state.
@@ -197,7 +199,8 @@ set_var = SP_CLASS=2 ; internal=0 local=1 national=2 international
set_var = SP_OUTCID=13125551234 ; omit if outbound_caller_id_did_id null
set_var = SP_E911CID=13125551234 ; omit if emergency_caller_id_did_id null
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal ; verify inheritance prefix works in set_var (AGENTS rule 10)
set_var = __SP_DIRECTION=internal ; loads as variable "__SP_DIRECTION" (checked on 24.0.0-rc2); inheritance to
; dialed channels still to be proven by a call test (M2-T6)
[101-auth]
type = auth
+2 -1
View File
@@ -14,7 +14,8 @@
"test": "vitest run",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck"
"typecheck": "pnpm -r typecheck",
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",