calls: limit outside calls at once, per extension and in total
ci / check (push) Successful in 1m16s
ci / images (push) Successful in 22s

Owner's decision after the M11-T8 review: nothing capped how many calls a stolen password could
run at once. Settings → Security: calls.maxPerExtension (3) and calls.maxTotal (10), 0 = no
limit. Each sp-route-* counts the caller's extension (or whose forward it is: SP_LIMITEXT from
sp-sub-forward) with GROUP(spext) and every outside call with GROUP(spout); over a limit the
caller hears "all circuits are busy". 911 is never limited.

trunk-test.ts on a real Asterisk, limits 1 and 2: a 2nd call from 101's phone and a 3rd in total
are refused (with both 0 the same check fails). call-limits.test.ts covers the 0 combinations.
Also fixes the Security tab's hint: browser phones still work from anywhere.

Task: M11-T8
This commit is contained in:
iamdoubz committed 2026-10-20 13:14:20 -05:00
1 parent e4e50f1c0d
commit f6a9b9a4b1
25 files changed
+203 -9

No files matched your search

@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -267,6 +268,12 @@ exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,$
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})
same => n,GotoIf($["${SP_LIMITEXT}" = ""]?total)
same => n,Set(GROUP(spext)=${SP_LIMITEXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > 3]?busy)
same => n(total),Set(GROUP(spout)=all)
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > 10]?busy)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
@@ -286,6 +293,12 @@ exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${FI
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})
same => n,GotoIf($["${SP_LIMITEXT}" = ""]?total)
same => n,Set(GROUP(spext)=${SP_LIMITEXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > 3]?busy)
same => n(total),Set(GROUP(spout)=all)
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > 10]?busy)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=15551230000)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -256,6 +257,12 @@ exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FIL
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 1]?denied)
same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})
same => n,GotoIf($["${SP_LIMITEXT}" = ""]?total)
same => n,Set(GROUP(spext)=${SP_LIMITEXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > 3]?busy)
same => n(total),Set(GROUP(spout)=all)
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > 10]?busy)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
@@ -280,6 +287,12 @@ exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${FIL
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < 2]?denied)
same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})
same => n,GotoIf($["${SP_LIMITEXT}" = ""]?total)
same => n,Set(GROUP(spext)=${SP_LIMITEXT})
same => n,GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > 3]?busy)
same => n(total),Set(GROUP(spout)=all)
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > 10]?busy)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID: route override > user > the DID a forwarded call came in on > default
same => n,Set(CID=)
@@ -54,6 +54,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} +
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_LIMITEXT=${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outside calls at once (08 §1): the lines in each sp-route-* for calls.maxPerExtension and
// calls.maxTotal, 0 = no limit. trunk-test.ts checks them on a real Asterisk.
import { describe, expect, it } from 'vitest';
import { loadCase } from './golden-case.ts';
import { render } from './index.ts';
const route = (perExt: number, total: number) => {
const snap = loadCase('voipms-basic');
snap.settings['calls.maxPerExtension'] = perExt;
snap.settings['calls.maxTotal'] = total;
const conf = render(snap)['extensions_selfphoneng.conf'];
const start = conf.indexOf('[sp-route-');
return conf.slice(start, conf.indexOf('\n[', start + 1));
};
describe('call limits', () => {
it('both: per extension, then the total', () => {
const r = route(3, 10);
expect(r).toContain('GotoIf($[${GROUP_COUNT(${SP_LIMITEXT}@spext)} > 3]?busy)');
expect(r).toContain('GotoIf($["${SP_LIMITEXT}" = ""]?total)');
expect(r).toContain(' same => n(total),Set(GROUP(spout)=all)');
expect(r).toContain('GotoIf($[${GROUP_COUNT(all@spout)} > 10]?busy)');
expect(r.indexOf('@spext')).toBeLessThan(r.indexOf('Dial('));
});
it('per extension only: a call with no extension skips to the end of the check', () => {
const r = route(2, 0);
expect(r).toContain('GotoIf($["${SP_LIMITEXT}" = ""]?counted)');
expect(r).toContain(' same => n(counted),NoOp()');
expect(r).not.toContain('spout');
});
it('total only, and neither', () => {
expect(route(0, 5)).not.toContain('spext');
expect(route(0, 5)).toContain(' same => n,Set(GROUP(spout)=all)');
expect(route(0, 0)).not.toMatch(/spext|spout|SP_LIMITEXT/);
});
it("a forward counts toward the forwarding extension's limit", () => {
expect(render(loadCase('voipms-basic'))['extensions_selfphoneng.conf']).toContain(
' same => n,Set(SP_LIMITEXT=${ARG2})',
);
});
});
@@ -12,6 +12,27 @@ export function renderOutbound(snap: Snapshot): string {
.filter((r) => r.enabled && r.patterns.length)
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
const perExt = snap.settings['calls.maxPerExtension'];
const total = snap.settings['calls.maxTotal'];
// Outside calls at once (08 §1): per extension (the caller, or whose forward this is) and in
// total. A call with no extension (an admin's external destination) only counts in the total.
const limits = [
...(perExt
? [
' same => n,Set(SP_LIMITEXT=${IF($["${SP_LIMITEXT}" = ""]?${SP_EXT}:${SP_LIMITEXT})})',
` same => n,GotoIf($["\${SP_LIMITEXT}" = ""]?${total ? 'total' : 'counted'})`,
' same => n,Set(GROUP(spext)=${SP_LIMITEXT})',
` same => n,GotoIf($[\${GROUP_COUNT(\${SP_LIMITEXT}@spext)} > ${s.digits(String(perExt))}]?busy)`,
]
: []),
...(total
? [
` same => n${perExt ? '(total)' : ''},Set(GROUP(spout)=all)`,
` same => n,GotoIf($[\${GROUP_COUNT(all@spout)} > ${s.digits(String(total))}]?busy)`,
]
: []),
...(perExt && !total ? [' same => n(counted),NoOp()'] : []),
];
const top = ['[sp-outbound-routes]'];
if (!routes.length) top.push('exten => pending,1,Hangup() ; no routes yet');
const out: string[] = [];
@@ -39,6 +60,7 @@ export function renderOutbound(snap: Snapshot): string {
'exten => _X.,1,Set(__SP_DIRECTION=outbound)',
' same => n,Set(CDR(SP_DIRECTION)=outbound)',
` same => n,GotoIf($[\${IF($["\${SP_CLASS}" = ""]?0:\${SP_CLASS})} < ${CLASS_RANK[r.requiredClass]}]?denied)`,
...limits,
' same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))',
' ; caller ID: route override > user > the DID a forwarded call came in on > default',
` same => n,Set(CID=${r.callerId ? s.digits(r.callerId) : ''})`,
@@ -57,6 +57,7 @@ exten => s,1,Set(__SP_FWDHOPS=$[\${IF($["\${SP_FWDHOPS}" = ""]?0:\${SP_FWDHOPS})
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_LIMITEXT=\${ARG2}) ; counts toward that extension's call limit
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,\${ARG1},1)
same => n(internal),Goto(sp-local-numbers,\${ARG1},1)
+33
View File
@@ -52,6 +52,8 @@ function snapshot(): Snapshot {
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
settings['routing.defaultCallerIdDidId'] = DID_ID;
settings['calls.maxPerExtension'] = 1;
settings['calls.maxTotal'] = 2;
const trunk = (id: string, name: string, password: string) => ({
id,
name,
@@ -398,6 +400,37 @@ async function main() {
log('class check: ss-noservice');
await ami.command('channel request hangup all');
// Outside calls at once (08 §1), 1 per extension and 2 in total here. Local channels carry
// __SP_LIMITEXT like a forward of that extension's; the second try is 101's own phone.
dials.length = 0;
const out = (num: string, ext: string) =>
ami.action({
Action: 'Originate',
Channel: `Local/${num}@sp-from-ext/n`, // /n: no optimizing away (and its GROUP with it)
Application: 'Wait',
Data: '30',
Variable: `__SP_CLASS=2,__SP_LIMITEXT=${ext}`,
Async: 'true',
});
const dialed = (num: string) =>
dials.some(
(m) =>
String(m.DestChannel).startsWith('PJSIP/trunk-') && String(m.DialString).startsWith(num),
);
await out('13125550001', '101');
await waitFor("101's first outside call", async () => dialed('13125550001'), 15);
await ami.command('channel originate PJSIP/101 extension 13125550002@sp-from-ext');
await out('13125550003', '102');
await waitFor("102's call (2 in total)", async () => dialed('13125550003'), 15);
await out('13125550004', '103');
await sleep(5000);
if (dialed('13125550002')) problems.push('101 had 2 outside calls at once, limit 1');
if (dialed('13125550004')) problems.push('3 outside calls at once, total limit 2');
if (!dialed('13125550002') && !dialed('13125550004'))
log('call limits: a 2nd call for 101 and a 3rd in total were refused');
await ami.command('channel request hangup all');
await sleep(2000);
// 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);