trunks: CRUD API, PJSIP trunk config, live registration status, Trunks page

voip.ms-first trunk form with the portal checklist, POP server list
and Test button; passwords are write-only and encrypted. Registration
and IP-auth trunks render per 03 §5.3 (TCP/TLS add ;transport=). The
API refuses a server name that doesn't resolve and transports that are
off, because either makes Asterisk drop the trunk.

Live state: registration from PJSIPShowRegistrationsOutbound (re-read
on every Registry event: two sub-accounts on one server share
Username/Domain), reachability from PJSIPShowEndpoint and trunk
ContactStatus (static contacts aren't in PJSIPShowContacts). Trunk
state is admin-only. Dashboard gets a "Trunks up" card.

trunk-test.ts (in pnpm test:asterisk) runs a fake voip.ms (second
Asterisk): the good trunk registers, a wrong password is Rejected,
qualify reaches the provider; its recorded AMI drives the unit tests.
scripts/e2e/m3-t1.mjs does the same through the UI. The acceptance
check with a real voip.ms sub-account is still to do.

Tests: settle() now awaits an apply instead of a fixed sleep (flaked
under full-suite load).

Task: M3-T1
This commit is contained in:
iamdoubz committed 2026-10-17 19:42:14 -05:00
1 parent c0c4036887
commit 1b7f60b5f2
48 files changed
+2749 -33

No files matched your search

+36 -1
View File
@@ -3,7 +3,7 @@
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 { dids, extensions, trunks, users } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getAllSettings } from '../services/settings.ts';
@@ -28,12 +28,30 @@ export type SnapExtension = {
export type SnapDid = { id: string; number: string };
export type SnapTrunk = {
id: string;
name: string;
enabled: boolean;
serverHost: string;
serverPort: number;
transport: 'udp' | 'tcp' | 'tls';
authMode: 'registration' | 'ip';
username: string | null;
password: string | null; // decrypted
fromUser: string | null;
fromDomain: string | null;
codecs: string[];
srtp: boolean;
maxChannels: number | null;
};
export type Snapshot = {
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
today: string;
settings: Awaited<ReturnType<typeof getAllSettings>>;
extensions: SnapExtension[];
dids: SnapDid[]; // M3 adds routing fields
trunks: SnapTrunk[];
};
export function todayIn(timezone: string, now = new Date()): string {
@@ -58,10 +76,27 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
const didRows = await db.select({ id: dids.id, number: dids.number }).from(dids);
const trunkRows = await db.select().from(trunks);
return {
today: todayIn(settings['general.timezone'], now),
settings,
dids: didRows,
trunks: trunkRows.map((t) => ({
id: t.id,
name: t.name,
enabled: t.enabled,
serverHost: t.serverHost,
serverPort: t.serverPort,
transport: t.transport,
authMode: t.authMode,
username: t.username,
password: t.passwordEnc ? decrypt(t.passwordEnc) : null,
fromUser: t.fromUser,
fromDomain: t.fromDomain,
codecs: t.codecs,
srtp: t.srtp,
maxChannels: t.maxChannels,
})),
extensions: rows.map(({ ext, role, outCid, e911Cid }) => ({
id: ext.id,
number: ext.number,