security: review fixes (dialed-number filter, browser endpoint ACL, IVR direct dial, …)
The 08 §1 threat table checked line by line against the code, plus a review by a separate agent that hadn't written it. Findings were reproduced with a failing test before each fix: - Critical: "011…,4" turned the route's Goto into Goto(sp-route-…,011…,4,1), starting past the class check, so any extension could call abroad. FILTER on the dialed number (also stops "&" adding a Dial target). trunk-test.ts proves it. - High: -web endpoints (desk phone's password, any transport) skipped the phone ACL; now 127.0.0.1 only when it's on. call-test.ts: UDP refused, WebSocket from 127.0.0.1 registers. - IVR direct dial no longer reaches paging groups or parked calls (sp-ivr-direct). - Trunks identify_by = ip: a From of trunk-<id> from anywhere no longer rings phones. - .dockerignore is an allowlist, so the public source archive can't pick up stray files. - CSRF check on the matched route (/%61pi/… got around it); Linphone tokens masked in logs; /api/live checks Origin and re-checks the session; *45/** filter digits; pg_dump password in the environment; more webhook ranges blocked. The review and what's left for the owner are in 08 §6. Task: M11-T8
This commit is contained in:
1 parent
0ff0f9292b
commit
5c46bd0e85
46 files changed
+555
-87
No files matched your search
+14
-1
@@ -54,6 +54,8 @@ declare module 'fastify' {
|
||||
// Paths the SPA must never swallow (04 §1 step 5).
|
||||
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
/** A Linphone setup link's token is the credential (07 §5): keep it out of the request log. */
|
||||
export const logUrl = (url: string) => url.replace(/^(\/prov\/linphone\/)[^/?]+/i, '$1…');
|
||||
|
||||
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
|
||||
const app = Fastify({
|
||||
@@ -69,6 +71,16 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
],
|
||||
serializers: {
|
||||
// Fastify's own req serializer, with the URL masked (M11-T8).
|
||||
req: (req) => ({
|
||||
method: req.method,
|
||||
url: logUrl(req.url),
|
||||
host: req.host,
|
||||
remoteAddress: req.ip,
|
||||
remotePort: req.socket?.remotePort,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,7 +97,8 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
// and a cross-site page can't set Authorization either.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
if (
|
||||
req.url.startsWith('/api/') &&
|
||||
// The matched route, not req.url: the router decodes "/%61pi/…" to /api/… (M11-T8).
|
||||
(req.routeOptions.url ?? req.url).startsWith('/api/') &&
|
||||
!SAFE_METHODS.has(req.method) &&
|
||||
!req.headers.authorization?.startsWith('Bearer ') &&
|
||||
req.headers['x-requested-with'] !== 'selfphoneng'
|
||||
|
||||
@@ -44,8 +44,11 @@ export async function createSession(
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolves a cookie token to its active user, sliding the expiry when under 7 days are left. */
|
||||
export async function getSessionUser(db: Db, token: string) {
|
||||
/**
|
||||
* Resolves a cookie token to its active user, sliding the expiry when under 7 days are left
|
||||
* (not for the live socket's re-checks: an open dashboard alone doesn't keep a session going).
|
||||
*/
|
||||
export async function getSessionUser(db: Db, token: string, slide = true) {
|
||||
const id = sha256(token);
|
||||
const [row] = await db
|
||||
.select({ expiresAt: sessions.expiresAt, user: users })
|
||||
@@ -54,7 +57,7 @@ export async function getSessionUser(db: Db, token: string) {
|
||||
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
|
||||
if (!row) return null;
|
||||
let extended = false;
|
||||
if (row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
|
||||
if (slide && row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ expiresAt: new Date(Date.now() + LIFETIME) })
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -130,6 +130,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -130,6 +130,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
+3
-2
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
+11
-3
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
@@ -217,7 +218,7 @@ exten => s,1,Set(__SP_TARGET=500)
|
||||
|
||||
; ─── Digital receptionist 600 "Main menu"
|
||||
[sp-ivr-33333333-3333-4333-8333-333333333600]
|
||||
include => sp-local-numbers
|
||||
include => sp-ivr-direct
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
@@ -235,6 +236,13 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
|
||||
[sp-ivr-direct]
|
||||
exten => 101,1,Goto(sp-local-numbers,101,1)
|
||||
exten => 102,1,Goto(sp-local-numbers,102,1)
|
||||
exten => 103,1,Goto(sp-local-numbers,103,1)
|
||||
exten => 500,1,Goto(sp-local-numbers,500,1)
|
||||
exten => 600,1,Goto(sp-local-numbers,600,1)
|
||||
|
||||
; ─── Office hours "Business hours"
|
||||
[sp-tc-55555555-5555-4555-8555-555555555001]
|
||||
exten => s,1,Set(OVR=${DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
|
||||
|
||||
@@ -276,6 +276,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
@@ -215,7 +216,7 @@ exten => s,1,Set(__SP_TARGET=500)
|
||||
|
||||
; ─── Digital receptionist 600 "Main menu"
|
||||
[sp-ivr-33333333-3333-4333-8333-333333333600]
|
||||
include => sp-local-numbers
|
||||
include => sp-ivr-direct
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
@@ -254,6 +255,13 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
|
||||
|
||||
[sp-ivr-direct]
|
||||
exten => 101,1,Goto(sp-local-numbers,101,1)
|
||||
exten => 102,1,Goto(sp-local-numbers,102,1)
|
||||
exten => 500,1,Goto(sp-local-numbers,500,1)
|
||||
exten => 600,1,Goto(sp-local-numbers,600,1)
|
||||
exten => 601,1,Goto(sp-local-numbers,601,1)
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
|
||||
@@ -203,6 +203,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -78,6 +78,8 @@ allow = g722,ulaw
|
||||
auth = 101-web-auth
|
||||
aors = 101-web
|
||||
from_domain = selfphoneng.invalid
|
||||
deny = 0.0.0.0/0.0.0.0
|
||||
permit = 127.0.0.1/255.255.255.255
|
||||
callerid = "Dan Doe" <101>
|
||||
mailboxes = 101@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
@@ -134,6 +136,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -276,6 +276,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -242,6 +242,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
+5
-4
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
@@ -260,7 +261,7 @@ include => sp-or-bbbbbbbb-0000-4000-8000-000000000001 ; First
|
||||
include => sp-or-bbbbbbbb-0000-4000-8000-000000000002 ; Second evil
|
||||
|
||||
[sp-or-bbbbbbbb-0000-4000-8000-000000000001]
|
||||
exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${EXTEN:0},1)
|
||||
exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
|
||||
[sp-route-bbbbbbbb-0000-4000-8000-000000000001]
|
||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
@@ -279,7 +280,7 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-or-bbbbbbbb-0000-4000-8000-000000000002]
|
||||
exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${EXTEN:1},1)
|
||||
exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${FILTER(0-9*#+,${EXTEN:1})},1)
|
||||
|
||||
[sp-route-bbbbbbbb-0000-4000-8000-000000000002]
|
||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
|
||||
@@ -46,6 +46,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = alaw,ulaw
|
||||
aors = trunk-a1b2c3d4-0000-4000-8000-000000000001
|
||||
@@ -94,6 +95,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-tcp
|
||||
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000002-auth
|
||||
@@ -144,6 +146,7 @@ qualify_frequency = 0
|
||||
type = endpoint
|
||||
transport = transport-tls
|
||||
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000004
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000004-auth
|
||||
|
||||
+12
-11
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
@@ -243,13 +244,13 @@ include => sp-or-aaaaaaaa-0000-4000-8000-000000000002 ; Toll-free
|
||||
include => sp-or-aaaaaaaa-0000-4000-8000-000000000001 ; Local/National NANP
|
||||
|
||||
[sp-or-aaaaaaaa-0000-4000-8000-000000000002]
|
||||
exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
|
||||
exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
|
||||
[sp-route-aaaaaaaa-0000-4000-8000-000000000002]
|
||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
@@ -272,8 +273,8 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-or-aaaaaaaa-0000-4000-8000-000000000001]
|
||||
exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${EXTEN:0},1)
|
||||
exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${EXTEN:0},1)
|
||||
exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${FILTER(0-9*#+,${EXTEN:0})},1)
|
||||
|
||||
[sp-route-aaaaaaaa-0000-4000-8000-000000000001]
|
||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
|
||||
@@ -130,6 +130,7 @@ qualify_frequency = 60
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
|
||||
identify_by = ip
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
|
||||
|
||||
+3
-2
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -130,7 +131,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=${EXTEN:3})
|
||||
same => n,Set(Q=${FILTER(0-9,${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -23,7 +23,7 @@ export function renderIvrs(snap: Snapshot): string {
|
||||
return [
|
||||
`; ─── Digital receptionist ${s.digits(r.number)} "${s.name(r.name)}"`,
|
||||
`[sp-ivr-${s.ident(r.id)}]`,
|
||||
...(r.allowDirectDial ? ['include => sp-local-numbers'] : []),
|
||||
...(r.allowDirectDial ? ['include => sp-ivr-direct'] : []),
|
||||
'exten => s,1,Answer()',
|
||||
' same => n,Set(SP_IVR_TRIES=0)',
|
||||
' same => n(menu),Set(TIMEOUT(digit)=3)',
|
||||
|
||||
@@ -39,6 +39,29 @@ export function renderLocalNumbers(snap: Snapshot): string {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* sp-ivr-direct (03 §6.9): what a caller in a digital receptionist may dial. Not paging groups
|
||||
* (an outside caller on the office speakers) and not parked calls (someone else's call), M11-T8.
|
||||
*/
|
||||
export function renderIvrDirect(snap: Snapshot): string {
|
||||
if (!snap.ivrs.some((r) => r.allowDirectDial)) return '';
|
||||
const nums = [
|
||||
...snap.extensions,
|
||||
...snap.ringGroups,
|
||||
...snap.queues,
|
||||
...snap.conferences,
|
||||
...snap.ivrs,
|
||||
].sort(byNumber);
|
||||
return (
|
||||
[
|
||||
'[sp-ivr-direct]',
|
||||
...nums
|
||||
.map((x) => s.digits(x.number))
|
||||
.map((n) => `exten => ${n},1,Goto(sp-local-numbers,${n},1)`),
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
export function renderGlobals(snap: Snapshot): string {
|
||||
const st = snap.settings;
|
||||
const didNumber = (id: string | null) => {
|
||||
|
||||
@@ -27,7 +27,11 @@ export function renderOutbound(snap: Snapshot): string {
|
||||
seen.add(match);
|
||||
const strip = s.digits(String(p.strip));
|
||||
const prepend = p.prepend ? s.dialable(p.prepend) : '';
|
||||
pat.push(`exten => _${match},1,Goto(sp-route-${id},${prepend}\${EXTEN:${strip}},1)`);
|
||||
// FILTER: SIP allows "," in a dialed number, and Goto(sp-route-…,011…,4,1) would start
|
||||
// past the class check (M11-T8). "&" would add a second Dial target.
|
||||
pat.push(
|
||||
`exten => _${match},1,Goto(sp-route-${id},${prepend}\${FILTER(0-9*#+,\${EXTEN:${strip}})},1)`,
|
||||
);
|
||||
}
|
||||
|
||||
const route = [
|
||||
|
||||
@@ -118,7 +118,8 @@ exten => *73,1,Answer() ; cancel forward alw
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: \${SP_EXT},Value: )
|
||||
same => n,Playback(call-fwd-unconditional&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _**X.,1,PickupChan(PJSIP/\${EXTEN:2}-&PJSIP/\${EXTEN:2}-web-) ; directed pickup **101
|
||||
exten => _**X.,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
|
||||
same => n,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-web-) ; directed pickup **101
|
||||
same => n,Hangup()
|
||||
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
|
||||
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
|
||||
@@ -133,7 +134,7 @@ exten => *65,1,Answer() ; say my extension
|
||||
same => n,SayDigits(\${SP_EXT})
|
||||
same => n,Hangup()
|
||||
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
|
||||
same => n,Set(Q=\${EXTEN:3})
|
||||
same => n,Set(Q=\${FILTER(0-9,\${EXTEN:3})})
|
||||
same => n,Set(K=SP/QPAUSE/\${Q}/\${SP_EXT})
|
||||
same => n,GotoIf($["\${DB(\${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(\${Q},Local/\${SP_EXT}@sp-queue-member/n)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resendPauses } from '../services/queues.ts';
|
||||
import { loadSnapshot, type Snapshot } from './load.ts';
|
||||
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
|
||||
import { STATIC_DIALPLAN } from './dialplan/static.ts';
|
||||
import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
|
||||
import { renderGlobals, renderIvrDirect, renderLocalNumbers } from './dialplan/local.ts';
|
||||
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
|
||||
import { renderOutbound } from './dialplan/outbound.ts';
|
||||
import { renderEmergency } from './dialplan/emergency.ts';
|
||||
@@ -66,6 +66,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
renderConferences(snap),
|
||||
renderPaging(snap),
|
||||
renderIvrs(snap),
|
||||
renderIvrDirect(snap),
|
||||
renderTimeConditions(snap),
|
||||
renderAnnouncements(snap),
|
||||
renderTrunkEntries(snap),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T8: a caller in a digital receptionist with direct dial on reaches people, groups, queues,
|
||||
// rooms and other menus, but not paging groups (the office speakers) or parked calls.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadCase } from './golden-case.ts';
|
||||
import { render } from './index.ts';
|
||||
|
||||
const context = (conf: string, name: string) => {
|
||||
const start = conf.indexOf(`[${name}]\n`);
|
||||
const end = conf.indexOf('\n[', start + 1);
|
||||
return conf.slice(start, end === -1 ? undefined : end);
|
||||
};
|
||||
|
||||
describe('IVR direct dial', () => {
|
||||
const snap = loadCase('ivr');
|
||||
snap.pagingGroups = loadCase('paging').pagingGroups;
|
||||
const conf = render(snap)['extensions_selfphoneng.conf'];
|
||||
const direct = context(conf, 'sp-ivr-direct');
|
||||
const page = snap.pagingGroups[0]!.number;
|
||||
|
||||
it('the menu includes sp-ivr-direct, not sp-local-numbers', () => {
|
||||
expect(conf).toContain('include => sp-ivr-direct');
|
||||
expect(conf).not.toMatch(/\[sp-ivr-[0-9a-f-]+\]\ninclude => sp-local-numbers/);
|
||||
});
|
||||
|
||||
it('lists users, ring groups and menus, but no paging group or parking lot', () => {
|
||||
for (const n of [...snap.extensions, ...snap.ringGroups, ...snap.ivrs].map((x) => x.number))
|
||||
expect(direct).toContain(`exten => ${n},1,Goto(sp-local-numbers,${n},1)`);
|
||||
expect(context(conf, 'sp-local-numbers')).toContain(`exten => ${page},1,`);
|
||||
expect(direct).not.toContain(`exten => ${page},`);
|
||||
expect(direct).not.toContain('parkedcalls');
|
||||
});
|
||||
});
|
||||
@@ -77,15 +77,20 @@ export function renderTransports(snap: Snapshot): string {
|
||||
* (global: it would filter trunks too). Browsers (-web) arrive through the reverse proxy: not
|
||||
* filtered.
|
||||
*/
|
||||
function phoneAcl(snap: Snapshot): string[] {
|
||||
if (!snap.settings['sip.restrictPhonesToLocalNets']) return [];
|
||||
function phoneAcl(snap: Snapshot): { desk: string[]; web: string[] } {
|
||||
if (!snap.settings['sip.restrictPhonesToLocalNets']) return { desk: [], web: [] };
|
||||
const nets = [
|
||||
...new Set([...snap.settings['network.localNets'], ...snap.settings['sip.extraAllowedNets']]),
|
||||
];
|
||||
return ['deny = 0.0.0.0/0.0.0.0', ...nets.map((n) => `permit = ${s.cidr(n)}`)];
|
||||
return {
|
||||
desk: ['deny = 0.0.0.0/0.0.0.0', ...nets.map((n) => `permit = ${s.cidr(n)}`)],
|
||||
// M11-T8: -web has the desk phone's password and pjsip takes it on any transport, so
|
||||
// without this it was a way around the ACL. Asterisk's HTTP server only listens on 127.0.0.1.
|
||||
web: ['deny = 0.0.0.0/0.0.0.0', 'permit = 127.0.0.1/255.255.255.255'],
|
||||
};
|
||||
}
|
||||
|
||||
function extensionSections(e: SnapExtension, acl: string[]): string {
|
||||
function extensionSections(e: SnapExtension, acl: { desk: string[]; web: string[] }): string {
|
||||
const num = s.digits(e.number);
|
||||
const name = s.name(e.name) || num;
|
||||
const groups = e.pickupGroup ? s.ident(e.pickupGroup) : null;
|
||||
@@ -128,7 +133,7 @@ function extensionSections(e: SnapExtension, acl: string[]): string {
|
||||
'force_rport = yes',
|
||||
'rewrite_contact = yes',
|
||||
'send_pai = yes',
|
||||
...acl,
|
||||
...acl.desk,
|
||||
...common,
|
||||
]),
|
||||
section(`${num}-auth`, [
|
||||
@@ -158,6 +163,7 @@ function extensionSections(e: SnapExtension, acl: string[]): string {
|
||||
// Otherwise the From domain is the machine's hostname, and SIP.js drops INVITEs whose host
|
||||
// label starts with a digit (e.g. a Docker or VPS hostname). The browser ignores the domain.
|
||||
'from_domain = selfphoneng.invalid',
|
||||
...acl.web,
|
||||
...common,
|
||||
]),
|
||||
section(`${num}-web-auth`, [
|
||||
@@ -230,6 +236,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
|
||||
'type = endpoint',
|
||||
`transport = ${transport}`,
|
||||
`context = sp-from-trunk-${id}`,
|
||||
// No inbound auth on a trunk: without this, a From of trunk-<id> from anywhere would match
|
||||
// it by username and skip the password (M11-T8). Its calls come from its server's address.
|
||||
'identify_by = ip',
|
||||
'disallow = all',
|
||||
`allow = ${t.codecs.map(s.codec).join(',')}`,
|
||||
reg && `outbound_auth = trunk-${id}-auth`,
|
||||
|
||||
@@ -10,9 +10,13 @@ import type { LiveState } from './state.ts';
|
||||
|
||||
const BATCH_MS = 250;
|
||||
const PING_MS = 30_000;
|
||||
/** How often each socket's session is checked again. Tests shorten it. */
|
||||
export const timing = { recheckMs: 30_000 };
|
||||
|
||||
type Client = {
|
||||
ws: WebSocket;
|
||||
/** The session cookie: a logout, deactivation or role change closes the socket (M11-T8). */
|
||||
token: string;
|
||||
admin: boolean;
|
||||
userId: string;
|
||||
/** The user's extension numbers: they see the queues they're an agent of. */
|
||||
@@ -94,13 +98,28 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}, PING_MS);
|
||||
|
||||
// A logout, deactivation or role change reaches open sockets within recheckMs (M11-T8).
|
||||
const recheck = setInterval(() => {
|
||||
for (const c of clients)
|
||||
void getSessionUser(app.db, c.token, false)
|
||||
.then((f) => {
|
||||
if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
|
||||
c.ws.close(4401, 'unauthorized');
|
||||
})
|
||||
.catch(() => {}); // database down: keep the socket, check again next time
|
||||
}, timing.recheckMs);
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
clearInterval(flush);
|
||||
clearInterval(ping);
|
||||
clearInterval(recheck);
|
||||
for (const c of clients) c.ws.close(1001, 'server shutting down');
|
||||
});
|
||||
|
||||
app.get('/api/live', { websocket: true }, async (ws, req) => {
|
||||
// Cross-site WebSocket hijacking: browsers always send Origin; other clients have none.
|
||||
const origin = req.headers.origin;
|
||||
if (origin && URL.parse(origin)?.host !== req.headers.host) return ws.close(4403, 'forbidden');
|
||||
const token = req.cookies[COOKIE];
|
||||
const found = token ? await getSessionUser(app.db, token) : null;
|
||||
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
|
||||
@@ -111,6 +130,7 @@ export async function liveRoutes(app: FastifyInstance) {
|
||||
.where(eq(extensions.userId, found.user.id));
|
||||
const c: Client = {
|
||||
ws,
|
||||
token: token!,
|
||||
admin: found.user.role === 'admin',
|
||||
userId: found.user.id,
|
||||
exts: mine.map((e) => e.n),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T8: a Linphone setup token is a credential, so the request log never shows it.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { logUrl } from './app.ts';
|
||||
|
||||
describe('logUrl', () => {
|
||||
it('masks the Linphone token and leaves other URLs alone', () => {
|
||||
expect(logUrl('/prov/linphone/AbC_123-xyzxyzxyzxyzxyz.xml')).toBe('/prov/linphone/…');
|
||||
expect(logUrl('/PROV/linphone/secret.xml?x=1')).toBe('/PROV/linphone/…?x=1');
|
||||
expect(logUrl('/prov/001565123456.cfg')).toBe('/prov/001565123456.cfg');
|
||||
expect(logUrl('/api/calls?from=2026-10-01')).toBe('/api/calls?from=2026-10-01');
|
||||
});
|
||||
});
|
||||
@@ -74,13 +74,13 @@ export async function createBackup(
|
||||
const work = await mkdtemp(join(tmpdir(), 'sp-backup-'));
|
||||
const tmp = join(src.outDir, `.${name}.tmp`);
|
||||
try {
|
||||
await run('pg_dump', [
|
||||
'--no-owner',
|
||||
'--dbname',
|
||||
src.databaseUrl,
|
||||
'--file',
|
||||
join(work, 'db.sql'),
|
||||
]);
|
||||
// The password goes in the environment: `ps` on the host shows a container's arguments (M11-T8).
|
||||
const db = new URL(src.databaseUrl);
|
||||
const password = decodeURIComponent(db.password);
|
||||
db.password = '';
|
||||
await run('pg_dump', ['--no-owner', '--dbname', db.href, '--file', join(work, 'db.sql')], {
|
||||
env: { ...process.env, PGPASSWORD: password },
|
||||
});
|
||||
const included: string[] = [];
|
||||
const args = ['-czf', tmp, '-C', work, 'db.sql', 'manifest.json'];
|
||||
for (const [as, dir] of Object.entries(src.dirs)) {
|
||||
|
||||
@@ -22,12 +22,16 @@ for (const [net, bits] of [
|
||||
['169.254.0.0', 16],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
['192.0.0.0', 24], // IETF protocol assignments (M11-T8)
|
||||
['198.18.0.0', 15], // benchmarking, sometimes used inside networks
|
||||
['224.0.0.0', 3], // multicast and reserved
|
||||
] as const)
|
||||
blocked.addSubnet(net, bits, 'ipv4');
|
||||
for (const [net, bits] of [
|
||||
['::', 127], // :: and ::1 (IPv4-mapped addresses match the IPv4 rules)
|
||||
['fc00::', 7],
|
||||
['64:ff9b::', 96], // NAT64: the low 32 bits are an IPv4 address, possibly a private one
|
||||
['2002::', 16], // 6to4: the same
|
||||
['fe80::', 10],
|
||||
['ff00::', 8],
|
||||
] as const)
|
||||
|
||||
@@ -39,12 +39,15 @@ function snapshot(): Snapshot {
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
||||
) as Snapshot['settings'];
|
||||
// Phone ACL on, the Docker networks allowed: the desk phones register, a "-web" login doesn't.
|
||||
settings['sip.restrictPhonesToLocalNets'] = true;
|
||||
settings['network.localNets'] = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
|
||||
const ext = (number: '101' | '102', name: string) => ({
|
||||
id: `00000000-0000-4000-8000-000000000${number}`,
|
||||
number,
|
||||
name,
|
||||
sipPassword: PASS[number],
|
||||
webrtcEnabled: false,
|
||||
webrtcEnabled: number === '102',
|
||||
maxContacts: 5,
|
||||
ringSeconds: 25,
|
||||
voicemailEnabled: false,
|
||||
@@ -104,7 +107,12 @@ function phoneConfig(dir: string, ext: '101' | '102', port: number, toneHz: numb
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n`,
|
||||
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n` +
|
||||
// M11-T8: the desk phone's password also opens 102-web; over UDP from a phone's network
|
||||
// that must fail (browsers only come through the reverse proxy on 127.0.0.1).
|
||||
(ext === '102'
|
||||
? `<sip:102-web@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=manual;regint=60\n`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,6 +215,27 @@ async function main() {
|
||||
if (!/101\/sip:.*Avail/.test(contacts) || !/102\/sip:.*Avail/.test(contacts))
|
||||
throw new Error(`phones did not register:\n${contacts}`);
|
||||
log('101 and 102 registered');
|
||||
await sleep(3000);
|
||||
if (/102-web\/sip:/.test(await ami.command('pjsip show contacts')))
|
||||
problems.push('102-web registered over UDP from the phones network with the ACL on');
|
||||
else log('102-web over UDP: refused (phone ACL covers browser endpoints)');
|
||||
// ...while a browser through the reverse proxy (WebSocket from 127.0.0.1) still registers.
|
||||
const wsReg = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
`container:${AST}`,
|
||||
'-v',
|
||||
`${join(import.meta.dirname, 'ws-register.mjs')}:/ws-register.mjs:ro`,
|
||||
'node:24-trixie-slim',
|
||||
'node',
|
||||
'/ws-register.mjs',
|
||||
'ws://127.0.0.1:8088/ws',
|
||||
'102-web',
|
||||
PASS['102'],
|
||||
).out.trim();
|
||||
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
|
||||
else log('102-web over WebSocket from 127.0.0.1: registered');
|
||||
// Real list responses with registered phones, for the live-state fixture.
|
||||
recorder?.write(
|
||||
'Action: PJSIPShowContacts\r\nActionID: rec-2\r\n\r\n' +
|
||||
|
||||
@@ -31,6 +31,7 @@ const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
|
||||
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
|
||||
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
|
||||
const PHONES = 'sp-trunk-phones';
|
||||
const ATTACKER = 'sp-trunk-attacker';
|
||||
const EXT_ID = '00000000-0000-4000-8000-000000000101';
|
||||
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
|
||||
const DID = '13125551234';
|
||||
@@ -127,6 +128,16 @@ function snapshot(): Snapshot {
|
||||
trunkIds: [GOOD],
|
||||
callerId: null,
|
||||
},
|
||||
{
|
||||
id: 'aaaaaaaa-0000-4000-8000-000000000002',
|
||||
name: 'International',
|
||||
position: 1,
|
||||
enabled: true,
|
||||
patterns: [{ match: '011.', strip: 0, prepend: '' }],
|
||||
requiredClass: 'international',
|
||||
trunkIds: [GOOD],
|
||||
callerId: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -246,7 +257,7 @@ async function main() {
|
||||
);
|
||||
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES);
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
|
||||
docker('network', 'create', NET);
|
||||
const prov = asterisk(PROVIDER, provConf, []);
|
||||
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
|
||||
@@ -387,6 +398,24 @@ async function main() {
|
||||
log('class check: ss-noservice');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// M11-T8: SIP allows "," in a dialed number. Unfiltered, Goto(sp-route-…,${EXTEN},1) became
|
||||
// Goto(sp-route-…,011…,4,1) and started past the class check: international for anyone.
|
||||
await sleep(1000);
|
||||
await ami.command('channel originate Local/01144207946095,4@sp-from-ext application Wait 10');
|
||||
await waitFor(
|
||||
'ss-noservice for 01144207946095,4',
|
||||
async () => /Playback!ss-noservice/.test(await ami.command('core show channels concise')),
|
||||
10,
|
||||
).catch((e: Error) => problems.push(e.message));
|
||||
if (
|
||||
/LAST_IN=to=011/.test(
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
|
||||
)
|
||||
)
|
||||
problems.push('a comma in the dialed number skipped the class check: 011… reached the trunk');
|
||||
else log('class check: a comma in the number does not skip it');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// M3-T4: 911 from a caller with no class at all still goes out, with the E911 caller ID, and
|
||||
// the dialplan tells the api. (The fake provider answers it; nothing real is dialed.)
|
||||
const userEvents: Record<string, string | string[]>[] = [];
|
||||
@@ -437,6 +466,35 @@ async function main() {
|
||||
log('911: out with the E911 caller ID, SpEmergency raised, alert plays on 101');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// M11-T8: a trunk has no inbound password, so only its server's address may match it. Before
|
||||
// identify_by = ip, an INVITE from anywhere with From: trunk-<id> matched it by username.
|
||||
const atkConf = join(dir, 'attacker');
|
||||
mkdirSync(atkConf);
|
||||
for (const f of GENERATED_FILES)
|
||||
writeFileSync(
|
||||
join(atkConf, f),
|
||||
f === 'pjsip_selfphoneng.conf'
|
||||
? `[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
|
||||
`[pbx]\ntype = endpoint\ncontext = default\ndisallow = all\nallow = ulaw\naors = pbx\n` +
|
||||
`from_user = trunk-${GOOD}\n\n[pbx]\ntype = aor\ncontact = sip:${PBX}:5060\n`
|
||||
: '',
|
||||
);
|
||||
if (!asterisk(ATTACKER, atkConf, []).ok) throw new Error('attacker container');
|
||||
await booted(ATTACKER);
|
||||
dials.length = 0;
|
||||
docker(
|
||||
'exec',
|
||||
ATTACKER,
|
||||
'asterisk',
|
||||
'-rx',
|
||||
`channel originate PJSIP/${DID}@pbx application Wait 5`,
|
||||
);
|
||||
await sleep(6000);
|
||||
if (dials.some((m) => String(m.DestChannel).startsWith('PJSIP/101-')))
|
||||
problems.push(`an INVITE claiming From: trunk-${GOOD} from another address rang 101`);
|
||||
else log('a From of trunk-<id> from another address is not taken for the trunk');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
// An RTCP report sent while a test container's network is going away fails with EPERM;
|
||||
@@ -454,7 +512,7 @@ async function main() {
|
||||
}
|
||||
recorder?.destroy();
|
||||
ami.stop();
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES);
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
|
||||
docker('network', 'rm', NET);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// REGISTER over SIP WebSocket with digest auth, like the browser softphone through the reverse
|
||||
// proxy. call-test.ts runs it in Asterisk's network namespace, so it comes from 127.0.0.1.
|
||||
// Usage: node ws-register.mjs <ws url> <user> <password>. Prints the final status line.
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
const [url, user, pass] = process.argv.slice(2);
|
||||
const md5 = (s) => createHash('md5').update(s).digest('hex');
|
||||
const host = new URL(url).hostname;
|
||||
const uri = `sip:${host}`;
|
||||
const callId = randomUUID();
|
||||
const tag = randomUUID().slice(0, 8);
|
||||
|
||||
const register = (cseq, auth) =>
|
||||
[
|
||||
`REGISTER ${uri} SIP/2.0`,
|
||||
`Via: SIP/2.0/WS ws-test.invalid;branch=z9hG4bK${randomUUID().slice(0, 12)}`,
|
||||
'Max-Forwards: 70',
|
||||
`From: <sip:${user}@${host}>;tag=${tag}`,
|
||||
`To: <sip:${user}@${host}>`,
|
||||
`Call-ID: ${callId}`,
|
||||
`CSeq: ${cseq} REGISTER`,
|
||||
`Contact: <sip:${user}@ws-test.invalid;transport=ws>`,
|
||||
'Expires: 60',
|
||||
...(auth ? [`Authorization: ${auth}`] : []),
|
||||
'Content-Length: 0',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
const ws = new WebSocket(url, 'sip');
|
||||
const timer = setTimeout(() => {
|
||||
console.log('TIMEOUT');
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
ws.onopen = () => ws.send(register(1));
|
||||
ws.onerror = (e) => {
|
||||
console.log(`ERROR ${e.message ?? e}`);
|
||||
process.exit(1);
|
||||
};
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = String(data);
|
||||
const status = msg.split('\r\n')[0];
|
||||
const challenge = /WWW-Authenticate: Digest (.*)/i.exec(msg)?.[1];
|
||||
if (status.includes(' 401 ') && challenge) {
|
||||
const p = Object.fromEntries(
|
||||
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((m) => [m[1], m[2]]),
|
||||
);
|
||||
const cnonce = randomUUID().slice(0, 8);
|
||||
const ha1 = md5(`${user}:${p.realm}:${pass}`);
|
||||
const ha2 = md5(`REGISTER:${uri}`);
|
||||
const response = md5(`${ha1}:${p.nonce}:00000001:${cnonce}:auth:${ha2}`);
|
||||
ws.send(
|
||||
register(
|
||||
2,
|
||||
`Digest username="${user}", realm="${p.realm}", nonce="${p.nonce}", uri="${uri}", ` +
|
||||
`response="${response}", algorithm=MD5, qop=auth, nc=00000001, cnonce="${cnonce}"` +
|
||||
(p.opaque ? `, opaque="${p.opaque}"` : ''),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(status);
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
};
|
||||
@@ -73,6 +73,18 @@ describe.skipIf(!hasTestDb)('auth', () => {
|
||||
expect(r.json().error.code).toBe('csrf');
|
||||
});
|
||||
|
||||
it('M11-T8: a percent-encoded path gets the same CSRF check', async () => {
|
||||
for (const url of ['/%61pi/auth/login', '/api%2Fauth/login', '//api/auth/login']) {
|
||||
const r = await t.app.inject({
|
||||
method: 'POST',
|
||||
url,
|
||||
payload: { email: ADMIN.email, password: adminPassword },
|
||||
});
|
||||
expect([403, 404], url).toContain(r.statusCode);
|
||||
if (r.statusCode === 403) expect(r.json().error.code).toBe('csrf');
|
||||
}
|
||||
});
|
||||
|
||||
it('must_change_password blocks everything outside /api/auth/* until changed', async () => {
|
||||
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
|
||||
const blocked = await get('/api/admin-probe', cookie);
|
||||
|
||||
@@ -27,6 +27,10 @@ it('isPrivateAddress', () => {
|
||||
'fe80::1',
|
||||
'fd00::1',
|
||||
'::ffff:7f00:1',
|
||||
'198.18.0.1',
|
||||
'192.0.0.8',
|
||||
'64:ff9b::a00:1',
|
||||
'2002:7f00:1::1',
|
||||
])
|
||||
expect(isPrivateAddress(ip), ip).toBe(true);
|
||||
for (const ip of ['8.8.8.8', '203.0.113.9', '2001:4860:4860::8888', '::ffff:8.8.8.8'])
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { AmiParser, field, type AmiMessage } from '../src/ami/events.ts';
|
||||
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { extensions, userRuntimeState } from '../src/db/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { extensions, sessions, userRuntimeState, users } from '../src/db/schema.ts';
|
||||
import { timing } from '../src/live/hub.ts';
|
||||
import { LiveState, extOfEndpoint, stateFromStatus } from '../src/live/state.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
import { ADMIN, createTestApp } from './app.ts';
|
||||
@@ -252,3 +254,51 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
|
||||
for (const c of [admin, me]) c.ws.terminate();
|
||||
});
|
||||
});
|
||||
|
||||
// M11-T8: a socket from another site is refused; a logout, deactivation or demotion closes it.
|
||||
describe.skipIf(!hasTestDb)('/api/live WebSocket re-checks', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
beforeAll(async () => {
|
||||
timing.recheckMs = 200;
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
});
|
||||
afterAll(() => {
|
||||
timing.recheckMs = 30_000;
|
||||
return t?.close();
|
||||
});
|
||||
|
||||
const open = async (headers: Record<string, string>) => {
|
||||
const ws = await t.app.injectWS('/api/live', { headers });
|
||||
let closed: number | undefined;
|
||||
ws.on('close', (code: number) => (closed = code));
|
||||
return () => closed;
|
||||
};
|
||||
const until = async (fn: () => boolean) => {
|
||||
for (let i = 0; i < 100 && !fn(); i++) await new Promise((r) => setTimeout(r, 20));
|
||||
expect(fn()).toBe(true);
|
||||
};
|
||||
|
||||
it('refuses an Origin that is not this host', async () => {
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
const closed = await open({ cookie, origin: 'https://evil.example', host: 'pbx.test' });
|
||||
await until(() => closed() === 4403);
|
||||
const ok = await open({ cookie, origin: 'https://pbx.test', host: 'pbx.test' });
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(ok()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('closes the socket once the session is gone or the role changed', async () => {
|
||||
const admin = await t.addUser('demoted@example.com', 'user-password-123', 'admin');
|
||||
const closedByRole = await open({
|
||||
cookie: await t.login('demoted@example.com', 'user-password-123'),
|
||||
});
|
||||
const loggedOut = await open({ cookie: await t.login(ADMIN.email, ADMIN.password) });
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
expect(closedByRole()).toBeUndefined();
|
||||
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, admin.id));
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
await t.db.delete(sessions).where(eq(sessions.userId, me!.id));
|
||||
await until(() => closedByRole() === 4401 && loggedOut() === 4401);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user