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

+2
View File
@@ -14,6 +14,7 @@ import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
import { liveRoutes } from './live/hub.ts';
declare module 'fastify' {
@@ -79,6 +80,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(userRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
app.register(liveRoutes);
if (existsSync(publicDir)) {
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,4 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
@@ -0,0 +1,113 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
[sp-vm-direct]
exten => _X.,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,104 @@
; 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
; ── trunk "Carrier B IP auth" ──
[trunk-a1b2c3d4-0000-4000-8000-000000000001]
type = aor
contact = sip:203.0.113.10:5060
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000001]
type = endpoint
transport = transport-udp
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001
disallow = all
allow = alaw,ulaw
aors = trunk-a1b2c3d4-0000-4000-8000-000000000001
from_domain = 203.0.113.10
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-a1b2c3d4-0000-4000-8000-000000000001-identify]
type = identify
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000001
match = 203.0.113.10
; ── trunk "Carrier C evilinjected = yes" ──
[trunk-a1b2c3d4-0000-4000-8000-000000000002-auth]
type = auth
auth_type = userpass
username = acct.c-1
password = p@ss w0rd!#
[trunk-a1b2c3d4-0000-4000-8000-000000000002-reg]
type = registration
transport = transport-tcp
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000002-auth
server_uri = sip:example.net:5080;transport=tcp
client_uri = sip:acct.c-1@example.net:5080;transport=tcp
contact_user = acct.c-1
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000002
[trunk-a1b2c3d4-0000-4000-8000-000000000002]
type = aor
contact = sip:example.net:5080;transport=tcp
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000002]
type = endpoint
transport = transport-tcp
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002
disallow = all
allow = g722,ulaw
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000002-auth
aors = trunk-a1b2c3d4-0000-4000-8000-000000000002
from_user = 15551230000
from_domain = example.net
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-a1b2c3d4-0000-4000-8000-000000000002-identify]
type = identify
endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000002
match = example.net
@@ -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,32 @@
{
"settings": { "sip.tcpEnabled": true },
"trunks": [
{
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"name": "Carrier B (IP auth)",
"provider": "generic",
"serverHost": "203.0.113.10",
"authMode": "ip",
"username": null,
"password": null,
"codecs": ["alaw", "ulaw"]
},
{
"id": "a1b2c3d4-0000-4000-8000-000000000002",
"name": "Carrier C; [evil]\ninjected = yes",
"serverHost": "example.net",
"serverPort": 5080,
"transport": "tcp",
"username": "acct.c-1",
"password": "p@ss w0rd!#",
"fromUser": "15551230000",
"fromDomain": "example.net",
"codecs": ["g722", "ulaw"]
},
{
"id": "a1b2c3d4-0000-4000-8000-000000000003",
"name": "Old provider",
"enabled": false
}
]
}
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,5 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_101=2
@@ -0,0 +1,116 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
[sp-vm-direct]
exten => _X.,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,0,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
; ─── Filled in by later milestones
[sp-emergency-dial] ; M3-T4 (03 §6.14)
exten => pending,1,Hangup()
[sp-featurecodes] ; M6-T7 (03 §6.13)
exten => pending,1,Hangup()
[sp-outbound-routes] ; M3-T3 (03 §6.12)
exten => pending,1,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,147 @@
; 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 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
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = TestPassword0000000000AB
[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
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = TestPassword0000000000AB
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── trunk "voip.ms" ──
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth]
type = auth
auth_type = userpass
username = 123456_pbx
password = TrunkPassw0rd
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg]
type = registration
transport = transport-udp
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
server_uri = sip:chicago1.voip.ms:5060
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
contact_user = 123456_pbx
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = aor
contact = sip:chicago1.voip.ms:5060
qualify_frequency = 60
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
aors = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
from_user = 123456_pbx
from_domain = chicago1.voip.ms
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify]
type = identify
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
match = chicago1.voip.ms
@@ -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,4 @@
{
"extensions": [{ "number": "101", "name": "Dan Doe" }],
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
}
+18
View File
@@ -19,6 +19,7 @@ export function loadCase(name: string): Snapshot {
settings?: Record<string, unknown>;
extensions?: Partial<Snapshot['extensions'][number]>[];
dids?: Snapshot['dids'];
trunks?: Partial<Snapshot['trunks'][number]>[];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -29,6 +30,23 @@ export function loadCase(name: string): Snapshot {
today: raw.today ?? '2026-10-17',
settings,
dids: raw.dids ?? [],
trunks: (raw.trunks ?? []).map((t) => ({
name: 'voip.ms',
enabled: true,
serverHost: 'chicago1.voip.ms',
serverPort: 5060,
transport: 'udp',
authMode: 'registration',
username: '123456_pbx',
password: 'TrunkPassw0rd',
fromUser: null,
fromDomain: null,
codecs: ['ulaw'],
srtp: false,
maxChannels: null,
...t,
id: t.id!,
})),
extensions: (raw.extensions ?? []).map((e, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
number: '101',
+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,
+76 -2
View File
@@ -1,6 +1,6 @@
// 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 { byNumber, type SnapExtension, type SnapTrunk, type Snapshot } from './load.ts';
import * as s from './sanitize.ts';
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
@@ -154,9 +154,83 @@ function extensionSections(e: SnapExtension): string {
return out.join('\n\n');
}
/** One trunk (03 §5.3). Sections are named by id so a rename never touches Asterisk objects. */
function trunkSections(t: SnapTrunk, snap: Snapshot): string {
const id = s.ident(t.id);
const name = s.name(t.name);
const on = { tcp: snap.settings['sip.tcpEnabled'], tls: snap.settings['sip.tlsEnabled'] };
if (t.transport !== 'udp' && !on[t.transport])
throw new s.SanitizeError(`trunk ${name}: transport ${t.transport} is turned off`);
const transport = `transport-${t.transport}`;
const host = s.host(t.serverHost);
const target = `${host}:${s.digits(String(t.serverPort))}${t.transport === 'udp' ? '' : `;transport=${t.transport}`}`;
const reg = t.authMode === 'registration';
const user = t.username ? s.sipUser(t.username) : null;
if (reg && (!user || !t.password))
throw new s.SanitizeError(`trunk ${name}: registration needs a username and password`);
const fromUser = t.fromUser ? s.sipUser(t.fromUser) : user;
const out = [`; ── trunk "${name}" ──`];
if (reg)
out.push(
section(`trunk-${id}-auth`, [
'type = auth',
'auth_type = userpass',
`username = ${user}`,
`password = ${s.typedSecret(t.password!)}`,
]),
section(`trunk-${id}-reg`, [
'type = registration',
`transport = ${transport}`,
`outbound_auth = trunk-${id}-auth`,
`server_uri = sip:${target}`,
`client_uri = sip:${user}@${target}`,
`contact_user = ${user}`,
'retry_interval = 60',
'forbidden_retry_interval = 600',
'fatal_retry_interval = 600',
'max_retries = 10000',
'expiration = 300',
'line = yes',
`endpoint = trunk-${id}`,
]),
);
out.push(
section(`trunk-${id}`, ['type = aor', `contact = sip:${target}`, 'qualify_frequency = 60']),
section(`trunk-${id}`, [
'type = endpoint',
`transport = ${transport}`,
`context = sp-from-trunk-${id}`,
'disallow = all',
`allow = ${t.codecs.map(s.codec).join(',')}`,
reg && `outbound_auth = trunk-${id}-auth`,
`aors = trunk-${id}`,
fromUser && `from_user = ${fromUser}`,
`from_domain = ${t.fromDomain ? s.host(t.fromDomain) : host}`,
'direct_media = no',
'rtp_symmetric = yes',
'force_rport = yes',
'rewrite_contact = yes',
'send_rpid = yes', // carries our caller ID to the provider
'trust_id_inbound = yes',
'dtmf_mode = rfc4733',
t.srtp && t.transport === 'tls' && 'media_encryption = sdes',
]),
section(`trunk-${id}-identify`, [
'type = identify',
`endpoint = trunk-${id}`,
`match = ${host}`,
]),
);
return out[0] + '\n' + out.slice(1).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';
const trunks = snap.trunks
.filter((t) => t.enabled)
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
.map((t) => trunkSections(t, snap));
return [renderTransports(snap), ...exts, ...trunks].join('\n\n') + '\n';
}
export function renderRtp(snap: Snapshot): string {
+6
View File
@@ -24,6 +24,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
/** Generated SIP passwords. */
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
/** Passwords typed by an admin (trunks): no ';' (comment), no line breaks, no edge spaces. */
export const typedSecret = check(/^[^\s;](?:[^\r\n;]*[^\s;])?$/, 'password', 128);
/** Codec names. */
export const codec = check(/^(?:ulaw|alaw|g722)$/, 'codec', 4);
/** Voicemail / conference PINs. */
export const pin = check(/^[0-9]{4,10}$/, 'pin', 10);
/** Trunk server host: a hostname or an IPv4 address. */
+2 -2
View File
@@ -40,7 +40,7 @@ const generator = createGenerator({
type: 'system.configApplied',
data: { ok: r.ok, error: r.error, restartRequired: r.restartRequired },
});
void live.loadExtensions().catch((err) => app.log.error({ err }, 'live reload failed'));
void live.loadFromDb().catch((err) => app.log.error({ err }, 'live reload failed'));
},
});
app.decorate('generator', generator);
@@ -57,7 +57,7 @@ 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 });
await live.loadExtensions();
await live.loadFromDb();
// Every (re)connect: Asterisk may have restarted, so push the full config again, then read its
// state (after the apply, so hints for new extensions exist). TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => {
+5 -2
View File
@@ -29,10 +29,13 @@ export async function liveRoutes(app: FastifyInstance) {
const keyOf = (m: LiveMessage) =>
m.type === 'extension.updated' || m.type === 'extension.removed'
? `extension:${m.data.number}` // a removal replaces a pending update and vice versa
: m.type;
: m.type === 'trunk.updated' || m.type === 'trunk.removed'
? `trunk:${m.data.trunkId}`
: m.type;
app.live.on('message', (m) => {
for (const c of clients) c.pending.set(keyOf(m), m);
const adminOnly = m.type.startsWith('trunk.');
for (const c of clients) if (c.admin || !adminOnly) c.pending.set(keyOf(m), m);
});
const viewFor = (c: Client, m: LiveMessage): LiveMessage =>
+114 -5
View File
@@ -9,11 +9,12 @@ import type {
ExtensionState,
LiveMessage,
LiveSnapshot,
TrunkLive,
} from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, userRuntimeState } from '../db/schema.ts';
import { extensions, trunks, userRuntimeState } from '../db/schema.ts';
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
export function stateFromStatus(status: number): ExtensionState {
@@ -35,15 +36,31 @@ function contactStatus(s: string | undefined): ContactLive['status'] {
export const extOfEndpoint = (endpoint: string): string | null =>
/^(\d{3,6})(?:-web)?$/.exec(endpoint)?.[1] ?? null;
/** "trunk-<uuid>", "trunk-<uuid>-reg" → the trunk id. */
export const trunkOfObject = (name: string): string | null =>
/^trunk-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:-reg)?$/.exec(
name,
)?.[1] ?? null;
/** OutboundRegistrationDetail Status: Registered, Rejected, Unregistered, Stopped, … */
function registrationFrom(s: string | undefined): TrunkLive['registration'] {
if (s === 'Registered') return 'registered';
if (s === 'Rejected') return 'rejected';
if (s === 'Unregistered' || s === 'Stopped') return 'unregistered';
return 'unknown';
}
/** What LiveState needs from AmiClient (tests pass a fake). */
type Ami = {
actionList: AmiClient['actionList'];
action(fields: Record<string, string>): Promise<unknown>;
on(event: string, fn: (m: AmiMessage) => void): unknown;
};
export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
private readonly exts = new Map<string, ExtensionLive & { contacts: ContactLive[] }>();
private amiConnected = false;
private readonly trunks = new Map<string, TrunkLive>();
amiConnected = false;
private readonly db: Db;
private readonly ami: Ami;
@@ -58,6 +75,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
snapshot(admin: boolean): LiveSnapshot {
return {
extensions: [...this.exts.values()].map((e) => this.view(e, admin)),
...(admin ? { trunks: [...this.trunks.values()].map((t) => ({ ...t })) } : {}),
ami: { connected: this.amiConnected },
};
}
@@ -81,8 +99,87 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
this.emit('message', { type: 'extension.updated', data: e });
}
/** Extensions from the DB (call after config changes too, so new users appear). */
async loadExtensions(): Promise<void> {
/** Extensions and trunks from the DB (call after config changes too, so new ones appear). */
async loadFromDb(): Promise<void> {
await this.loadExtensions();
await this.loadTrunks();
}
private async loadTrunks(): Promise<void> {
const rows = await this.db
.select({ id: trunks.id })
.from(trunks)
.where(eq(trunks.enabled, true));
const seen = new Set(rows.map((r) => r.id));
for (const { id } of rows)
if (!this.trunks.has(id)) {
this.trunks.set(id, {
trunkId: id,
registration: 'unknown',
reachable: false,
lastChange: new Date().toISOString(),
});
this.trunkChanged(id);
}
for (const id of [...this.trunks.keys()])
if (!seen.has(id)) {
this.trunks.delete(id);
this.emit('message', { type: 'trunk.removed', data: { trunkId: id } });
}
}
private trunkChanged(id: string, patch: Partial<TrunkLive> = {}) {
const t = this.trunks.get(id);
if (!t) return;
const changed = Object.entries(patch).some(([k, v]) => t[k as keyof TrunkLive] !== v);
if (changed) Object.assign(t, patch, { lastChange: new Date().toISOString() });
if (changed || !Object.keys(patch).length)
this.emit('message', { type: 'trunk.updated', data: { ...t } });
}
/**
* Registration status of every trunk. Registry events can't tell two sub-accounts on the
* same server apart (same Username and Domain), so any Registry event re-reads the list.
* The response carries the auth sections too, password included: never log it.
*/
async refreshRegistrations(): Promise<void> {
const list = await this.ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' });
const status = new Map<string, TrunkLive['registration']>();
for (const m of list) {
if (field(m, 'Event') !== 'OutboundRegistrationDetail') continue;
const id = trunkOfObject(field(m, 'ObjectName') ?? '');
if (id) status.set(id, registrationFrom(field(m, 'Status')));
}
for (const id of this.trunks.keys())
this.trunkChanged(id, { registration: status.get(id) ?? 'unknown' });
}
/** Static trunk contacts aren't in PJSIPShowContacts; the endpoint detail has their status. */
private async refreshReachable(id: string): Promise<number | null> {
const list = await this.ami.actionList({
Action: 'PJSIPShowEndpoint',
Endpoint: `trunk-${id}`,
});
const c = list.find((m) => field(m, 'Event') === 'ContactStatusDetail');
const reachable = field(c ?? {}, 'Status') === 'Reachable';
this.trunkChanged(id, { reachable });
const rtt = Number(field(c ?? {}, 'RoundtripUsec'));
return reachable && rtt > 0 ? Math.round(rtt / 1000) : null;
}
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(id: string, waitMs = 1500): Promise<{ live: TrunkLive; rttMs: number | null }> {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
if (!live) throw new Error('trunk is not loaded (disabled?)');
return { live: { ...live }, rttMs };
}
/** Extensions from the DB. */
private async loadExtensions(): Promise<void> {
const rows = await this.db
.select({ number: extensions.number, name: extensions.name, dnd: userRuntimeState.dnd })
.from(extensions)
@@ -111,7 +208,9 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** Initial load on every AMI connect (04 §5.1). */
async loadFromAsterisk(): Promise<void> {
await this.loadExtensions();
await this.loadFromDb();
await this.refreshRegistrations();
await Promise.all([...this.trunks.keys()].map((id) => this.refreshReachable(id)));
for (const e of this.exts.values()) e.contacts = [];
const [states, contacts] = await Promise.all([
this.ami.actionList({ Action: 'ExtensionStateList' }),
@@ -142,6 +241,9 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
return this.onExtensionStatus(m, true);
case 'ContactStatus':
return this.onContactStatus(m);
case 'Registry':
if (field(m, 'ChannelType') === 'PJSIP')
void this.refreshRegistrations().catch(() => undefined); // AMI dropped: reconnect reloads
}
}
@@ -155,6 +257,13 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
private onContactStatus(m: AmiMessage) {
const endpoint = field(m, 'EndpointName') ?? field(m, 'AOR') ?? '';
const trunk = trunkOfObject(endpoint);
if (trunk) {
const s = field(m, 'ContactStatus');
if (s === 'Reachable' || s === 'Unreachable' || s === 'Removed')
this.trunkChanged(trunk, { reachable: s === 'Reachable' });
return;
}
const e = this.exts.get(extOfEndpoint(endpoint) ?? '');
if (!e) return;
const uri = field(m, 'URI') ?? '';
+215
View File
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/trunks (04 §7.3, 06 §2). Admin only. Passwords are write-only.
import { lookup } from 'node:dns/promises';
import { isIPv4 } from 'node:net';
import { arrayContains, asc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
TrunkCreateSchema,
TrunkUpdateSchema,
type ListResponse,
type TrunkDto,
type TrunkTestResult,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
type TrunkRow = typeof trunks.$inferSelect;
const toDto = (t: TrunkRow): TrunkDto => ({
id: t.id,
name: t.name,
provider: t.provider,
enabled: t.enabled,
serverHost: t.serverHost,
serverPort: t.serverPort,
transport: t.transport,
authMode: t.authMode,
username: t.username,
fromUser: t.fromUser,
fromDomain: t.fromDomain,
codecs: t.codecs as TrunkDto['codecs'],
srtp: t.srtp,
maxChannels: t.maxChannels,
voipmsApiUsername: t.voipmsApiUsername,
hasPassword: t.passwordEnc !== null,
hasApiPassword: t.voipmsApiPasswordEnc !== null,
createdAt: t.createdAt.toISOString(),
});
const IdParams = z.object({ id: z.uuid() });
async function getTrunk(db: Db, id: string): Promise<TrunkRow> {
const [t] = await db.select().from(trunks).where(eq(trunks.id, id));
if (!t) throw notFound('Trunk not found');
return t;
}
/** Things the generator can't render or Asterisk can't load: refuse them up front. */
async function checkUsable(db: Db, t: Pick<TrunkRow, 'serverHost' | 'transport'>) {
if (t.transport === 'tcp' && !(await getSetting(db, 'sip.tcpEnabled')))
throw badRequest('Turn on SIP over TCP in Settings → SIP first');
if (t.transport === 'tls' && !(await getSetting(db, 'sip.tlsEnabled')))
throw badRequest('Turn on SIP over TLS in Settings → SIP first');
// Asterisk refuses to load a trunk whose server name doesn't resolve (identify fails).
if (!isIPv4(t.serverHost))
await lookup(t.serverHost, { family: 4 }).catch(() => {
throw badRequest(`Can't find the server ${t.serverHost}. Check the spelling.`);
});
}
const isUniqueViolation = (e: unknown) =>
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
export async function trunkRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
app.get('/api/trunks', async (): Promise<ListResponse<TrunkDto>> => {
const rows = await db.select().from(trunks).orderBy(asc(trunks.name));
return { items: rows.map(toDto), nextCursor: null };
});
app.get('/api/trunks/:id', async (req) => {
const { id } = IdParams.parse(req.params);
return toDto(await getTrunk(db, id));
});
app.post('/api/trunks', async (req, reply) => {
const { password, voipmsApiPassword, ...body } = TrunkCreateSchema.parse(req.body);
const values = {
...body,
voipmsApiUsername: body.voipmsApiUsername || null,
passwordEnc: password ? encrypt(password) : null,
voipmsApiPasswordEnc: voipmsApiPassword ? encrypt(voipmsApiPassword) : null,
};
await checkUsable(db, { serverHost: body.serverHost, transport: body.transport ?? 'udp' });
let created: TrunkRow;
try {
[created] = (await db.insert(trunks).values(values).returning()) as [TrunkRow];
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`A trunk named ${body.name} already exists`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'trunk',
entityId: created.id,
summary: `Created trunk ${created.name}`,
diff: { after: toDto(created) },
});
// TODO(M3-T3): create the default outbound routes with the first trunk (06 §4).
app.generator.requestApply('api-change');
return reply.code(201).send(toDto(created));
});
app.patch('/api/trunks/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const { password, voipmsApiPassword, ...body } = TrunkUpdateSchema.parse(req.body);
const before = await getTrunk(db, id);
const merged = { ...before, ...body };
if (merged.srtp && merged.transport !== 'tls') throw badRequest('SRTP needs the TLS transport');
if (
merged.authMode === 'registration' &&
(!merged.username || !(password || before.passwordEnc))
)
throw badRequest('Registration needs a username and password');
if (body.serverHost !== undefined || body.transport !== undefined)
await checkUsable(db, merged);
const set = {
...body,
...(password ? { passwordEnc: encrypt(password) } : {}),
// An empty API password clears it.
...(voipmsApiPassword !== undefined
? { voipmsApiPasswordEnc: voipmsApiPassword ? encrypt(voipmsApiPassword) : null }
: {}),
};
let after: TrunkRow;
try {
[after] = (await db.update(trunks).set(set).where(eq(trunks.id, id)).returning()) as [
TrunkRow,
];
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`A trunk named ${body.name} already exists`);
throw e;
}
const b = toDto(before);
const a = toDto(after);
const changed = (Object.keys(a) as (keyof TrunkDto)[]).filter(
(k) => JSON.stringify(b[k]) !== JSON.stringify(a[k]),
);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'trunk',
entityId: id,
summary: `Updated trunk ${after.name}${password ? ' (password changed)' : ''}${changed.length ? ` (${changed.join(', ')})` : ''}`,
diff: {
before: Object.fromEntries(changed.map((k) => [k, b[k]])),
after: Object.fromEntries(changed.map((k) => [k, a[k]])),
},
});
app.generator.requestApply('api-change');
return a;
});
app.delete('/api/trunks/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
const usedBy = [
...(await db.select({ n: dids.number }).from(dids).where(eq(dids.trunkId, id))).map(
(r) => `number ${r.n}`,
),
...(
await db
.select({ n: outboundRoutes.name })
.from(outboundRoutes)
.where(arrayContains(outboundRoutes.trunkIds, [id]))
).map((r) => `outbound rule "${r.n}"`),
];
if (usedBy.length)
throw conflict(`${t.name} is still used by ${usedBy.join(', ')}`, { usedBy });
await db.delete(trunks).where(eq(trunks.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'trunk',
entityId: id,
summary: `Deleted trunk ${t.name}`,
diff: { before: toDto(t) },
});
app.generator.requestApply('api-change');
return { ok: true };
});
app.post('/api/trunks/:id/test', async (req): Promise<TrunkTestResult> => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
const { live, rttMs } = await app.live.testTrunk(id);
const reg = t.authMode === 'registration';
const detail =
reg && live.registration === 'registered'
? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
});
}
+11 -3
View File
@@ -15,6 +15,7 @@ import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
import { EventEmitter } from 'node:events';
import { createGenerator } from '../src/generator/index.ts';
import { LiveState } from '../src/live/state.ts';
import type { AmiMessage } from '../src/ami/events.ts';
import { createTestDb } from './db.ts';
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
@@ -27,8 +28,15 @@ export function fakeAmi() {
events,
on: events.on.bind(events),
off: events.off.bind(events),
async actionList(): Promise<Record<string, string>[]> {
return [];
/** Canned list responses by Action name. */
lists: {} as Record<string, AmiMessage[]>,
actions: [] as Record<string, string>[],
async actionList(f: Record<string, string>): Promise<AmiMessage[]> {
return this.lists[f.Action!] ?? [];
},
async action(f: Record<string, string>) {
this.actions.push(f);
return { Response: 'Success' };
},
isConnected() {
return this.connected;
@@ -70,7 +78,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
dir: genDir,
log: app.log,
debounceMs: 20,
onApplied: () => void live.loadExtensions(),
onApplied: () => void live.loadFromDb().catch(() => {}), // may land after close()
}),
);
extend?.(app);
+1
View File
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
today: '2026-10-17',
settings,
dids: [],
trunks: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
+233
View File
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3 trunk checks against a fake provider: a second Asterisk plays voip.ms (a sub-account that
// registers), our generated config is the PBX. Everything runs on a private Docker bridge network.
// M3-T1: the good trunk registers, the one with a wrong password is rejected, and the AMI
// session can be recorded for the live-state tests.
// Usage: node --conditions=source apps/api/test/asterisk/trunk-test.ts [--record <file>]
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const RECORD = process.argv.includes('--record')
? process.argv[process.argv.indexOf('--record') + 1]
: undefined;
const NET = 'sp-trunk';
const PBX = 'sp-trunk-pbx';
const PROVIDER = 'sp-trunk-provider';
const AMI_PORT = 15039;
const AMI_SECRET = 'trunk-test-secret';
const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const log = (...a: unknown[]) => console.log(`[trunk-test]`, ...a);
function snapshot(): Snapshot {
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
const trunk = (id: string, name: string, password: string) => ({
id,
name,
enabled: true,
serverHost: PROVIDER,
serverPort: 5060,
transport: 'udp' as const,
authMode: 'registration' as const,
username: SUB.user,
password,
fromUser: null,
fromDomain: null,
codecs: ['ulaw'],
srtp: false,
maxChannels: null,
});
return {
today: '2026-10-17',
settings,
dids: [],
extensions: [],
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
};
}
/** The fake voip.ms: one sub-account that may register. */
const PROVIDER_PJSIP = `[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[${SUB.user}]
type = endpoint
context = provider-in
disallow = all
allow = ulaw
auth = ${SUB.user}-auth
aors = ${SUB.user}
[${SUB.user}-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[${SUB.user}]
type = aor
max_contacts = 1
remove_existing = yes
`;
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
for (let i = 0; i < seconds; i++) {
if (await fn()) return;
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
async function main() {
const dir = mkdtempSync(join(tmpdir(), 'sp-trunk-'));
const pbxConf = join(dir, 'pbx');
const provConf = join(dir, 'provider');
mkdirSync(pbxConf);
mkdirSync(provConf);
const files = render(snapshot());
for (const f of GENERATED_FILES) {
writeFileSync(join(pbxConf, f), files[f]);
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? PROVIDER_PJSIP : '');
}
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
const problems: string[] = [];
let recorder: Socket | undefined;
let raw = '';
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
username: 'selfphoneng',
secret: AMI_SECRET,
logger: { info() {}, warn() {}, error() {}, debug() {} },
backoffMs: [500],
});
const asterisk = (name: string, conf: string, extra: string[]) =>
docker(
'run',
'-d',
'--rm',
'--name',
name,
'--network',
NET,
...extra,
'-e',
`AMI_SECRET=${AMI_SECRET}`,
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
const booted = async (name: string) =>
waitFor(
`${name} to boot`,
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
try {
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'create', NET);
const prov = asterisk(PROVIDER, provConf, []);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
await booted(PROVIDER);
// The PBX starts second: its identify sections resolve the provider's name at load.
const pbx = asterisk(PBX, pbxConf, [
'-p',
`127.0.0.1:${AMI_PORT}:5038`,
'-v',
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
]);
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
await booted(PBX);
ami.start();
await new Promise((r) => ami.once('connected', r));
if (RECORD) {
recorder = new Socket();
recorder.setEncoding('latin1');
recorder.on('data', (d: string) => (raw += d));
await new Promise<void>((r) => recorder!.connect(AMI_PORT, '127.0.0.1', r));
recorder.write(
`Action: Login\r\nUsername: selfphoneng\r\nSecret: ${AMI_SECRET}\r\nEvents: on\r\nActionID: rec-1\r\n\r\n`,
);
}
const regs = async () =>
Object.fromEntries(
(await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' }))
.filter((m) => m.Event === 'OutboundRegistrationDetail')
.map((m) => [m.ObjectName, m.Status]),
);
await waitFor('both registrations to settle', async () => {
const r = await regs();
return r[`trunk-${GOOD}-reg`] === 'Registered' && r[`trunk-${BAD}-reg`] === 'Rejected';
}).catch(async (e: Error) => {
throw new Error(`${e.message}: ${JSON.stringify(await regs())}`);
});
log('good trunk Registered, wrong password Rejected');
const onProvider = docker('exec', PROVIDER, 'asterisk', '-rx', 'pjsip show contacts').out;
if (!onProvider.includes(`${SUB.user}/sip:${SUB.user}@`))
problems.push(`provider has no contact for ${SUB.user}:\n${onProvider}`);
// Qualify (OPTIONS) reaches the provider: the trunk AOR's contact turns Avail.
await waitFor('trunk contact to be reachable', async () =>
/trunk-7d3e9a52[^\n]*Avail/.test(await ami.command('pjsip show contacts')),
);
recorder?.write(
'Action: PJSIPShowRegistrationsOutbound\r\nActionID: rec-2\r\n\r\n' +
// Static trunk contacts aren't listed here ("No Contacts found")...
'Action: PJSIPShowContacts\r\nActionID: rec-3\r\n\r\n' +
// ...but the endpoint detail carries their qualify status.
`Action: PJSIPShowEndpoint\r\nEndpoint: trunk-${GOOD}\r\nActionID: rec-4\r\n\r\n`,
);
await sleep(1000);
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l));
problems.push(...errors.map((l) => `pbx log: ${l.trim()}`));
} catch (e) {
problems.push((e as Error).message);
} finally {
if (RECORD && raw) {
writeFileSync(RECORD, raw, 'latin1');
log(`recorded ${raw.length} bytes of AMI to ${RECORD}`);
}
recorder?.destroy();
ami.stop();
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
if (problems.length) {
console.log('FAIL trunks');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log('ok trunks (registration, rejection, qualify)');
}
await main();
+5 -1
View File
@@ -20,7 +20,11 @@ describe.skipIf(!hasTestDb)('extensions API', () => {
cookie?: string,
) => Promise<LightMyRequestResponse>;
const pjsip = () => readFileSync(join(t.genDir, 'pjsip_selfphoneng.conf'), 'utf8');
const settle = () => new Promise((r) => setTimeout(r, 150)); // debounce is 20 ms in tests
// Past the 20 ms test debounce, then wait for that apply (applies run one at a time).
const settle = async () => {
await new Promise((r) => setTimeout(r, 60));
await t.app.generator.apply({ trigger: 'test' });
};
let userId: string;
let extId: string;
+440
View File
@@ -0,0 +1,440 @@
Asterisk Call Manager/13.0.0
Response: Success
ActionID: rec-1
Message: Authentication accepted
Event: FullyBooted
Privilege: system,all
Uptime: 0
LastReload: 0
Status: Fully Booted
Event: SuccessfulAuth
Privilege: security,all
EventTV: 2026-10-18T00:29:21.485+0000
Severity: Informational
Service: AMI
EventVersion: 1
AccountID: selfphoneng
SessionID: 0x7f13f4000e10
LocalAddress: IPV4/TCP/0.0.0.0/5038
RemoteAddress: IPV4/TCP/172.21.0.1/47102
UsingPassword: 0
SessionTV: 2026-10-18T00:29:21.485+0000
Event: PeerStatus
Privilege: system,all
ChannelType: PJSIP
Peer: PJSIP/trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6
PeerStatus: Reachable
Event: DeviceStateChange
Privilege: call,all
Device: PJSIP/trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6
State: NOT_INUSE
Event: ContactStatus
Privilege: system,all
URI: sip:sp-trunk-provider:5060
ContactStatus: Reachable
AOR: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6
EndpointName: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6
RoundtripUsec: 2798
Event: PeerStatus
Privilege: system,all
ChannelType: PJSIP
Peer: PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
PeerStatus: Reachable
Event: DeviceStateChange
Privilege: call,all
Device: PJSIP/trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
State: NOT_INUSE
Event: ContactStatus
Privilege: system,all
URI: sip:sp-trunk-provider:5060
ContactStatus: Reachable
AOR: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
RoundtripUsec: 1448
Event: Registry
Privilege: system,all
ChannelType: PJSIP
Username: sip:123456_pbx@sp-trunk-provider:5060
Domain: sip:sp-trunk-provider:5060
Status: Rejected
Event: Registry
Privilege: system,all
ChannelType: PJSIP
Username: sip:123456_pbx@sp-trunk-provider:5060
Domain: sip:sp-trunk-provider:5060
Status: Registered
Response: Success
ActionID: rec-2
EventList: start
Message: Following are Events for each Outbound registration
Event: OutboundRegistrationDetail
ActionID: rec-2
ObjectType: registration
ObjectName: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6-reg
MaxRetries: 10000
ClientUri: sip:123456_pbx@sp-trunk-provider:5060
SecurityNegotiation: no
AuthRejectionPermanent: true
ServerUri: sip:sp-trunk-provider:5060
MaxRandomInitialDelay: 10
SupportPath: false
RetryInterval: 60
ContactHeaderParams:
Expiration: 300
Transport: transport-udp
Line: true
ContactUser: 123456_pbx
Endpoint: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6
UserAgent:
ForbiddenRetryInterval: 600
OutboundAuth: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6-auth
OutboundProxy:
FatalRetryInterval: 600
SupportOutbound: no
Status: Rejected
NextReg: 0
Event: AuthDetail
ActionID: rec-2
ObjectType: auth
ObjectName: trunk-0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6-auth
OauthSecret:
PasswordDigest:
SupportedAlgorithmsUas: MD5
RefreshToken:
Username: 123456_pbx
Md5Cred:
Realm:
AuthType: userpass
Password: NotThePassword1
SupportedAlgorithmsUac: MD5
NonceLifetime: 32
OauthClientid:
Event: OutboundRegistrationDetail
ActionID: rec-2
ObjectType: registration
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg
MaxRetries: 10000
ClientUri: sip:123456_pbx@sp-trunk-provider:5060
SecurityNegotiation: no
AuthRejectionPermanent: true
ServerUri: sip:sp-trunk-provider:5060
MaxRandomInitialDelay: 10
SupportPath: false
RetryInterval: 60
ContactHeaderParams:
Expiration: 300
Transport: transport-udp
Line: true
ContactUser: 123456_pbx
Endpoint: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
UserAgent:
ForbiddenRetryInterval: 600
OutboundAuth: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
OutboundProxy:
FatalRetryInterval: 600
SupportOutbound: no
Status: Registered
NextReg: 0
Event: AuthDetail
ActionID: rec-2
ObjectType: auth
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
OauthSecret:
PasswordDigest:
SupportedAlgorithmsUas: MD5
RefreshToken:
Username: 123456_pbx
Md5Cred:
Realm:
AuthType: userpass
Password: ProviderSecret42
SupportedAlgorithmsUac: MD5
NonceLifetime: 32
OauthClientid:
Event: OutboundRegistrationDetailComplete
ActionID: rec-2
EventList: Complete
ListItems: 2
Registered: 1
NotRegistered: 1
Response: Error
ActionID: rec-3
Message: No Contacts found
Response: Success
ActionID: rec-4
EventList: start
Message: Following are Events for each object associated with the Endpoint
Event: EndpointDetail
ActionID: rec-4
ObjectType: endpoint
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
RpidImmediate: false
Webrtc: no
Ignore183WithoutSdp: false
SendAoc: false
DeviceStateBusyAt: 0
T38UdptlMaxdatagram: 0
CodecPrefsIncomingAnswer: prefer:pending, operation:intersect, keep:all, transcode:allow
SecurityNegotiation: no
DtlsRekey: 0
RtpPortEnd: 0
NamedPickupGroup:
DirectMediaMethod: invite
SendRpid: true
PickupGroup:
SdpSession: Asterisk
DtlsVerify: No
MessageContext:
Tenantid:
Mailboxes:
RecordOnFeature: automixmon
DtlsPrivateKey:
DtlsFingerprint: SHA-256
OverlapContext:
OutgoingCallOfferPref: remote_merge
RtpPortStart: 0
FromDomain: sp-trunk-provider
TimersSessExpires: 1800
NamedCallGroup:
DtlsCipher:
MediaEncryptionOptimistic: false
SuppressQ850ReasonHeaders: false
Aors: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
IdentifyBy: username,ip
CalleridPrivacy: allowed_not_screened
MwiSubscribeReplacesUnsolicited: no
CosAudio: 0
FollowEarlyMediaFork: true
Context: sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
RtpSymmetric: true
Transport: transport-udp
MohSuggest: default
T38Udptl: false
FaxDetect: false
T38BindUdptlToMediaAddress: false
TosVideo: 0
SrtpTag32: false
ReferBlindProgress: true
MaxAudioStreams: 1
Bundle: false
UseAvpf: false
CallGroup:
SendConnectedLine: yes
FaxDetectTimeout: 0
SdpOwner: -
ForceRport: true
CalleridTag:
RtpTimeoutHold: 0
SuppressMohOnSendonly: false
UsePtime: false
MediaAddress:
VoicemailExtension:
RtpTimeout: 0
StirShaken: no
SetVar:
ContactAcl:
PreferredCodecOnly: false
GeolocOutgoingCallProfile:
ForceAvp: false
RecordOffFeature: automixmon
FromUser: 123456_pbx
SendDiversion: true
SendHistoryInfo: false
T38UdptlIpv6: false
ToneZone:
Language:
AllowSubscribe: true
RtpIpv6: false
Callerid: <unknown>
MohPassthrough: false
CosVideo: 0
DtlsAutoGenerateCert: No
AsymmetricRtpCodec: false
GeolocIncomingCallProfile:
IceSupport: false
AggregateMwi: true
OneTouchRecording: false
MwiFromUser:
RedirectMethod: user
Accountcode:
CodecPrefsOutgoingAnswer: prefer:pending, operation:intersect, keep:all, transcode:allow
Allow: (ulaw)
RewriteContact: true
UserEqPhone: false
RtpEngine: asterisk
FollowRedirectMethods:
SubscribeContext:
NotifyEarlyInuseRinging: false
IncomingMwiMailbox:
IncomingCallOfferPref: local
AllowUnauthenticatedOptions: false
Auth:
DirectMediaGlareMitigation: none
TrustIdInbound: true
StirShakenProfile:
BindRtpToMediaAddress: false
DisableDirectMediaOnNat: false
MediaEncryption: no
MediaUseReceivedTransport: false
AllowOverlap: true
DtmfMode: rfc4733
OutboundAuth: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
TosAudio: 0
DtlsCertFile:
DtlsCaPath:
DtlsSetup: active
ConnectedLineMethod: invite
G726NonStandard: false
100rel: yes
Timers: yes
DirectMedia: false
Acl:
TimersMinSe: 90
TrustIdOutbound: false
SubMinExpiry: 0
RtcpMux: false
MaxVideoStreams: 1
AcceptMultipleSdpAnswers: false
CodecPrefsIncomingOffer: prefer:pending, operation:intersect, keep:all, transcode:allow
CodecPrefsOutgoingOffer: prefer:pending, operation:union, keep:all, transcode:allow
TrustConnectedLine: yes
SendPai: false
RtpKeepalive: 0
T38UdptlEc: none
T38UdptlNat: false
AllowTransfer: true
DtlsCaFile:
OutboundProxy:
InbandProgress: false
DeviceState: Not in use
ActiveChannels: 0
Event: AuthDetail
ActionID: rec-4
ObjectType: auth
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
OauthSecret:
PasswordDigest:
SupportedAlgorithmsUas: MD5
RefreshToken:
Username: 123456_pbx
Md5Cred:
Realm:
AuthType: userpass
Password: ProviderSecret42
SupportedAlgorithmsUac: MD5
NonceLifetime: 32
OauthClientid:
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
Event: TransportDetail
ActionID: rec-4
ObjectType: transport
ObjectName: transport-udp
ExternalSignalingHostname:
WebsocketWriteTimeout: 100
Cos: 0
TcpKeepaliveEnable: false
Protocol: udp
SymmetricTransport: false
Domain:
VerifyServer: No
LocalNet:
Bind: 0.0.0.0:5060
AllowWildcardCerts: No
TcpKeepaliveIntervalTime: 1
AsyncOperations: 1
ExternalMediaAddress:
Method: unspecified
ExternalSignalingAddress:
RequireClientCert: No
TcpKeepaliveIdleTime: 30
Tos: 0
CaListPath:
Cipher:
CertFile:
ExternalSignalingPort: 0
Password:
AllowReload: false
VerifyClient: No
TcpKeepaliveProbeCount: 5
PrivKeyFile:
CaListFile:
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
Event: AorDetail
ActionID: rec-4
ObjectType: aor
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
MinimumExpiration: 60
DefaultExpiration: 3600
QualifyTimeout: 3.000000
Qualify2xxOnly: false
Mailboxes:
SupportPath: false
RemoveUnavailable: false
VoicemailExtension:
MaxContacts: 0
AuthenticateQualify: false
Contacts: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37/sip:sp-trunk-provider:5060
MaximumExpiration: 7200
QualifyFrequency: 60
RemoveExisting: false
OutboundProxy:
TotalContacts: 1
ContactsRegistered: 0
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
Event: ContactStatusDetail
ActionID: rec-4
AOR: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
URI: sip:sp-trunk-provider:5060
UserAgent:
RegExpire: 0
Status: Reachable
RoundtripUsec: 1448
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
ID: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37@@8c01e6e618612f18f75f0f070ce7a9a9
AuthenticateQualify: 0
OutboundProxy:
Path:
QualifyFrequency: 0
QualifyTimeout: 3.000
Qualify2xxOnly: 0
Event: IdentifyDetail
ActionID: rec-4
ObjectType: identify
ObjectName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify
Match: 172.21.0.2/255.255.255.255
MatchRequestUri:
SrvLookups: true
Endpoint: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
MatchHeader:
EndpointName: trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
Event: EndpointDetailComplete
ActionID: rec-4
EventList: Complete
ListItems: 6
+10 -5
View File
@@ -67,6 +67,7 @@ describe.skipIf(!hasTestDb)('LiveState with a recorded session', () => {
const events = new EventEmitter();
const ami = {
on: events.on.bind(events),
action: async () => ({}),
actionList: async (f: Record<string, string>): Promise<AmiMessage[]> =>
f.Action === 'PJSIPShowContacts'
? listOf('rec-2', 'ContactList')
@@ -112,8 +113,12 @@ describe.skipIf(!hasTestDb)('LiveState with a recorded session', () => {
});
it('users see presence but not devices', async () => {
const live = new LiveState(db, { on() {}, actionList: async () => [] });
await live.loadExtensions();
const live = new LiveState(db, {
on() {},
action: async () => ({}),
actionList: async () => [],
});
await live.loadFromDb();
const u = live.snapshot(false).extensions[0]!;
expect(u.contacts).toBeUndefined();
expect(u).toHaveProperty('online');
@@ -155,7 +160,7 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
await until(() => admin.msgs.length === 1 && user.msgs.length === 1);
expect(admin.msgs[0]).toEqual({
type: 'snapshot',
data: { extensions: [], ami: { connected: false } },
data: { extensions: [], trunks: [], ami: { connected: false } },
});
// A config change adds an extension: one extension.updated per client, contacts only for admins.
@@ -165,8 +170,8 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('1234'),
});
await t.live.loadExtensions();
await t.live.loadExtensions(); // no change → no duplicate message
await t.live.loadFromDb();
await t.live.loadFromDb(); // no change → no duplicate message
await until(() => admin.msgs.length === 2 && user.msgs.length === 2);
await new Promise((r) => setTimeout(r, 300));
expect(admin.msgs).toHaveLength(2);
+240
View File
@@ -0,0 +1,240 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T1: trunks CRUD, generated PJSIP, and registration status replayed from a real Asterisk 24
// session against a fake provider (trunk-test.ts --record): one trunk registers, one is rejected.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TrunkCreateSchema, type LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field } from '../src/ami/events.ts';
import { decrypt } from '../src/crypto/secrets.ts';
import { auditLog, dids, trunks } from '../src/db/schema.ts';
import { trunkOfObject } from '../src/live/state.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const MSGS = new AmiParser().push(
readFileSync(new URL('./fixtures/ami/trunk-registration.raw', import.meta.url), 'latin1'),
);
const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
const reply = (id: string) => MSGS.filter((m) => field(m, 'ActionID') === id && m.Event);
const LIVE_EVENTS = MSGS.filter((m) => m.Event && !m.ActionID);
describe('trunk schema', () => {
const base = { name: 'voip.ms', serverHost: 'chicago1.voip.ms' };
it('registration needs a username and password; IP auth does not', () => {
expect(TrunkCreateSchema.safeParse(base).success).toBe(false);
expect(TrunkCreateSchema.safeParse({ ...base, authMode: 'ip' }).success).toBe(true);
expect(
TrunkCreateSchema.safeParse({ ...base, username: '123456_pbx', password: 'pw' }).success,
).toBe(true);
});
it.each([
['password with ;', { password: 'a;b' }],
['password with a newline', { password: 'a\nb' }],
['password with an edge space', { password: ' ab' }],
['username with @', { username: 'a@b' }],
['host with a space', { serverHost: 'chicago1 .voip.ms' }],
['SRTP over UDP', { srtp: true }],
['unknown codec', { codecs: ['opus'] }],
])('rejects %s', (_, patch) => {
const r = TrunkCreateSchema.safeParse({
...base,
username: '123456_pbx',
password: 'pw',
...patch,
});
expect(r.success).toBe(false);
});
it('maps Asterisk object names to trunk ids', () => {
expect(trunkOfObject(`trunk-${GOOD}`)).toBe(GOOD);
expect(trunkOfObject(`trunk-${GOOD}-reg`)).toBe(GOOD);
expect(trunkOfObject(`trunk-${GOOD}-auth`)).toBeNull();
expect(trunkOfObject('101')).toBeNull();
});
});
describe.skipIf(!hasTestDb)('trunks API', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: string;
let req: (
method: string,
url: string,
payload?: unknown,
cookie?: string,
) => Promise<LightMyRequestResponse>;
const pjsip = () => readFileSync(join(t.genDir, 'pjsip_selfphoneng.conf'), 'utf8');
const settle = async () => {
await new Promise((r) => setTimeout(r, 60));
await t.app.generator.apply({ trigger: 'test' });
};
let id: string;
const voipms = {
name: 'voip.ms',
serverHost: '127.0.0.1', // an IP: no DNS lookup in tests
username: '123456_pbx',
password: 'Sub-account pw!',
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload, cookie = admin) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
});
afterAll(() => t?.close());
it('is admin only', async () => {
await t.addUser('u@example.com', 'user-password-123');
const user = await t.login('u@example.com', 'user-password-123');
expect((await req('GET', '/api/trunks', undefined, user)).statusCode).toBe(403);
});
it('creates a voip.ms trunk with defaults; the password is stored encrypted and never returned', async () => {
const r = await req('POST', '/api/trunks', voipms);
expect(r.statusCode).toBe(201);
const dto = r.json();
id = dto.id;
expect(dto).toMatchObject({
provider: 'voipms',
serverPort: 5060,
transport: 'udp',
authMode: 'registration',
codecs: ['ulaw'],
hasPassword: true,
hasApiPassword: false,
});
expect(JSON.stringify(dto)).not.toContain('Sub-account pw!');
const [row] = await t.db.select().from(trunks).where(eq(trunks.id, id));
expect(decrypt(row!.passwordEnc!)).toBe('Sub-account pw!');
const [a] = await t.db.select().from(auditLog).where(eq(auditLog.entityId, id));
expect(JSON.stringify(a)).not.toContain('Sub-account pw!');
await settle();
const conf = pjsip();
expect(conf).toContain(`[trunk-${id}-reg]`);
expect(conf).toContain(`client_uri = sip:123456_pbx@127.0.0.1:5060`);
expect(conf).toContain(`password = Sub-account pw!`);
expect(conf).toContain(`context = sp-from-trunk-${id}`);
});
it('refuses duplicates, transports that are off, and servers that do not resolve', async () => {
expect((await req('POST', '/api/trunks', voipms)).statusCode).toBe(409);
const tls = await req('POST', '/api/trunks', { ...voipms, name: 'tls', transport: 'tls' });
expect(tls.statusCode).toBe(400);
expect(tls.json().error.message).toMatch(/TLS/);
const dns = await req('POST', '/api/trunks', {
...voipms,
name: 'typo',
serverHost: 'no-such-host.invalid',
});
expect(dns.statusCode).toBe(400);
expect(dns.json().error.message).toMatch(/Can't find the server/);
});
it('updates keep the stored password unless a new one is given; disabling removes the config', async () => {
const r = await req('PATCH', `/api/trunks/${id}`, { maxChannels: 4, codecs: ['ulaw', 'alaw'] });
expect(r.statusCode).toBe(200);
expect(r.json()).toMatchObject({ maxChannels: 4, hasPassword: true });
await settle();
expect(pjsip()).toContain('allow = ulaw,alaw');
expect(pjsip()).toContain('password = Sub-account pw!');
await req('PATCH', `/api/trunks/${id}`, { enabled: false });
await settle();
expect(pjsip()).not.toContain(`trunk-${id}`);
await req('PATCH', `/api/trunks/${id}`, { enabled: true });
});
it('refuses to delete a trunk that a phone number uses', async () => {
const [d] = await t.db
.insert(dids)
.values({ number: '13125551234', trunkId: id, destination: { type: 'hangup' } })
.returning();
const r = await req('DELETE', `/api/trunks/${id}`);
expect(r.statusCode).toBe(409);
expect(r.json().error.message).toContain('number 13125551234');
await t.db.delete(dids).where(eq(dids.id, d!.id));
expect((await req('DELETE', `/api/trunks/${id}`)).statusCode).toBe(200);
await settle();
expect(pjsip()).not.toContain('trunk-');
});
});
describe.skipIf(!hasTestDb)('trunk live state (recorded session)', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
beforeAll(async () => {
t = await createTestApp();
for (const [tid, name] of [
[GOOD, 'voip.ms'],
[BAD, 'wrong password'],
] as const)
await t.db.insert(trunks).values({ id: tid, name, serverHost: 'sp-trunk-provider' });
t.ami.lists.PJSIPShowRegistrationsOutbound = reply('rec-2');
t.ami.lists.PJSIPShowEndpoint = reply('rec-4');
});
afterAll(() => t?.close());
it('loads registration and reachability on connect', async () => {
await t.live.loadFromAsterisk();
const byId = Object.fromEntries(t.live.snapshot(true).trunks!.map((x) => [x.trunkId, x]));
expect(byId[GOOD]).toMatchObject({ registration: 'registered', reachable: true });
expect(byId[BAD]).toMatchObject({ registration: 'rejected', reachable: true }); // canned reply
expect(t.live.snapshot(false).trunks).toBeUndefined(); // users never see trunks
});
it('follows Registry and ContactStatus events', async () => {
t.ami.lists.PJSIPShowRegistrationsOutbound = [];
const msgs: LiveMessage[] = [];
t.live.on('message', (m) => msgs.push(m));
for (const m of LIVE_EVENTS) t.live.handleEvent(m); // replays two Registry events
await new Promise((r) => setTimeout(r, 20));
const trunk = (tid: string) => t.live.snapshot(true).trunks!.find((x) => x.trunkId === tid)!;
expect(trunk(GOOD).registration).toBe('unknown'); // re-read: the canned list is now empty
t.live.handleEvent({
Event: 'ContactStatus',
ContactStatus: 'Unreachable',
AOR: `trunk-${GOOD}`,
EndpointName: `trunk-${GOOD}`,
URI: 'sip:sp-trunk-provider:5060',
});
expect(trunk(GOOD).reachable).toBe(false);
expect(msgs.some((m) => m.type === 'trunk.updated' && m.data.trunkId === GOOD)).toBe(true);
expect(msgs.some((m) => m.type === 'extension.updated')).toBe(false);
});
it('POST /:id/test qualifies the trunk and explains the result', async () => {
t.ami.lists.PJSIPShowRegistrationsOutbound = reply('rec-2');
t.live.amiConnected = true;
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
const r = await t.app.inject({
method: 'POST',
url: `/api/trunks/${GOOD}/test`,
headers: { ...H, cookie },
});
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({
registration: 'registered',
rttMs: 1,
detail: 'Registered with sp-trunk-provider',
});
expect(t.ami.actions).toContainEqual({ Action: 'PJSIPQualify', Endpoint: `trunk-${GOOD}` });
const bad = await t.app.inject({
method: 'POST',
url: `/api/trunks/${BAD}/test`,
headers: { ...H, cookie },
});
expect(bad.json().detail).toBe('sp-trunk-provider rejected the username or password');
});
});
+12
View File
@@ -8,6 +8,10 @@ import type {
ListResponse,
NumberKind,
SipCredentials,
TrunkCreate,
TrunkDto,
TrunkTestResult,
TrunkUpdate,
UserCreate,
UserDto,
UserUpdate,
@@ -74,3 +78,11 @@ export const extensionsApi = {
nextNumber: (kind: NumberKind) =>
api<{ number: string | null }>('GET', `/api/numbering/next?kind=${kind}`).then((r) => r.number),
};
export const trunksApi = {
list: () => api<ListResponse<TrunkDto>>('GET', '/api/trunks').then((r) => r.items),
create: (t: TrunkCreate) => api<TrunkDto>('POST', '/api/trunks', t),
update: (id: string, t: TrunkUpdate) => api<TrunkDto>('PATCH', `/api/trunks/${id}`, t),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/trunks/${id}`),
test: (id: string) => api<TrunkTestResult>('POST', `/api/trunks/${id}/test`),
};
+11 -1
View File
@@ -1,19 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
import { create } from 'zustand';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
import type { ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
type LiveStore = {
/** WebSocket state: 'connecting' before the first snapshot. */
status: 'connecting' | 'open' | 'reconnecting';
amiConnected: boolean;
extensions: Record<string, ExtensionLive>;
/** Admins only (empty for users). */
trunks: Record<string, TrunkLive>;
};
export const useLive = create<LiveStore>(() => ({
status: 'connecting',
amiConnected: false,
extensions: {},
trunks: {},
}));
function apply(m: LiveMessage) {
@@ -24,6 +27,7 @@ function apply(m: LiveMessage) {
status: 'open',
amiConnected: m.data.ami.connected,
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
trunks: Object.fromEntries((m.data.trunks ?? []).map((t) => [t.trunkId, t])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -31,6 +35,12 @@ function apply(m: LiveMessage) {
const { [m.data.number]: _gone, ...rest } = s.extensions;
return { extensions: rest };
}
case 'trunk.updated':
return { trunks: { ...s.trunks, [m.data.trunkId]: m.data } };
case 'trunk.removed': {
const { [m.data.trunkId]: _gone, ...rest } = s.trunks;
return { trunks: rest };
}
case 'ami.status':
return { amiConnected: m.data.connected };
default:
+3
View File
@@ -10,6 +10,7 @@ import { LoginPage } from './pages/LoginPage.tsx';
import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.tsx';
import { UsersPage } from './pages/admin/UsersPage.tsx';
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
import { TrunksPage } from './pages/admin/TrunksPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import './index.css';
@@ -26,6 +27,8 @@ const router = createBrowserRouter([
<DashboardPage />
) : p.path === 'users' ? (
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
) : p.path === 'trunks' ? (
<TrunksPage />
) : (
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
),
+1 -1
View File
@@ -12,7 +12,7 @@ import { subscribeLive, useLive } from '../../live/store.ts';
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: null },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: 'M3' },
{ path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: 'M3' },
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
+13 -4
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). M2: telephony link and extensions online. Trunks, calls, queues and the
// warnings banner arrive with their features (M3, M7, M8).
// Dashboard (05 §2). Telephony link, trunks and extensions online. Calls, queues and the
// warnings banner arrive with their features (M3-T4, M7, M8).
import type { ReactNode } from 'react';
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
@@ -15,7 +15,9 @@ function Card({ title, children }: { title: string; children: ReactNode }) {
}
export function DashboardPage() {
const { status, amiConnected, extensions } = useLive();
const { status, amiConnected, extensions, trunks } = useLive();
const trunkList = Object.values(trunks);
const trunksUp = trunkList.filter((t) => t.registration === 'registered' || t.reachable).length;
const list = Object.values(extensions).sort((a, b) => Number(a.number) - Number(b.number));
const online = list.filter((e) => e.online).length;
const onCall = list.filter((e) =>
@@ -25,10 +27,17 @@ export function DashboardPage() {
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card>
<Card title="Trunks up">
{status === 'connecting'
? '…'
: trunkList.length
? `${trunksUp} / ${trunkList.length}`
: 'None'}
</Card>
<Card title="On a call">{status === 'connecting' ? '…' : onCall}</Card>
<Card title="Telephony engine">
<span
+512
View File
@@ -0,0 +1,512 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Trunks (05 §2, 06 §1–§2): list with live status, voip.ms-first form with the portal checklist.
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import {
CODECS,
Hostname,
VOIPMS_SERVERS,
type TrunkDto,
type TrunkLive,
type TrunkTestResult,
} from '@selfphoneng/shared';
import { trunksApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
if (!t.enabled)
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200';
const bad = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200';
const wait = 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200';
if (!live) return { label: 'Unknown', color: wait };
if (t.authMode === 'ip')
return live.reachable
? { label: 'Reachable', color: ok }
: { label: 'Unreachable', color: bad };
if (live.registration === 'registered') return { label: 'Registered', color: ok };
if (live.registration === 'rejected') return { label: 'Rejected', color: bad };
return { label: 'Not registered', color: wait };
}
function StatusPill({ t }: { t: TrunkDto }) {
const live = useLive((s) => s.trunks[t.id]);
const s = trunkStatus(t, live);
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${s.color}`}>{s.label}</span>
);
}
export function TrunksPage() {
const qc = useQueryClient();
const trunks = useQuery({ queryKey: ['trunks'], queryFn: trunksApi.list });
const [editing, setEditing] = useState<TrunkDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<TrunkDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['trunks'] });
const remove = useMutation({
mutationFn: (id: string) => trunksApi.remove(id),
onSuccess: () => {
setDeleting(null);
void refresh();
},
});
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Trunks</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
New trunk
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
A trunk connects the phone system to your phone company (voip.ms or any SIP provider), so
calls can go to and come from the outside world.
</p>
{trunks.isError && <Alert>{trunks.error.message}</Alert>}
{trunks.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{trunks.data?.length === 0 && (
<p className="text-sm text-zinc-500">No trunks yet. Add your voip.ms sub-account.</p>
)}
{trunks.data && trunks.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{trunks.data.map((t) => (
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
<StatusPill t={t} />
<span className="font-medium">{t.name}</span>
<span className="flex-1 truncate text-zinc-500">
{t.username ? `${t.username}@` : ''}
{t.serverHost}
</span>
<Button onClick={() => setEditing(t)}>Edit</Button>
<Button
variant="ghost"
onClick={() => {
remove.reset();
setDeleting(t);
}}
>
Delete
</Button>
</li>
))}
</ul>
)}
{editing && (
<TrunkDrawer
trunk={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={(t) => {
void refresh();
setEditing(t); // stay open so the admin can press Test
}}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete trunk"
message={
<p>
Delete <strong>{deleting?.name}</strong>? Calls can no longer use it.
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
const FormSchema = z.object({
name: z.string().trim().min(1, 'Required').max(40),
provider: z.enum(['voipms', 'generic']),
enabled: z.boolean(),
serverHost: Hostname,
serverPort: z.number({ error: 'A port number' }).int().min(1).max(65535),
transport: z.enum(['udp', 'tcp', 'tls']),
authMode: z.enum(['registration', 'ip']),
username: z.string().trim().max(64),
password: z.string().max(128),
fromUser: z.string().trim().max(64),
fromDomain: z.union([z.literal(''), Hostname]),
codecs: z.array(z.enum(CODECS)).min(1, 'Pick at least one codec'),
srtp: z.boolean(),
maxChannels: z.string().regex(/^(\d{1,4})?$/, 'A number, or empty for no limit'),
voipmsApiUsername: z.string().trim().max(254),
voipmsApiPassword: z.string().max(128),
});
type FormValues = z.infer<typeof FormSchema>;
function TrunkDrawer({
trunk,
onClose,
onSaved,
}: {
trunk: TrunkDto | null;
onClose: () => void;
onSaved: (t: TrunkDto) => void;
}) {
const [error, setError] = useState<string>();
const [saved, setSaved] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: trunk?.name ?? 'voip.ms',
provider: trunk?.provider ?? 'voipms',
enabled: trunk?.enabled ?? true,
serverHost: trunk?.serverHost ?? '',
serverPort: trunk?.serverPort ?? 5060,
transport: trunk?.transport ?? 'udp',
authMode: trunk?.authMode ?? 'registration',
username: trunk?.username ?? '',
password: '',
fromUser: trunk?.fromUser ?? '',
fromDomain: trunk?.fromDomain ?? '',
codecs: trunk?.codecs ?? ['ulaw'],
srtp: trunk?.srtp ?? false,
maxChannels: trunk?.maxChannels ? String(trunk.maxChannels) : '',
voipmsApiUsername: trunk?.voipmsApiUsername ?? '',
voipmsApiPassword: '',
},
});
const { errors } = form.formState;
const [provider, authMode, transport] = useWatch({
control: form.control,
name: ['provider', 'authMode', 'transport'],
});
const voipms = provider === 'voipms';
function setTransport(t: FormValues['transport']) {
form.setValue('transport', t);
form.setValue('serverPort', t === 'tls' ? 5061 : 5060);
if (t !== 'tls') form.setValue('srtp', false);
}
const onSubmit = form.handleSubmit(async (v) => {
setError(undefined);
const body = {
...v,
username: v.username || null,
fromUser: v.fromUser || null,
fromDomain: v.fromDomain || null,
maxChannels: v.maxChannels ? Number(v.maxChannels) : null,
voipmsApiUsername: v.voipmsApiUsername || null,
// Empty password fields mean "keep the stored one".
password: v.password || undefined,
voipmsApiPassword: v.voipmsApiPassword || undefined,
};
try {
const t = trunk ? await trunksApi.update(trunk.id, body) : await trunksApi.create(body);
form.reset({ ...v, password: '', voipmsApiPassword: '' });
setSaved(true);
onSaved(t);
} catch (e) {
setError((e as Error).message);
}
});
return (
<Dialog
open
drawer
onClose={onClose}
title={trunk ? `Edit ${trunk.name}` : 'New trunk'}
footer={
<>
<Button onClick={onClose}>{saved ? 'Close' : 'Cancel'}</Button>
<Button variant="primary" type="submit" form="trunk-form">
{trunk ? 'Save' : 'Create trunk'}
</Button>
</>
}
>
<form id="trunk-form" onSubmit={onSubmit} className="flex flex-col gap-4" noValidate>
{error && <Alert>{error}</Alert>}
{saved && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved. The phone system picks it up within a few seconds.
</p>
)}
{trunk && <TestPanel trunk={trunk} />}
<Field label="Provider">
{(a) => (
<select {...a} {...form.register('provider')} className={inputClass}>
<option value="voipms">voip.ms</option>
<option value="generic">Other SIP provider</option>
</select>
)}
</Field>
{voipms && <VoipmsChecklist />}
<Field label="Name" error={errors.name?.message}>
{(a) => <input {...a} {...form.register('name')} className={inputClass} />}
</Field>
<Field
label="Server"
error={errors.serverHost?.message}
hint={voipms ? 'The voip.ms POP your sub-account and numbers use.' : undefined}
>
{(a) => (
<>
<input
{...a}
{...form.register('serverHost')}
list={voipms ? 'voipms-servers' : undefined}
placeholder={voipms ? 'chicago1.voip.ms' : 'sip.example.com'}
autoComplete="off"
className={inputClass}
/>
<datalist id="voipms-servers">
{VOIPMS_SERVERS.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</>
)}
</Field>
<Field label="Sign-in">
{(a) => (
<select {...a} {...form.register('authMode')} className={inputClass}>
<option value="registration">Username and password (registration)</option>
<option value="ip">IP address (the provider knows this server's IP)</option>
</select>
)}
</Field>
{authMode === 'registration' && (
<>
<Field
label={voipms ? 'Sub-account username' : 'Username'}
error={errors.username?.message}
hint={voipms ? 'Like 123456_pbx.' : undefined}
>
{(a) => (
<input
{...a}
{...form.register('username')}
autoComplete="off"
className={inputClass}
/>
)}
</Field>
<Field
label="Password"
error={errors.password?.message}
hint={trunk?.hasPassword ? 'Leave empty to keep the current password.' : undefined}
>
{(a) => (
<input
{...a}
{...form.register('password')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
</>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Transport">
{(a) => (
<select
{...a}
value={transport}
onChange={(e) => setTransport(e.target.value as FormValues['transport'])}
className={inputClass}
>
<option value="udp">UDP</option>
<option value="tcp">TCP</option>
<option value="tls">TLS (encrypted)</option>
</select>
)}
</Field>
<Field label="Port" error={errors.serverPort?.message}>
{(a) => (
<input
{...a}
{...form.register('serverPort', { valueAsNumber: true })}
type="number"
className={inputClass}
/>
)}
</Field>
</div>
<fieldset className="flex flex-col gap-1">
<legend className="text-sm font-medium">Codecs</legend>
<div className="flex gap-4">
{CODECS.map((c) => (
<label key={c} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={c} {...form.register('codecs')} className="size-4" />
{c === 'ulaw' ? 'G.711 µ-law' : c === 'alaw' ? 'G.711 A-law' : 'G.722 (HD)'}
</label>
))}
</div>
{errors.codecs && (
<p className="text-xs text-red-600 dark:text-red-400">{errors.codecs.message}</p>
)}
</fieldset>
<Field
label="Max simultaneous calls"
error={errors.maxChannels?.message}
hint="Empty for no limit."
>
{(a) => (
<input
{...a}
{...form.register('maxChannels')}
inputMode="numeric"
className={inputClass}
/>
)}
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register('enabled')} className="size-4" />
Enabled
</label>
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">Advanced</summary>
<div className="mt-3 flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
{...form.register('srtp')}
disabled={transport !== 'tls'}
className="size-4"
/>
Encrypt audio (SRTP, needs TLS)
</label>
<Field
label="From user"
error={errors.fromUser?.message}
hint="Defaults to the username."
>
{(a) => <input {...a} {...form.register('fromUser')} className={inputClass} />}
</Field>
<Field
label="From domain"
error={errors.fromDomain?.message}
hint="Defaults to the server."
>
{(a) => <input {...a} {...form.register('fromDomain')} className={inputClass} />}
</Field>
</div>
</details>
{voipms && (
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
<div className="mt-3 flex flex-col gap-4">
<p className="text-zinc-500">
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP
and REST/JSON API. Enable it, set an API password, and allow this server's public IP
address.
</p>
<Field label="API username (your voip.ms email)">
{(a) => (
<input
{...a}
{...form.register('voipmsApiUsername')}
type="email"
autoComplete="off"
className={inputClass}
/>
)}
</Field>
<Field
label="API password"
hint={
trunk?.hasApiPassword ? 'Leave empty to keep the current password.' : undefined
}
>
{(a) => (
<input
{...a}
{...form.register('voipmsApiPassword')}
type="password"
autoComplete="new-password"
className={inputClass}
/>
)}
</Field>
</div>
</details>
)}
</form>
</Dialog>
);
}
function TestPanel({ trunk }: { trunk: TrunkDto }) {
const [result, setResult] = useState<TrunkTestResult | { error: string } | null>(null);
const test = useMutation({
mutationFn: () => trunksApi.test(trunk.id),
onSuccess: setResult,
onError: (e) => setResult({ error: e.message }),
});
const live = useLive((s) => s.trunks[trunk.id]);
const status = trunkStatus(trunk, live);
return (
<div className="flex flex-col gap-2 rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
</span>
<span className="flex-1" />
<Button onClick={() => test.mutate()} disabled={test.isPending || !trunk.enabled}>
{test.isPending ? 'Testing…' : 'Test'}
</Button>
</div>
{result && (
<p role="status">
{'error' in result
? result.error
: `${result.detail}${result.rttMs !== null ? ` (${result.rttMs} ms)` : ''}`}
</p>
)}
</div>
);
}
/** What the customer sets in the voip.ms portal (06 §1). */
function VoipmsChecklist() {
return (
<details className="rounded-md border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-900 dark:bg-sky-950/40">
<summary className="cursor-pointer font-medium">Set up voip.ms first (checklist)</summary>
<ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
<li>
<strong>Create a sub-account</strong> (Main Menu → Sub Accounts → Create), one per phone
system, e.g. <code>123456_pbx</code>: authentication <em>User/Password</em>, a strong
random password, device type <em>Asterisk, IP PBX, Gateway or VoIP Switch</em>, codec{' '}
<em>G.711u</em>, DTMF <em>RFC 2833</em>, NAT <em>Yes</em>, CallerID number = your main
number, international calling <em>off</em> unless you need it.
</li>
<li>
<strong>Pick a POP server</strong> close to you (e.g. chicago1.voip.ms). Enter the same
one below.
</li>
<li>
<strong>Point each number at the sub-account</strong> (DID Numbers → Manage DIDs → edit):
routing <em>SIP/IAX → the sub-account</em>, and the <em>same POP server</em>. A different
POP is a common reason incoming calls fail.
</li>
<li>
<strong>Register an E911 address</strong> for every number used as an emergency caller ID
(DID → e911).
</li>
<li>
<strong>Set balance alerts</strong> and per-call or daily limits in your voip.ms account
settings.
</li>
</ol>
</details>
);
}
+6 -1
View File
@@ -307,8 +307,13 @@ type = identify
endpoint = trunk-{id}
match = chicago1.voip.ms
```
For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbound_auth` line.
For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbound_auth` line
(and `from_user` when there is no username).
Skip disabled trunks entirely.
For TCP/TLS trunks, `server_uri`, `client_uri` and the AOR `contact` get `;transport=tcp|tls`.
`identify` resolves `match` when the config loads, and an unresolvable name is an ERROR that drops the
trunk's identify (verified), so the API refuses a server name that doesn't resolve, and a transport
that isn't turned on in Settings → SIP.
## 6. Dialplan: `extensions_selfphoneng.conf`
+4 -2
View File
@@ -129,7 +129,8 @@ Initial load on connect (use `actionList`):
|---|---|---|
| Extension states | `ExtensionStateList` | `Exten`, `Context` (= `sp-local-numbers`), `Status` |
| Contacts | `PJSIPShowContacts` | `Endpoint`/`EndpointName`, `Uri`, `UserAgent`, `Status`, `RoundtripUsec` |
| Trunk registrations | `PJSIPShowRegistrationsOutbound` | `ObjectName` (`trunk-{id}-reg`), `Status` |
| Trunk registrations | `PJSIPShowRegistrationsOutbound` | `OutboundRegistrationDetail`: `ObjectName` (`trunk-{id}-reg`), `Status` (`Registered`, `Rejected`, `Unregistered`, `Stopped`). **The reply also has `AuthDetail` items with the trunk password in clear text: never log it.** |
| Trunk reachability | `PJSIPShowEndpoint` `trunk-{id}` per trunk | `ContactStatusDetail` `Status`, `RoundtripUsec`. Static (`contact =`) AOR contacts are **not** listed by `PJSIPShowContacts` (verified on 24.0.0-rc2) |
| Channels | `CoreShowChannels` | `Channel`, `Uniqueid`, `Linkedid`, `ChannelStateDesc`, `CallerIDNum`, `ConnectedLineNum`, `Application`, `Exten`, `Context` |
| Queues | `QueueStatus` | `QueueParams`, `QueueMember`, `QueueEntry` events |
| Parking | `ParkedCalls` | `ParkingSpace`, `ParkeeLinkedid`, `ParkeeCallerIDNum` |
@@ -141,7 +142,8 @@ Events to handle (`live/handlers.ts`):
|---|---|
| `ExtensionStatus` | update `ExtensionLive.state` (Status: 0 idle, 1 inuse, 2 busy, 4 unavailable, 8 ringing, 9 ringinuse, 16 onhold) |
| `ContactStatus` | add/update/remove contact (ContactStatus: Created, Reachable, Unreachable, Removed, Updated) |
| `Registry` | trunk registration status (match `Username` + `Domain` to a trunk) |
| `Registry` | re-read `PJSIPShowRegistrationsOutbound`. `Username`/`Domain` are the client/server URIs, which two sub-accounts on one server share, so they can't name the trunk (verified) |
| `ContactStatus` with `AOR` `trunk-{id}` | trunk `reachable` (qualify) |
| `Newchannel`, `Newstate`, `NewCallerid`, `NewConnectedLine`, `DialBegin`, `DialEnd`, `BridgeEnter`, `BridgeLeave`, `Hold`, `Unhold`, `Hangup` | maintain `ActiveCall` grouped by `Linkedid`. When the last channel of a linkedid hangs up, drop the call and schedule CDR aggregation (§5.4) for 5 s later |
| `VarSet` with `Variable` in (`SP_DID`, `SP_DIRECTION`, `SP_REC`, `SP_TARGET`) | enrich `ActiveCall` (needs `eventfilter` or `read=all`; fine on localhost) |
| `QueueCallerJoin` / `QueueCallerLeave` / `QueueCallerAbandon` / `QueueMemberStatus` / `QueueMemberPause` / `AgentConnect` / `AgentComplete` | `QueueLive` |
+1 -1
View File
@@ -15,7 +15,7 @@
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts"
"test:asterisk": "node apps/api/test/asterisk/load-golden.mjs && node --conditions=source apps/api/test/asterisk/call-test.ts && node --conditions=source apps/api/test/asterisk/trunk-test.ts"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+1
View File
@@ -7,3 +7,4 @@ export * from './auth.ts';
export * from './users.ts';
export * from './extensions.ts';
export * from './live.ts';
export * from './trunks.ts';
+13
View File
@@ -23,8 +23,19 @@ export type ExtensionLive = {
dnd: boolean;
};
export type TrunkLive = {
trunkId: string;
/** Always 'unknown' for IP-auth trunks: they don't register. */
registration: 'registered' | 'rejected' | 'unregistered' | 'unknown';
/** The server answers OPTIONS (qualify). */
reachable: boolean;
lastChange: string;
};
export type LiveSnapshot = {
extensions: ExtensionLive[];
/** Admins only. */
trunks?: TrunkLive[];
ami: { connected: boolean };
};
@@ -33,6 +44,8 @@ export type LiveMessage =
| { type: 'snapshot'; data: LiveSnapshot }
| { type: 'extension.updated'; data: ExtensionLive }
| { type: 'extension.removed'; data: { number: string } }
| { type: 'trunk.updated'; data: TrunkLive }
| { type: 'trunk.removed'; data: { trunkId: string } }
| { type: 'ami.status'; data: { connected: boolean } }
| {
type: 'system.configApplied';
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SIP trunk as the API accepts and returns it (02, 04 §7.3, 06 §2). Patterns match the generator's
// sanitizers (03 §2) so valid input always renders.
import { z } from 'zod';
/** A hostname or IPv4 address (same rule as the generator's `host` sanitizer). */
export const Hostname = z
.string()
.trim()
.regex(
/^(?=.{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])?$/,
'A hostname like chicago1.voip.ms',
);
const SipUser = z
.string()
.trim()
.regex(/^[A-Za-z0-9_.-]{1,64}$/, 'Letters, digits, _ . and - only');
const Password = z
.string()
.min(1, 'Required')
.max(128)
// Goes into pjsip.conf verbatim: no line breaks, no ';' (comment) and no leading/trailing space.
.regex(/^[^\s;](?:[^\r\n;]*[^\s;])?$/, 'No semicolons, line breaks or surrounding spaces');
export const CODECS = ['ulaw', 'alaw', 'g722'] as const;
const TrunkFields = z.object({
name: z.string().trim().min(1, 'Required').max(40),
provider: z.enum(['voipms', 'generic']),
enabled: z.boolean(),
serverHost: Hostname,
serverPort: z.number().int().min(1).max(65535),
transport: z.enum(['udp', 'tcp', 'tls']),
authMode: z.enum(['registration', 'ip']),
username: SipUser.nullable(),
password: Password, // write-only
fromUser: SipUser.nullable(),
fromDomain: Hostname.nullable(),
codecs: z.array(z.enum(CODECS)).min(1, 'Pick at least one codec'),
srtp: z.boolean(),
maxChannels: z.number().int().min(1).max(1000).nullable(),
voipmsApiUsername: z.string().trim().max(254).nullable(),
voipmsApiPassword: z.string().max(128), // write-only
});
export const TrunkCreateSchema = TrunkFields.partial()
.required({ name: true, serverHost: true })
.superRefine((t, ctx) => {
if ((t.authMode ?? 'registration') === 'registration' && (!t.username || !t.password))
ctx.addIssue({
code: 'custom',
path: [t.username ? 'password' : 'username'],
message: 'Registration needs a username and password',
});
if (t.srtp && (t.transport ?? 'udp') !== 'tls')
ctx.addIssue({ code: 'custom', path: ['srtp'], message: 'SRTP needs the TLS transport' });
});
/** The same cross-field rules are checked against the merged row on update (routes/trunks.ts). */
export const TrunkUpdateSchema = TrunkFields.partial();
export type TrunkCreate = z.input<typeof TrunkCreateSchema>;
export type TrunkUpdate = z.input<typeof TrunkUpdateSchema>;
export type TrunkDto = Omit<z.output<typeof TrunkFields>, 'password' | 'voipmsApiPassword'> & {
id: string;
hasPassword: boolean;
hasApiPassword: boolean;
createdAt: string;
};
/** POST /api/trunks/:id/test */
export type TrunkTestResult = {
registration: TrunkRegistration;
/** OPTIONS round trip to the server; null when it didn't answer. */
rttMs: number | null;
detail: string;
};
export type TrunkRegistration = 'registered' | 'rejected' | 'unregistered' | 'unknown';
/** voip.ms POP servers offered in the form (06 §2); free text is still allowed. */
export const VOIPMS_SERVERS = [
'atlanta1.voip.ms',
'chicago1.voip.ms',
'dallas1.voip.ms',
'denver1.voip.ms',
'houston1.voip.ms',
'losangeles1.voip.ms',
'montreal1.voip.ms',
'newyork1.voip.ms',
'seattle1.voip.ms',
'tampa1.voip.ms',
'toronto1.voip.ms',
'vancouver1.voip.ms',
] as const;
+6 -2
View File
@@ -29,5 +29,9 @@ node scripts/e2e/m2-live.mjs check http://127.0.0.1:8080 admin@example.com corre
node scripts/e2e/m2-live.mjs down
```
`pnpm test:asterisk` (golden-file load test plus `call-test.ts`, 30 s two-way audio between two
baresip phones) needs no api at all.
`m3-t1.mjs` runs on the same setup (after `m1-t4.mjs`): it starts a fake voip.ms (a second Asterisk
that accepts one sub-account) on the m2-live network, creates the trunk in the UI and waits for
**Registered**, then presses Test.
`pnpm test:asterisk` (golden-file load test, `call-test.ts` with 30 s two-way audio between two
baresip phones, and `trunk-test.ts` with registration against a fake provider) needs no api at all.
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M3-T1 in a browser against a fake voip.ms: a second Asterisk on the m2-live network accepts the
// sub-account's registration. Create the trunk in the UI, see it turn Registered, press Test.
// Needs `m2-live.mjs up` and the api started as described there.
// Usage: node scripts/e2e/m3-t1.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
const NET = 'sp-live';
const PROVIDER = 'sp-live-provider';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' }; // the fake provider's test account
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
};
const [base, email, pw, shotsDir] = process.argv.slice(2);
const conf = mkdtempSync(join(tmpdir(), 'sp-provider-'));
for (const f of [
'rtp_selfphoneng.conf',
'extensions_globals_selfphoneng.conf',
'extensions_selfphoneng.conf',
'voicemail_selfphoneng.conf',
'queues_selfphoneng.conf',
'musiconhold_selfphoneng.conf',
'confbridge_selfphoneng.conf',
])
writeFileSync(join(conf, f), '');
writeFileSync(
join(conf, 'pjsip_selfphoneng.conf'),
`[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
`[${SUB.user}]\ntype = endpoint\ncontext = provider-in\ndisallow = all\nallow = ulaw\nauth = ${SUB.user}-auth\naors = ${SUB.user}\n\n` +
`[${SUB.user}-auth]\ntype = auth\nauth_type = userpass\nusername = ${SUB.user}\npassword = ${SUB.pass}\n\n` +
`[${SUB.user}]\ntype = aor\nmax_contacts = 1\nremove_existing = yes\n`,
);
docker('rm', '-f', PROVIDER);
const run = docker(
'run',
'-d',
'--rm',
'--name',
PROVIDER,
'--network',
NET,
'-e',
'AMI_SECRET=provider',
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
IMAGE,
);
assert.ok(run.ok, run.out);
for (
let i = 0;
i < 60 && !docker('exec', PROVIDER, 'asterisk', '-rx', 'core waitfullybooted').ok;
i++
)
await sleep(500);
// An IP, not the container name: the api (on the host) checks that the server name resolves.
const ip = docker(
'inspect',
'-f',
`{{(index .NetworkSettings.Networks "${NET}").IPAddress}}`,
PROVIDER,
).out;
console.log(`fake provider at ${ip}`);
const p = await launch({ shotsDir });
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/admin'`);
await p.click('Trunks');
await p.waitFor(`document.body.innerText.includes('No trunks yet')`, 'empty trunks page');
await p.click('New trunk');
await p.waitFor(`document.body.innerText.includes('Set up voip.ms first')`, 'voip.ms checklist');
await p.fill('Server', ip);
await p.fill('Sub-account username', SUB.user);
await p.fill('Password', SUB.pass);
await p.shot('m3-t1-01-form');
await p.click('Create trunk');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'saved');
await p.waitFor(
`[...document.querySelectorAll('dialog span')].some(s => s.textContent === 'Registered')`,
'trunk shows Registered',
90000,
);
console.log('trunk shows Registered in the UI');
await p.click('Test');
await p.waitFor(
`document.body.innerText.includes('Registered with ${ip}')`,
'test result',
15000,
);
await p.shot('m3-t1-02-registered');
console.log('Test: Registered with', ip);
await p.click('Close');
await p.click('Dashboard');
await p.waitFor(`/Trunks up\\s*1 \\/ 1/.test(document.body.innerText)`, 'dashboard trunk card');
await p.shot('m3-t1-03-dashboard');
const onProvider = docker('exec', PROVIDER, 'asterisk', '-rx', 'pjsip show contacts').out;
assert.match(onProvider, new RegExp(`${SUB.user}/sip:${SUB.user}@`), 'contact on the provider');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('M3-T1 (fake provider): PASS');
} catch (e) {
await p.shot('m3-t1-FAILED').catch(() => {});
console.error('FAIL:', e.message, '\nconsole:', p.consoleErrors);
process.exitCode = 1;
} finally {
await p.close();
docker('rm', '-f', PROVIDER);
}