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

+5 -1
View File
@@ -158,7 +158,11 @@ real money. Keep:
- international calling **off** at voip.ms and in **Outbound rules** unless you need it;
- the voip.ms **balance alerts and spending limits** on;
- the **desk phone network limit** above on if your phones are all in the office.
- the **desk phone network limit** above on if your phones are all in the office;
- the **outside calls at once** limits (Settings → Security) as low as your office needs: per
extension (3 by default) and for the whole system (10). A stolen password then runs up at most
that many calls at a time. Forwarded calls count for the person who set the forward; 911 is
never limited. Callers over a limit hear "all circuits are busy".
Never reuse a SIP password. SelfPhoneNG generates long random ones; phones get them from the
setup files.
+2
View File
@@ -576,6 +576,8 @@ with a zod schema per key and a default. Missing rows return the default.
| `sms.webhookToken` | string | random 32 chars, made on first read | secret part of the voip.ms SMS callback URL |
| `sip.restrictPhonesToLocalNets` | boolean | false | only accept phone registrations from `network.localNets` (+ `sip.extraAllowedNets`) |
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
| `calls.maxPerExtension` | number | 3 | outside calls going out at once per extension (the caller, or whose forward it is); 0 = no limit (08 §1) |
| `calls.maxTotal` | number | 10 | outside calls going out at once in total; 0 = no limit. 911 counts toward neither |
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
| `provisioning.enabled` | boolean | true | |
| `system.restartRequired` | boolean | false | set by generator |
+12
View File
@@ -434,6 +434,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)
@@ -781,6 +782,16 @@ exten => _011.,1,Goto(sp-route-{id2},${FILTER(0-9*#+,${EXTEN:0})},1)
[sp-route-{id}]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < {required_class_number}]?denied)
; outside calls at once (calls.maxPerExtension, calls.maxTotal; a line pair is left out when its
; setting is 0, and with no total the per-extension skip goes to n(counted),NoOp()). A forward
; counts for the extension whose forward it is (SP_LIMITEXT from sp-sub-forward); a call with no
; extension (sp-ext-forward) only in the total. call-limits.test.ts, trunk-test.ts.
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)} > {calls.maxPerExtension}]?busy)
same => n(total),Set(GROUP(spout)=all)
same => n,GotoIf($[${GROUP_COUNT(all@spout)} > {calls.maxTotal}]?busy)
same => n,ExecIf($["${SP_RECMODE}" = "outbound" | "${SP_RECMODE}" = "all"]?Gosub(sp-sub-record,s,1))
; caller ID priority: route override > user's SP_OUTCID > inbound DID (forwards) > default
same => n,Set(CID={route override number, or empty})
@@ -1011,6 +1022,7 @@ real queue caller). Because only the first and last file change this file, the a
| `SP_TARGET` | object contexts | live state |
| `SP_REC` | `sp-sub-record` | CDR → recordings |
| `SP_FWDHOPS`, `SP_FORWARDED` | forwards | loop guard |
| `SP_LIMITEXT` | `sp-sub-forward`; else `SP_EXT` in `sp-route-*` | call limits (group `spext`) |
## 10. Example: life of an inbound call
+1 -1
View File
@@ -47,7 +47,7 @@ Left sidebar navigation, in this order:
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table. As built (M11-T6): From/To dates (the last 30 days by default, a year at most); totals (calls, missed, average talk time); the charts are plain bars, no chart library, each bar labeled for screen readers; the heatmap is a weekday × hour table in the company time zone; the queue table has the same numbers as the queue page |
| SMS | `/admin/sms` | Same UI as the user one, with all DIDs |
| Blocked numbers | `/admin/blocked` | |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens |
| Settings | `/admin/settings` | Tabs: General, Network (public IP detect, LAN IP, local nets, STUN/TURN), SIP (ports, TCP/TLS), Numbering ranges, Email (SMTP + test), Voicemail, Recording (retention, announcement sound + **legal notice text, 08 §5**), Emergency (default E911 caller ID, notify emails/extensions + a **test notification** button that does NOT dial 911), Backups (schedule, keep, include recordings, list + download + "Back up now"), Webhooks, API tokens. Security: desk phone networks, **outside calls at once** (per extension, whole system; 0 = no limit), phone setup files, SIP TLS |
| System | `/admin/system` | Status JSON pretty-printed, Asterisk version, generator runs list, "Apply config now", "Rollback", "Restart telephony", generated-files viewer (read-only, masked), allowlisted CLI dropdown (04 §7.3) with output panel, audit log. As built (owner follow-ups to M11-T7, 2026-10-20), laid out after 3CX's admin dashboard (not copied): a **System information** panel (version, host name, company, public IP static/dynamic, LAN IPs, ports SIP/SIPS/media/HTTPS, telephony running, restart needed; copy buttons) with how to restart (`docker compose restart asterisk` on the server; no restart button), and an **Event log** panel (severity, event code, time, source, details; Load more; Download = `/api/system/events.csv`). Events (`system_events`, kept 90 days): `trunk.down`/`trunk.up` (registration changes), `telephony.down`/`telephony.up` (AMI), `telephony.restartNeeded`, `config.failed`, `emergency.call`/`emergency.test`, `network.publicIpChanged`, `backup.done`/`backup.failed`, `disk.low`. Admin edits stay in the audit log (not shown yet). The dashboard's `restart_required` warning links here. Generator runs, generated files and CLI views aren't built; the admin guide's troubleshooting page covers them from the shell |
### 2.1 First-run setup wizard (`/setup`)
+2 -5
View File
@@ -5,7 +5,7 @@
| Threat | Example | Controls |
|---|---|---|
| SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; trunks matched by source address only (`identify_by = ip`, M11-T8); fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2), browser endpoints included; firewall allow-list |
| **Toll fraud** (the expensive one) | Stolen SIP creds used to call premium international numbers | International route disabled by default; per-user outbound class; `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class; dialed numbers `FILTER`ed before the route's Goto (M11-T8: a "," skipped the class check) |
| **Toll fraud** (the expensive one) | Stolen SIP creds used to call premium international numbers | International route disabled by default; per-user outbound class; outside calls at once per extension (3) and in total (10), Settings → Security; trunk `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class; dialed numbers `FILTER`ed before the route's Goto (M11-T8: a "," skipped the class check) |
| Web credential stuffing | Login brute force | argon2id, rate limits, audit log, TOTP for admins (M11) |
| Voicemail PIN guessing | Calling *98 and trying PINs | 6-digit default PIN, `maxlogins=3`, reject trivial PINs (`000000`, `123456`, the extension number repeated). As built (M11-T1): also for PINs changed from the phone's voicemail menu: `externpasscheck = /usr/local/bin/sp-vm-pincheck` (asterisk image, same rules, a unit test keeps it in step with `isTrivialPin`), `minpassword = 4`; the api stores accepted ones from AMI `VoicemailPasswordChange` (`services/vmpin.ts`), otherwise the next config write would put the old PIN back |
| Provisioning leaks | Fetching `/prov/<mac>.cfg` to steal SIP passwords | Per-device Basic auth, 404 on unknown MAC, rate limit + temporary IP block, optional LAN-only |
@@ -160,6 +160,7 @@ reproduced before the fix where a test could show it, and that test now passes.
| 10 | Low | `*45<queue>` and `**<ext>` put raw dialed text into app arguments | FILTER to digits (03 §6.13) | golden files |
| 11 | Low | pg_dump got the DB password on its command line (visible to `ps` on the host) | `PGPASSWORD` in its environment | pg_dump run with the new URL form |
| 12 | Low | Webhook private-address block missed 192.0.0.0/24, 198.18.0.0/15, NAT64, 6to4 | added | `integrations.test.ts` |
| 13 | Medium | No limit on simultaneous outside calls (trunk `max_channels` empty by default): a stolen password could run many international calls at once | Owner's call (2026-10-20): `calls.maxPerExtension` (3) and `calls.maxTotal` (10), `GROUP_COUNT` in each `sp-route-*` | `trunk-test.ts` (limits 1 and 2: a 2nd call from 101's phone and a 3rd in total refused; with both 0 the same test fails), `call-limits.test.ts` |
Checked and sound (both passes): no `sql.raw` or string-built SQL; every admin route has
`requireAdmin`, and recordings, voicemail, `/api/me/*` check ownership; generated config can't be
@@ -169,10 +170,6 @@ stored as sha256; AES-256-GCM with a random IV per value; AMI, Asterisk HTTP and
127.0.0.1 only; webhooks check addresses at connect time (no DNS rebinding), follow no redirects.
Left as they are, for the owner:
- **Concurrent-call cap.** Trunk `max_channels` is optional and empty by default, and nothing
limits one extension's simultaneous calls. With stolen credentials, the voip.ms balance and
international settings (06 §1) are what limit the damage. A per-extension limit
(`GROUP_COUNT` in `sp-route-*`) or a default trunk limit would be a new setting.
- Turning two-step sign-in on doesn't ask for the password again (turning it off does).
- `*98` allows 3 PIN tries per call, with no limit on calls; only internal phones reach it.
- A known MAC answers `/prov` with 401, an unknown one with 404 (07 §2 accepts this).