security: headers, phone ACL, PIN rules for PINs set from the phone
Headers (08 §2.4) on every response: CSP (connect-src also allows
WEBRTC_WS_URL's origin when set), nosniff, Referrer-Policy,
Permissions-Policy. The dark-mode bootstrap moved from an inline
<script> to /theme.js so script-src stays 'self'.
Phone ACL (08 §2.2): with sip.restrictPhonesToLocalNets, every desk
extension endpoint gets deny 0.0.0.0/0 and permit for each local and
extra net. The spec's snippet was wrong: contact_acl names acl.conf
sections, a pjsip type=acl is the global ACL (it would filter trunks),
and Contact-header rules let a NATed remote phone through. Browser
endpoints come through the reverse proxy and aren't filtered. Golden
case phone-acl. Settings → Security holds it, with the provisioning
switches.
PIN rules for PINs changed from the phone's voicemail menu, which went
around the api: externpasscheck = sp-vm-pincheck (in the Asterisk
image, same rules as isTrivialPin, a unit test keeps them in step) and
minpassword = 4. Accepted PINs reach the api through AMI
VoicemailPasswordChange and are stored; before, the next config write
silently put the old PIN back.
e2e m11-t1: no CSP violations across admin pages; Asterisk refuses a
REGISTER ("Not match Endpoint ACL") until the network is listed; from
the phone, 1111 is refused by the check script and 7391 is stored and
survives a later config write.
Task: M11-T1
This commit is contained in:
1 parent
62939f0138
commit
65e777ab41
38 files changed
+950
-19
No files matched your search
@@ -89,6 +89,27 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
|
||||
});
|
||||
|
||||
// Security headers on every response (08 §2.4). The softphone's WebSocket may live elsewhere
|
||||
// (WEBRTC_WS_URL, e.g. in dev), so its origin is allowed too. HSTS is the reverse proxy's job.
|
||||
const wsOrigins = [
|
||||
`wss://${config.PUBLIC_HOSTNAME}`,
|
||||
...(config.WEBRTC_WS_URL ? [new URL(config.WEBRTC_WS_URL).origin] : []),
|
||||
];
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`connect-src 'self' ${[...new Set(wsOrigins)].join(' ')}`,
|
||||
"media-src 'self' blob:",
|
||||
"img-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ');
|
||||
app.addHook('onSend', async (_req, reply) => {
|
||||
reply.header('Content-Security-Policy', csp);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('Referrer-Policy', 'same-origin');
|
||||
reply.header('Permissions-Policy', 'microphone=(self), camera=()');
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_req, reply) => {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
+4
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[sp_admin_user]
|
||||
type = user
|
||||
admin = yes
|
||||
marked = yes
|
||||
music_on_hold_when_empty = yes
|
||||
+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,239 @@
|
||||
; 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,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
|
||||
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()
|
||||
|
||||
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
|
||||
; Exten = the extension that dialed 911.
|
||||
[sp-emergency-alert]
|
||||
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Playback(beep&beep&beep)
|
||||
same => n,Playback(extension)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13).
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
same => n,Hangup()
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
same => n,Hangup()
|
||||
exten => *78,1,Answer() ; DND on
|
||||
same => n,Set(DB(SP/DND/${SP_EXT})=1)
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(do-not-disturb&activated)
|
||||
same => n,Hangup()
|
||||
exten => *79,1,Answer() ; DND off
|
||||
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
|
||||
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(do-not-disturb&de-activated)
|
||||
same => n,Hangup()
|
||||
exten => _*72X.,1,Answer() ; *72<number> forward always
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
|
||||
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
|
||||
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
|
||||
same => n,Playback(call-fwd-unconditional&activated)
|
||||
same => n,Hangup()
|
||||
exten => *73,1,Answer() ; cancel forward always
|
||||
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
|
||||
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
|
||||
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.
|
||||
exten => *8,1,Pickup()
|
||||
same => n,Hangup()
|
||||
exten => *43,1,Answer() ; echo test
|
||||
same => n,Playback(demo-echotest)
|
||||
same => n,Echo()
|
||||
same => n,Playback(demo-echodone)
|
||||
same => n,Hangup()
|
||||
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(K=SP/QPAUSE/${Q}/${SP_EXT})
|
||||
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
|
||||
same => n,Set(DB(${K})=1)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
|
||||
same => n,Playback(agent-loggedoff)
|
||||
same => n,Hangup()
|
||||
same => n(login),Set(X=${DB_DELETE(${K})})
|
||||
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
|
||||
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(agent-loginok)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
|
||||
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
|
||||
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
|
||||
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
|
||||
same => n,Hangup()
|
||||
same => n(deny),Answer()
|
||||
same => n,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
|
||||
exten => _*3[123]X,1,Answer()
|
||||
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
|
||||
same => n,Goto(sp-tc-override,${EXTEN},1)
|
||||
same => n(deny),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[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,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Calls from trunk "voip.ms"
|
||||
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
|
||||
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
|
||||
same => n,Set(CDR(SP_DIRECTION)=inbound)
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
|
||||
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
|
||||
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
|
||||
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
|
||||
same => n,Set(SP_IN=${GOSUB_RETVAL})
|
||||
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
|
||||
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
|
||||
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
|
||||
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
|
||||
same => n(blocked),Hangup(21)
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
[sp-outbound-routes]
|
||||
exten => pending,1,Hangup() ; no routes yet
|
||||
|
||||
[sp-emergency-dial]
|
||||
exten => 911,1,Goto(sp-emergency,s,1)
|
||||
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
|
||||
|
||||
[sp-emergency]
|
||||
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
|
||||
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
same => n,Set(__SP_DIRECTION=outbound)
|
||||
same => n,Set(CDR(SP_DIRECTION)=outbound)
|
||||
same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||
same => n,Hangup()
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
@@ -0,0 +1,154 @@
|
||||
; 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
|
||||
deny = 0.0.0.0/0.0.0.0
|
||||
permit = 192.168.1.0/24
|
||||
permit = 10.0.0.0/8
|
||||
permit = 203.0.113.0/24
|
||||
callerid = "Dan Doe" <101>
|
||||
mailboxes = 101@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
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 = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[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
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Dan Doe" <101>
|
||||
mailboxes = 101@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
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 = Ab3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[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 = TrunkPw123
|
||||
|
||||
[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,27 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
[general]
|
||||
format = wav
|
||||
attach = no ; the api sends emails itself
|
||||
maxmsg = 100
|
||||
maxsecs = 300
|
||||
minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
nextaftercmd = yes
|
||||
sendvoicemail = no
|
||||
pollmailboxes = yes
|
||||
pollfreq = 30
|
||||
|
||||
[zonemessages]
|
||||
local = America/Chicago|'vm-received' Q 'digits/at' IMp
|
||||
|
||||
[default]
|
||||
101 => 4826,Dan Doe,,,tz=local|attach=no
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"settings": {
|
||||
"sip.restrictPhonesToLocalNets": true,
|
||||
"network.localNets": ["192.168.1.0/24", "10.0.0.0/8"],
|
||||
"sip.extraAllowedNets": ["203.0.113.0/24", "10.0.0.0/8"]
|
||||
},
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
"number": "101",
|
||||
"name": "Dan Doe",
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t",
|
||||
"webrtcEnabled": true
|
||||
}
|
||||
],
|
||||
"trunks": [
|
||||
{
|
||||
"id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37",
|
||||
"name": "voip.ms",
|
||||
"serverHost": "chicago1.voip.ms",
|
||||
"username": "123456_pbx",
|
||||
"password": "TrunkPw123"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -8,6 +8,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('golden', () => {
|
||||
'queue',
|
||||
'conferences',
|
||||
'paging',
|
||||
'phone-acl',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,21 @@ export function renderTransports(snap: Snapshot): string {
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function extensionSections(e: SnapExtension): string {
|
||||
/**
|
||||
* Phone ACL (08 §2.2): desk phones may only talk to us from these nets. Source-address rules on
|
||||
* each desk endpoint, not `contact_acl` (that names acl.conf sections) nor a pjsip `type = acl`
|
||||
* (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 [];
|
||||
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)}`)];
|
||||
}
|
||||
|
||||
function extensionSections(e: SnapExtension, acl: string[]): string {
|
||||
const num = s.digits(e.number);
|
||||
const name = s.name(e.name) || num;
|
||||
const groups = e.pickupGroup ? s.ident(e.pickupGroup) : null;
|
||||
@@ -111,6 +125,7 @@ function extensionSections(e: SnapExtension): string {
|
||||
'force_rport = yes',
|
||||
'rewrite_contact = yes',
|
||||
'send_pai = yes',
|
||||
...acl,
|
||||
...common,
|
||||
]),
|
||||
section(`${num}-auth`, [
|
||||
@@ -229,7 +244,8 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
|
||||
}
|
||||
|
||||
export function renderPjsip(snap: Snapshot): string {
|
||||
const exts = [...snap.extensions].sort(byNumber).map(extensionSections);
|
||||
const acl = phoneAcl(snap);
|
||||
const exts = [...snap.extensions].sort(byNumber).map((e) => extensionSections(e, acl));
|
||||
const trunks = snap.trunks
|
||||
.filter((t) => t.enabled)
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
|
||||
|
||||
@@ -21,6 +21,10 @@ minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
minpassword = 4
|
||||
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
|
||||
; stores them from VoicemailPasswordChange (services/vmpin.ts).
|
||||
externpasscheck = /usr/local/bin/sp-vm-pincheck
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
|
||||
@@ -16,6 +16,7 @@ import { syncAstDb } from './services/astdb.ts';
|
||||
import { watchToggles } from './services/toggles.ts';
|
||||
import { watchCdr } from './services/cdr.ts';
|
||||
import { watchQueueCalls } from './services/queuestats.ts';
|
||||
import { watchPinChanges } from './services/vmpin.ts';
|
||||
import { cleanupRecordings } from './routes/recordings.ts';
|
||||
import { getSetting } from './services/settings.ts';
|
||||
import { todayIn } from './generator/load.ts';
|
||||
@@ -62,6 +63,7 @@ app.decorate('sendMail', sendMail);
|
||||
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
|
||||
watchToggles({ db, ami, live, log: app.log }, ami);
|
||||
watchQueueCalls({ db, log: app.log }, ami);
|
||||
watchPinChanges({ db, log: app.log, requestApply: () => generator.requestApply('phone-pin') }, ami);
|
||||
const history = watchCdr({ db, log: app.log, recordingsDir: resolve(config.RECORDINGS_DIR) }, ami);
|
||||
const voicemail = watchVoicemail(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T1: the phone-side PIN check (asterisk/sp-vm-pincheck) and the api's rules agree.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTrivialPin } from '../crypto/secrets.ts';
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('../../../../asterisk/sp-vm-pincheck', import.meta.url));
|
||||
const hasSh = spawnSync('sh', ['-c', 'true']).status === 0;
|
||||
const script = (box: string, pin: string) =>
|
||||
spawnSync('sh', [SCRIPT, box, 'default', '0000', pin], { encoding: 'utf8' }).stdout.trim();
|
||||
const api = (box: string, pin: string) =>
|
||||
/^\d{4,10}$/.test(pin) && !isTrivialPin(pin, box) ? 'VALID' : 'INVALID';
|
||||
|
||||
describe.skipIf(!hasSh)('sp-vm-pincheck', () => {
|
||||
it('says what the api says', () => {
|
||||
const pins = [
|
||||
'4826',
|
||||
'1111',
|
||||
'000000',
|
||||
'1234',
|
||||
'123456',
|
||||
'4321',
|
||||
'9876543210',
|
||||
'0123456789',
|
||||
'90123',
|
||||
'101101',
|
||||
'1011',
|
||||
'10110',
|
||||
'12a4',
|
||||
'123',
|
||||
'12345678901',
|
||||
'',
|
||||
'5550199',
|
||||
'2024',
|
||||
];
|
||||
for (const box of ['101', '2000', '55'])
|
||||
for (const pin of pins) expect(script(box, pin), `${box} ${pin}`).toBe(api(box, pin));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Voicemail PINs changed from the phone (*97/*98 → mailbox options → change password). Asterisk
|
||||
// checks the new PIN with sp-vm-pincheck (same rules as the api) and writes it into its own copy
|
||||
// of the config; VoicemailPasswordChange tells us, and we store it so the next config write keeps
|
||||
// it instead of putting the old one back. Never logs the PIN.
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { field, type AmiMessage } from '../ami/events.ts';
|
||||
import { encrypt, isTrivialPin } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions } from '../db/schema.ts';
|
||||
import { audit } from './audit.ts';
|
||||
|
||||
export async function recordPinChange(db: Db, m: AmiMessage): Promise<'saved' | 'refused' | null> {
|
||||
if (m.Event !== 'VoicemailPasswordChange' || field(m, 'Context') !== 'default') return null;
|
||||
const box = field(m, 'Mailbox') ?? '';
|
||||
const pin = field(m, 'NewPassword') ?? '';
|
||||
const [e] = await db
|
||||
.select({ id: extensions.id, number: extensions.number })
|
||||
.from(extensions)
|
||||
.where(and(eq(extensions.number, box), eq(extensions.voicemailEnabled, true)));
|
||||
if (!e) return null;
|
||||
// sp-vm-pincheck refused these already; a second look in case the script is missing.
|
||||
if (!/^\d{4,10}$/.test(pin) || isTrivialPin(pin, e.number)) {
|
||||
await audit(db, {
|
||||
action: 'update',
|
||||
entityType: 'extension',
|
||||
entityId: e.id,
|
||||
summary: `Ignored a weak voicemail PIN set from phone ${e.number}; the old one stays`,
|
||||
});
|
||||
return 'refused';
|
||||
}
|
||||
await db
|
||||
.update(extensions)
|
||||
.set({ voicemailPinEnc: encrypt(pin) })
|
||||
.where(eq(extensions.id, e.id));
|
||||
await audit(db, {
|
||||
action: 'update',
|
||||
entityType: 'extension',
|
||||
entityId: e.id,
|
||||
summary: `Voicemail PIN of ${e.number} changed from the phone`,
|
||||
});
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
export function watchPinChanges(
|
||||
d: { db: Db; log: { error(o: object, msg: string): void }; requestApply: () => void },
|
||||
ami: Pick<EventEmitter, 'on'>,
|
||||
) {
|
||||
ami.on('event', (m: AmiMessage) => {
|
||||
if (m.Event !== 'VoicemailPasswordChange') return;
|
||||
recordPinChange(d.db, m)
|
||||
// Either way, rewrite the config from the database: the new PIN, or the old one back.
|
||||
.then((r) => r && d.requestApply())
|
||||
.catch((err: unknown) => d.log.error({ err }, 'voicemail PIN change failed'));
|
||||
});
|
||||
}
|
||||
@@ -148,4 +148,16 @@ describe.skipIf(!hasTestDb)('auth', () => {
|
||||
expect((await get('/api/nope')).statusCode).toBe(404);
|
||||
expect((await get('/prov/x.cfg')).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('M11-T1: security headers on every response (08 §2.4)', async () => {
|
||||
for (const url of ['/admin/users', '/api/nope', '/healthz']) {
|
||||
const h = (await get(url)).headers;
|
||||
expect(h['content-security-policy'], url).toBe(
|
||||
"default-src 'self'; connect-src 'self' wss://pbx.test; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
|
||||
);
|
||||
expect(h['x-content-type-options']).toBe('nosniff');
|
||||
expect(h['referrer-policy']).toBe('same-origin');
|
||||
expect(h['permissions-policy']).toBe('microphone=(self), camera=()');
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-8
@@ -6,14 +6,7 @@
|
||||
<title>SelfPhoneNG</title>
|
||||
<link rel="icon" type="image/png" href="/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<script>
|
||||
// Apply the theme before first paint so dark mode doesn't flash.
|
||||
try {
|
||||
const t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
document.documentElement.classList.add('dark');
|
||||
} catch {}
|
||||
</script>
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Apply the theme before first paint so dark mode doesn't flash. A file, not inline: the
|
||||
// Content-Security-Policy allows only scripts from this origin (08 §2.4).
|
||||
/* global matchMedia, document */
|
||||
try {
|
||||
const t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
document.documentElement.classList.add('dark');
|
||||
} catch {
|
||||
// no storage (private window): the light default
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Settings (05 §2). M3: Email and Emergency; M7: Recording. The other tabs arrive with their features.
|
||||
// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Security (phone ACL, provisioning).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
@@ -12,6 +12,7 @@ const TABS = [
|
||||
{ id: 'email', label: 'Email' },
|
||||
{ id: 'emergency', label: 'Emergency' },
|
||||
{ id: 'recording', label: 'Recording' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
] as const;
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -40,6 +41,7 @@ export function SettingsPage() {
|
||||
{settings.data && tab === 'email' && <EmailTab s={settings.data} />}
|
||||
{settings.data && tab === 'emergency' && <EmergencyTab s={settings.data} />}
|
||||
{settings.data && tab === 'recording' && <RecordingTab s={settings.data} />}
|
||||
{settings.data && tab === 'security' && <SecurityTab s={settings.data} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -373,3 +375,62 @@ function RecordingTab({ s }: { s: SettingsDto }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Phone ACL (08 §2.2) and provisioning access (07 §2). */
|
||||
function SecurityTab({ s }: { s: SettingsDto }) {
|
||||
const save = useSave();
|
||||
const [restrict, setRestrict] = useState(s['sip.restrictPhonesToLocalNets']);
|
||||
const [extra, setExtra] = useState(s['sip.extraAllowedNets'].join(', '));
|
||||
const [lanOnly, setLanOnly] = useState(s['provisioning.lanOnly']);
|
||||
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
|
||||
const box = (checked: boolean, set: (v: boolean) => void, label: string) => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate({
|
||||
'sip.restrictPhonesToLocalNets': restrict,
|
||||
'sip.extraAllowedNets': extra.split(/[,\s]+/).filter(Boolean),
|
||||
'provisioning.lanOnly': lanOnly,
|
||||
'provisioning.enabled': provOn,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h2 className="font-medium">Desk phones</h2>
|
||||
{box(restrict, setRestrict, 'Only accept desk phones from the networks below')}
|
||||
<p className="-mt-2 text-xs text-zinc-500">
|
||||
Local networks: {s['network.localNets'].join(', ')}. Phones anywhere else (at home, on a
|
||||
mobile network) can't sign in while this is on. Browser phones aren't affected.
|
||||
</p>
|
||||
<Field
|
||||
label="Other networks allowed"
|
||||
hint="For example a branch office: 203.0.113.0/24. Separate with commas."
|
||||
>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<h2 className="font-medium">Phone setup files</h2>
|
||||
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
|
||||
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user