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 { userRoutes } from './routes/users.ts';
|
||||||
import { systemRoutes } from './routes/system.ts';
|
import { systemRoutes } from './routes/system.ts';
|
||||||
import { extensionRoutes } from './routes/extensions.ts';
|
import { extensionRoutes } from './routes/extensions.ts';
|
||||||
|
import { trunkRoutes } from './routes/trunks.ts';
|
||||||
import { liveRoutes } from './live/hub.ts';
|
import { liveRoutes } from './live/hub.ts';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -79,6 +80,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
|||||||
app.register(userRoutes);
|
app.register(userRoutes);
|
||||||
app.register(systemRoutes);
|
app.register(systemRoutes);
|
||||||
app.register(extensionRoutes);
|
app.register(extensionRoutes);
|
||||||
|
app.register(trunkRoutes);
|
||||||
app.register(liveRoutes);
|
app.register(liveRoutes);
|
||||||
|
|
||||||
if (existsSync(publicDir)) {
|
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>;
|
settings?: Record<string, unknown>;
|
||||||
extensions?: Partial<Snapshot['extensions'][number]>[];
|
extensions?: Partial<Snapshot['extensions'][number]>[];
|
||||||
dids?: Snapshot['dids'];
|
dids?: Snapshot['dids'];
|
||||||
|
trunks?: Partial<Snapshot['trunks'][number]>[];
|
||||||
};
|
};
|
||||||
const settings = Object.fromEntries(
|
const settings = Object.fromEntries(
|
||||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||||
@@ -29,6 +30,23 @@ export function loadCase(name: string): Snapshot {
|
|||||||
today: raw.today ?? '2026-10-17',
|
today: raw.today ?? '2026-10-17',
|
||||||
settings,
|
settings,
|
||||||
dids: raw.dids ?? [],
|
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) => ({
|
extensions: (raw.extensions ?? []).map((e, i) => ({
|
||||||
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||||
number: '101',
|
number: '101',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { alias } from 'drizzle-orm/pg-core';
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
import type { Db } from '../db/client.ts';
|
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 { decrypt } from '../crypto/secrets.ts';
|
||||||
import { getAllSettings } from '../services/settings.ts';
|
import { getAllSettings } from '../services/settings.ts';
|
||||||
|
|
||||||
@@ -28,12 +28,30 @@ export type SnapExtension = {
|
|||||||
|
|
||||||
export type SnapDid = { id: string; number: string };
|
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 = {
|
export type Snapshot = {
|
||||||
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
|
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
|
||||||
today: string;
|
today: string;
|
||||||
settings: Awaited<ReturnType<typeof getAllSettings>>;
|
settings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||||
extensions: SnapExtension[];
|
extensions: SnapExtension[];
|
||||||
dids: SnapDid[]; // M3 adds routing fields
|
dids: SnapDid[]; // M3 adds routing fields
|
||||||
|
trunks: SnapTrunk[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function todayIn(timezone: string, now = new Date()): string {
|
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));
|
.leftJoin(e911Did, eq(e911Did.id, extensions.emergencyCallerIdDidId));
|
||||||
|
|
||||||
const didRows = await db.select({ id: dids.id, number: dids.number }).from(dids);
|
const didRows = await db.select({ id: dids.id, number: dids.number }).from(dids);
|
||||||
|
const trunkRows = await db.select().from(trunks);
|
||||||
return {
|
return {
|
||||||
today: todayIn(settings['general.timezone'], now),
|
today: todayIn(settings['general.timezone'], now),
|
||||||
settings,
|
settings,
|
||||||
dids: didRows,
|
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 }) => ({
|
extensions: rows.map(({ ext, role, outCid, e911Cid }) => ({
|
||||||
id: ext.id,
|
id: ext.id,
|
||||||
number: ext.number,
|
number: ext.number,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// pjsip_selfphoneng.conf (03 §5) and rtp_selfphoneng.conf (03 §4).
|
// 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';
|
import * as s from './sanitize.ts';
|
||||||
|
|
||||||
const CLASS_NUMBER = { internal: 0, local: 1, national: 2, international: 3 } as const;
|
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');
|
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 {
|
export function renderPjsip(snap: Snapshot): string {
|
||||||
const exts = [...snap.extensions].sort(byNumber).map(extensionSections);
|
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 {
|
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);
|
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
|
||||||
/** Generated SIP passwords. */
|
/** Generated SIP passwords. */
|
||||||
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
|
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. */
|
/** Voicemail / conference PINs. */
|
||||||
export const pin = check(/^[0-9]{4,10}$/, 'pin', 10);
|
export const pin = check(/^[0-9]{4,10}$/, 'pin', 10);
|
||||||
/** Trunk server host: a hostname or an IPv4 address. */
|
/** Trunk server host: a hostname or an IPv4 address. */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const generator = createGenerator({
|
|||||||
type: 'system.configApplied',
|
type: 'system.configApplied',
|
||||||
data: { ok: r.ok, error: r.error, restartRequired: r.restartRequired },
|
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);
|
app.decorate('generator', generator);
|
||||||
@@ -57,7 +57,7 @@ if (admin === 'created')
|
|||||||
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial 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 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
|
// 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).
|
// state (after the apply, so hints for new extensions exist). TODO(M6): sync AstDB first (04 §5.5).
|
||||||
ami.on('connected', () => {
|
ami.on('connected', () => {
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ export async function liveRoutes(app: FastifyInstance) {
|
|||||||
const keyOf = (m: LiveMessage) =>
|
const keyOf = (m: LiveMessage) =>
|
||||||
m.type === 'extension.updated' || m.type === 'extension.removed'
|
m.type === 'extension.updated' || m.type === 'extension.removed'
|
||||||
? `extension:${m.data.number}` // a removal replaces a pending update and vice versa
|
? `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) => {
|
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 =>
|
const viewFor = (c: Client, m: LiveMessage): LiveMessage =>
|
||||||
|
|||||||
+114
-5
@@ -9,11 +9,12 @@ import type {
|
|||||||
ExtensionState,
|
ExtensionState,
|
||||||
LiveMessage,
|
LiveMessage,
|
||||||
LiveSnapshot,
|
LiveSnapshot,
|
||||||
|
TrunkLive,
|
||||||
} from '@selfphoneng/shared';
|
} from '@selfphoneng/shared';
|
||||||
import type { AmiClient } from '../ami/client.ts';
|
import type { AmiClient } from '../ami/client.ts';
|
||||||
import { field, type AmiMessage } from '../ami/events.ts';
|
import { field, type AmiMessage } from '../ami/events.ts';
|
||||||
import type { Db } from '../db/client.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). */
|
/** ExtensionStatus/ExtensionStateList Status is a bit mask (04 §5.1). */
|
||||||
export function stateFromStatus(status: number): ExtensionState {
|
export function stateFromStatus(status: number): ExtensionState {
|
||||||
@@ -35,15 +36,31 @@ function contactStatus(s: string | undefined): ContactLive['status'] {
|
|||||||
export const extOfEndpoint = (endpoint: string): string | null =>
|
export const extOfEndpoint = (endpoint: string): string | null =>
|
||||||
/^(\d{3,6})(?:-web)?$/.exec(endpoint)?.[1] ?? 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). */
|
/** What LiveState needs from AmiClient (tests pass a fake). */
|
||||||
type Ami = {
|
type Ami = {
|
||||||
actionList: AmiClient['actionList'];
|
actionList: AmiClient['actionList'];
|
||||||
|
action(fields: Record<string, string>): Promise<unknown>;
|
||||||
on(event: string, fn: (m: AmiMessage) => void): unknown;
|
on(event: string, fn: (m: AmiMessage) => void): unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
||||||
private readonly exts = new Map<string, ExtensionLive & { contacts: ContactLive[] }>();
|
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 db: Db;
|
||||||
private readonly ami: Ami;
|
private readonly ami: Ami;
|
||||||
|
|
||||||
@@ -58,6 +75,7 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
snapshot(admin: boolean): LiveSnapshot {
|
snapshot(admin: boolean): LiveSnapshot {
|
||||||
return {
|
return {
|
||||||
extensions: [...this.exts.values()].map((e) => this.view(e, admin)),
|
extensions: [...this.exts.values()].map((e) => this.view(e, admin)),
|
||||||
|
...(admin ? { trunks: [...this.trunks.values()].map((t) => ({ ...t })) } : {}),
|
||||||
ami: { connected: this.amiConnected },
|
ami: { connected: this.amiConnected },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,8 +99,87 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
this.emit('message', { type: 'extension.updated', data: e });
|
this.emit('message', { type: 'extension.updated', data: e });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extensions from the DB (call after config changes too, so new users appear). */
|
/** Extensions and trunks from the DB (call after config changes too, so new ones appear). */
|
||||||
async loadExtensions(): Promise<void> {
|
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
|
const rows = await this.db
|
||||||
.select({ number: extensions.number, name: extensions.name, dnd: userRuntimeState.dnd })
|
.select({ number: extensions.number, name: extensions.name, dnd: userRuntimeState.dnd })
|
||||||
.from(extensions)
|
.from(extensions)
|
||||||
@@ -111,7 +208,9 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
|
|
||||||
/** Initial load on every AMI connect (04 §5.1). */
|
/** Initial load on every AMI connect (04 §5.1). */
|
||||||
async loadFromAsterisk(): Promise<void> {
|
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 = [];
|
for (const e of this.exts.values()) e.contacts = [];
|
||||||
const [states, contacts] = await Promise.all([
|
const [states, contacts] = await Promise.all([
|
||||||
this.ami.actionList({ Action: 'ExtensionStateList' }),
|
this.ami.actionList({ Action: 'ExtensionStateList' }),
|
||||||
@@ -142,6 +241,9 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
|
|||||||
return this.onExtensionStatus(m, true);
|
return this.onExtensionStatus(m, true);
|
||||||
case 'ContactStatus':
|
case 'ContactStatus':
|
||||||
return this.onContactStatus(m);
|
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) {
|
private onContactStatus(m: AmiMessage) {
|
||||||
const endpoint = field(m, 'EndpointName') ?? field(m, 'AOR') ?? '';
|
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) ?? '');
|
const e = this.exts.get(extOfEndpoint(endpoint) ?? '');
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
const uri = field(m, 'URI') ?? '';
|
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 { EventEmitter } from 'node:events';
|
||||||
import { createGenerator } from '../src/generator/index.ts';
|
import { createGenerator } from '../src/generator/index.ts';
|
||||||
import { LiveState } from '../src/live/state.ts';
|
import { LiveState } from '../src/live/state.ts';
|
||||||
|
import type { AmiMessage } from '../src/ami/events.ts';
|
||||||
import { createTestDb } from './db.ts';
|
import { createTestDb } from './db.ts';
|
||||||
|
|
||||||
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
|
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
|
||||||
@@ -27,8 +28,15 @@ export function fakeAmi() {
|
|||||||
events,
|
events,
|
||||||
on: events.on.bind(events),
|
on: events.on.bind(events),
|
||||||
off: events.off.bind(events),
|
off: events.off.bind(events),
|
||||||
async actionList(): Promise<Record<string, string>[]> {
|
/** Canned list responses by Action name. */
|
||||||
return [];
|
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() {
|
isConnected() {
|
||||||
return this.connected;
|
return this.connected;
|
||||||
@@ -70,7 +78,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
|||||||
dir: genDir,
|
dir: genDir,
|
||||||
log: app.log,
|
log: app.log,
|
||||||
debounceMs: 20,
|
debounceMs: 20,
|
||||||
onApplied: () => void live.loadExtensions(),
|
onApplied: () => void live.loadFromDb().catch(() => {}), // may land after close()
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
extend?.(app);
|
extend?.(app);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
|
|||||||
today: '2026-10-17',
|
today: '2026-10-17',
|
||||||
settings,
|
settings,
|
||||||
dids: [],
|
dids: [],
|
||||||
|
trunks: [],
|
||||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
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,
|
cookie?: string,
|
||||||
) => Promise<LightMyRequestResponse>;
|
) => Promise<LightMyRequestResponse>;
|
||||||
const pjsip = () => readFileSync(join(t.genDir, 'pjsip_selfphoneng.conf'), 'utf8');
|
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 userId: string;
|
||||||
let extId: 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 events = new EventEmitter();
|
||||||
const ami = {
|
const ami = {
|
||||||
on: events.on.bind(events),
|
on: events.on.bind(events),
|
||||||
|
action: async () => ({}),
|
||||||
actionList: async (f: Record<string, string>): Promise<AmiMessage[]> =>
|
actionList: async (f: Record<string, string>): Promise<AmiMessage[]> =>
|
||||||
f.Action === 'PJSIPShowContacts'
|
f.Action === 'PJSIPShowContacts'
|
||||||
? listOf('rec-2', 'ContactList')
|
? listOf('rec-2', 'ContactList')
|
||||||
@@ -112,8 +113,12 @@ describe.skipIf(!hasTestDb)('LiveState with a recorded session', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('users see presence but not devices', async () => {
|
it('users see presence but not devices', async () => {
|
||||||
const live = new LiveState(db, { on() {}, actionList: async () => [] });
|
const live = new LiveState(db, {
|
||||||
await live.loadExtensions();
|
on() {},
|
||||||
|
action: async () => ({}),
|
||||||
|
actionList: async () => [],
|
||||||
|
});
|
||||||
|
await live.loadFromDb();
|
||||||
const u = live.snapshot(false).extensions[0]!;
|
const u = live.snapshot(false).extensions[0]!;
|
||||||
expect(u.contacts).toBeUndefined();
|
expect(u.contacts).toBeUndefined();
|
||||||
expect(u).toHaveProperty('online');
|
expect(u).toHaveProperty('online');
|
||||||
@@ -155,7 +160,7 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
|
|||||||
await until(() => admin.msgs.length === 1 && user.msgs.length === 1);
|
await until(() => admin.msgs.length === 1 && user.msgs.length === 1);
|
||||||
expect(admin.msgs[0]).toEqual({
|
expect(admin.msgs[0]).toEqual({
|
||||||
type: 'snapshot',
|
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.
|
// 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'),
|
sipPasswordEnc: encrypt('x'),
|
||||||
voicemailPinEnc: encrypt('1234'),
|
voicemailPinEnc: encrypt('1234'),
|
||||||
});
|
});
|
||||||
await t.live.loadExtensions();
|
await t.live.loadFromDb();
|
||||||
await t.live.loadExtensions(); // no change → no duplicate message
|
await t.live.loadFromDb(); // no change → no duplicate message
|
||||||
await until(() => admin.msgs.length === 2 && user.msgs.length === 2);
|
await until(() => admin.msgs.length === 2 && user.msgs.length === 2);
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
expect(admin.msgs).toHaveLength(2);
|
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,
|
ListResponse,
|
||||||
NumberKind,
|
NumberKind,
|
||||||
SipCredentials,
|
SipCredentials,
|
||||||
|
TrunkCreate,
|
||||||
|
TrunkDto,
|
||||||
|
TrunkTestResult,
|
||||||
|
TrunkUpdate,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserDto,
|
UserDto,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
@@ -74,3 +78,11 @@ export const extensionsApi = {
|
|||||||
nextNumber: (kind: NumberKind) =>
|
nextNumber: (kind: NumberKind) =>
|
||||||
api<{ number: string | null }>('GET', `/api/numbering/next?kind=${kind}`).then((r) => r.number),
|
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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
|
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
|
import type { ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
|
||||||
|
|
||||||
type LiveStore = {
|
type LiveStore = {
|
||||||
/** WebSocket state: 'connecting' before the first snapshot. */
|
/** WebSocket state: 'connecting' before the first snapshot. */
|
||||||
status: 'connecting' | 'open' | 'reconnecting';
|
status: 'connecting' | 'open' | 'reconnecting';
|
||||||
amiConnected: boolean;
|
amiConnected: boolean;
|
||||||
extensions: Record<string, ExtensionLive>;
|
extensions: Record<string, ExtensionLive>;
|
||||||
|
/** Admins only (empty for users). */
|
||||||
|
trunks: Record<string, TrunkLive>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useLive = create<LiveStore>(() => ({
|
export const useLive = create<LiveStore>(() => ({
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
amiConnected: false,
|
amiConnected: false,
|
||||||
extensions: {},
|
extensions: {},
|
||||||
|
trunks: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function apply(m: LiveMessage) {
|
function apply(m: LiveMessage) {
|
||||||
@@ -24,6 +27,7 @@ function apply(m: LiveMessage) {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
amiConnected: m.data.ami.connected,
|
amiConnected: m.data.ami.connected,
|
||||||
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
|
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':
|
case 'extension.updated':
|
||||||
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
|
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;
|
const { [m.data.number]: _gone, ...rest } = s.extensions;
|
||||||
return { extensions: rest };
|
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':
|
case 'ami.status':
|
||||||
return { amiConnected: m.data.connected };
|
return { amiConnected: m.data.connected };
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LoginPage } from './pages/LoginPage.tsx';
|
|||||||
import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.tsx';
|
import { ADMIN_PAGES, AdminLayout, ComingSoon } from './pages/admin/AdminLayout.tsx';
|
||||||
import { UsersPage } from './pages/admin/UsersPage.tsx';
|
import { UsersPage } from './pages/admin/UsersPage.tsx';
|
||||||
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
|
import { DashboardPage } from './pages/admin/DashboardPage.tsx';
|
||||||
|
import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||||
import { MePage } from './pages/user/MePage.tsx';
|
import { MePage } from './pages/user/MePage.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ const router = createBrowserRouter([
|
|||||||
<DashboardPage />
|
<DashboardPage />
|
||||||
) : p.path === 'users' ? (
|
) : p.path === 'users' ? (
|
||||||
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
<RequireAuth>{(user) => <UsersPage currentUserId={user.id} />}</RequireAuth>
|
||||||
|
) : p.path === 'trunks' ? (
|
||||||
|
<TrunksPage />
|
||||||
) : (
|
) : (
|
||||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { subscribeLive, useLive } from '../../live/store.ts';
|
|||||||
export const ADMIN_PAGES = [
|
export const ADMIN_PAGES = [
|
||||||
{ path: '', label: 'Dashboard', milestone: null },
|
{ path: '', label: 'Dashboard', milestone: null },
|
||||||
{ path: 'users', label: 'Users & Extensions', 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: 'numbers', label: 'Phone numbers', milestone: 'M3' },
|
||||||
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
|
{ path: 'outbound', label: 'Outbound rules', milestone: 'M3' },
|
||||||
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
{ path: 'ring-groups', label: 'Ring groups', milestone: 'M6' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Dashboard (05 §2). M2: telephony link and extensions online. Trunks, calls, queues and the
|
// Dashboard (05 §2). Telephony link, trunks and extensions online. Calls, queues and the
|
||||||
// warnings banner arrive with their features (M3, M7, M8).
|
// warnings banner arrive with their features (M3-T4, M7, M8).
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
|
import { StatusDot, statusLabel } from '../../components/StatusDot.tsx';
|
||||||
import { useLive } from '../../live/store.ts';
|
import { useLive } from '../../live/store.ts';
|
||||||
@@ -15,7 +15,9 @@ function Card({ title, children }: { title: string; children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
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 list = Object.values(extensions).sort((a, b) => Number(a.number) - Number(b.number));
|
||||||
const online = list.filter((e) => e.online).length;
|
const online = list.filter((e) => e.online).length;
|
||||||
const onCall = list.filter((e) =>
|
const onCall = list.filter((e) =>
|
||||||
@@ -25,10 +27,17 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
<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">
|
<Card title="Extensions online">
|
||||||
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
|
||||||
</Card>
|
</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="On a call">{status === 'connecting' ? '…' : onCall}</Card>
|
||||||
<Card title="Telephony engine">
|
<Card title="Telephony engine">
|
||||||
<span
|
<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}
|
endpoint = trunk-{id}
|
||||||
match = chicago1.voip.ms
|
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.
|
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`
|
## 6. Dialplan: `extensions_selfphoneng.conf`
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ Initial load on connect (use `actionList`):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Extension states | `ExtensionStateList` | `Exten`, `Context` (= `sp-local-numbers`), `Status` |
|
| Extension states | `ExtensionStateList` | `Exten`, `Context` (= `sp-local-numbers`), `Status` |
|
||||||
| Contacts | `PJSIPShowContacts` | `Endpoint`/`EndpointName`, `Uri`, `UserAgent`, `Status`, `RoundtripUsec` |
|
| 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` |
|
| Channels | `CoreShowChannels` | `Channel`, `Uniqueid`, `Linkedid`, `ChannelStateDesc`, `CallerIDNum`, `ConnectedLineNum`, `Application`, `Exten`, `Context` |
|
||||||
| Queues | `QueueStatus` | `QueueParams`, `QueueMember`, `QueueEntry` events |
|
| Queues | `QueueStatus` | `QueueParams`, `QueueMember`, `QueueEntry` events |
|
||||||
| Parking | `ParkedCalls` | `ParkingSpace`, `ParkeeLinkedid`, `ParkeeCallerIDNum` |
|
| 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) |
|
| `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) |
|
| `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 |
|
| `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) |
|
| `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` |
|
| `QueueCallerJoin` / `QueueCallerLeave` / `QueueCallerAbandon` / `QueueMemberStatus` / `QueueMemberPause` / `AgentConnect` / `AgentComplete` | `QueueLive` |
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './auth.ts';
|
|||||||
export * from './users.ts';
|
export * from './users.ts';
|
||||||
export * from './extensions.ts';
|
export * from './extensions.ts';
|
||||||
export * from './live.ts';
|
export * from './live.ts';
|
||||||
|
export * from './trunks.ts';
|
||||||
@@ -23,8 +23,19 @@ export type ExtensionLive = {
|
|||||||
dnd: boolean;
|
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 = {
|
export type LiveSnapshot = {
|
||||||
extensions: ExtensionLive[];
|
extensions: ExtensionLive[];
|
||||||
|
/** Admins only. */
|
||||||
|
trunks?: TrunkLive[];
|
||||||
ami: { connected: boolean };
|
ami: { connected: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,6 +44,8 @@ export type LiveMessage =
|
|||||||
| { type: 'snapshot'; data: LiveSnapshot }
|
| { type: 'snapshot'; data: LiveSnapshot }
|
||||||
| { type: 'extension.updated'; data: ExtensionLive }
|
| { type: 'extension.updated'; data: ExtensionLive }
|
||||||
| { type: 'extension.removed'; data: { number: string } }
|
| { type: 'extension.removed'; data: { number: string } }
|
||||||
|
| { type: 'trunk.updated'; data: TrunkLive }
|
||||||
|
| { type: 'trunk.removed'; data: { trunkId: string } }
|
||||||
| { type: 'ami.status'; data: { connected: boolean } }
|
| { type: 'ami.status'; data: { connected: boolean } }
|
||||||
| {
|
| {
|
||||||
type: 'system.configApplied';
|
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
|
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
|
`m3-t1.mjs` runs on the same setup (after `m1-t4.mjs`): it starts a fake voip.ms (a second Asterisk
|
||||||
baresip phones) needs no api at all.
|
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