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:
1 parent
c0c4036887
commit
1b7f60b5f2
48 files changed
+2749
-33
No files matched your search
@@ -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.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
+113
@@ -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.
|
||||
+5
@@ -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" }]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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') ?? '';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
|
||||
today: '2026-10-17',
|
||||
settings,
|
||||
dids: [],
|
||||
trunks: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ?? ''} />
|
||||
),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './auth.ts';
|
||||
export * from './users.ts';
|
||||
export * from './extensions.ts';
|
||||
export * from './live.ts';
|
||||
export * from './trunks.ts';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user