docs: initial SelfPhoneNG spec
Fix leftover __OP_* dialplan variables from the openphone rename (all readers use SP_*). Record the reverse-proxy decision in 00 §10.
This commit is contained in:
commit
547bb68a6f
13 files changed
+3804
No files matched your search
@@ -0,0 +1,383 @@
|
||||
# 00 — Overview
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build **SelfPhoneNG**: a fully open source, self-hosted
|
||||
business phone system with the everyday features of 3CX:
|
||||
|
||||
- Extensions (users) with desk phones, softphones and a browser softphone
|
||||
- A SIP trunk the customer brings (reference: voip.ms) carrying one or more DIDs (phone numbers)
|
||||
- Inbound routing: DID → user / ring group / queue / IVR / voicemail, with business hours and holidays
|
||||
- Outbound routing with permissions and toll-fraud protection
|
||||
- Voicemail with email delivery and web playback
|
||||
- IVR ("digital receptionist"), ring groups, call queues
|
||||
- Call recording, call history (CDR), live call dashboard
|
||||
- Transfer, hold, park, pickup, conference rooms, music on hold, paging/intercom
|
||||
- Presence / BLF (busy lamp field)
|
||||
- Desk phone auto-provisioning
|
||||
- SMS through the trunk provider's API
|
||||
- E911 handling that follows US multi-line telephone system law
|
||||
- A REST API and webhooks
|
||||
- Backups and one-command install with Docker Compose
|
||||
|
||||
Target user: one small business or home lab, 1–100 extensions, one server.
|
||||
|
||||
## 2. Non-goals for version 1
|
||||
|
||||
Do NOT build these in v1. Do not leave half-built hooks for them either.
|
||||
|
||||
- Multi-tenancy (several companies on one install)
|
||||
- Native mobile apps (we document open source SIP apps instead, see `07-provisioning.md`)
|
||||
- Video calls, screen sharing, team chat
|
||||
- Fax
|
||||
- CRM integrations (the REST API + webhooks are the integration point)
|
||||
- Being a SIP carrier or selling numbers. The customer always brings their own trunk.
|
||||
- High availability / clustering
|
||||
- Our own SIP or RTP stack
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
┌───────────────────┼─────────────────────────────────────────┐
|
||||
│ Host (Ubuntu/Debian, Docker) │
|
||||
│ │ │
|
||||
│ ┌───────────────┴──────────┐ 443/tcp (HTTPS + WSS) │
|
||||
│ │ Reverse proxy │◄──────── browsers, phones │
|
||||
│ │ (bundled Caddy OR the │ (provisioning) │
|
||||
│ │ user's own nginx) │ │
|
||||
│ └───┬──────────────┬───────┘ │
|
||||
│ │ /api /prov / │ /ws (SIP over WebSocket) │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────────────────────┐ 5060/udp+tcp SIP│
|
||||
│ │ api │AMI│ asterisk │◄─── voip.ms │
|
||||
│ │ (Node) ├──►│ (PJSIP, dialplan, │◄─── desk phones │
|
||||
│ │ serves │ │ voicemail, queues, ...) │ 10000-20000/udp │
|
||||
│ │ web UI │ └──────────┬───────────────┘ RTP audio │
|
||||
│ └──┬───┬───┘ │ │
|
||||
│ │ │ writes generated │ reads generated config, │
|
||||
│ │ │ config files │ writes voicemail/recordings │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ ┌────────────────────────────────┐ │
|
||||
│ │ │ shared volumes (./data/...) │ │
|
||||
│ │ └────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────┐ │
|
||||
│ │ postgres │ │
|
||||
│ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 Responsibilities
|
||||
|
||||
**Asterisk** (container `asterisk`): SIP registration of phones, SIP trunk
|
||||
registration to voip.ms, call setup, audio, codecs, voicemail recording and
|
||||
MWI, queues, conferences, music on hold, call recording to files, presence hints.
|
||||
Asterisk holds **no business data of its own**. It reads config files that the
|
||||
api generates. The only runtime state it keeps is in AstDB (§3.3).
|
||||
|
||||
**api** (container `api`, Node.js/TypeScript):
|
||||
- Owns PostgreSQL. **PostgreSQL is the single source of truth.**
|
||||
- Turns DB rows into Asterisk config files (the "generator", `03-config-generator.md`)
|
||||
and tells Asterisk to reload.
|
||||
- Connects to the Asterisk Manager Interface (AMI) to receive events (calls,
|
||||
device states, registrations, CDRs) and send actions (originate, hangup, reload).
|
||||
- Keeps an in-memory "live state" (active calls, extension states, trunk
|
||||
status) and pushes it to browsers over a WebSocket.
|
||||
- Serves the REST API, the provisioning endpoints, and the built web UI (static files).
|
||||
- Runs background jobs: voicemail email, retention cleanup, backups, nightly regeneration.
|
||||
|
||||
**web** (package `apps/web`, React): the admin panel, the user portal and the
|
||||
browser softphone. It is built to static files and served by the api. It is
|
||||
not a separate container.
|
||||
|
||||
**postgres**: application data.
|
||||
|
||||
**Reverse proxy**: TLS termination for HTTPS and WSS. Either the bundled Caddy
|
||||
(automatic Let's Encrypt) or the user's existing nginx (sample config provided).
|
||||
|
||||
### 3.2 The config loop (memorize this)
|
||||
|
||||
```
|
||||
Admin changes something in the UI
|
||||
→ API validates (zod) and writes to PostgreSQL
|
||||
→ API calls generator.apply() (debounced 1 s, one at a time)
|
||||
→ generator reads the whole DB, renders all Asterisk files as strings
|
||||
→ writes them atomically to /etc/asterisk/selfphoneng/
|
||||
→ sends AMI reload commands for the modules whose files changed
|
||||
→ Asterisk picks up the new config
|
||||
```
|
||||
|
||||
Generation is **pure and complete**: the same DB contents always produce the
|
||||
same files, byte for byte. We never patch a generated file in place. We
|
||||
regenerate everything every time. That keeps it simple and testable.
|
||||
|
||||
### 3.3 Runtime toggles (AstDB)
|
||||
|
||||
Some settings change many times a day and must work even when the user
|
||||
toggles them from a desk phone with a feature code (for example DND, call
|
||||
forwarding, queue login). Regenerating config for each toggle would be
|
||||
wasteful, so these live in **AstDB**, Asterisk's built-in key/value store,
|
||||
and dialplan reads them with `${DB(...)}`.
|
||||
|
||||
Rules:
|
||||
- PostgreSQL still stores the value (table `user_runtime_state`, see `02-data-model.md`).
|
||||
- When the API changes a toggle, it writes PostgreSQL first, then AstDB via AMI `DBPut`/`DBDel`.
|
||||
- When a feature code changes a toggle, the dialplan writes AstDB and fires
|
||||
`UserEvent(SpToggle,...)`. The API hears the event and writes PostgreSQL.
|
||||
- On API startup (and after every AMI reconnect) the API pushes every toggle
|
||||
from PostgreSQL into AstDB (PostgreSQL wins).
|
||||
|
||||
AstDB keys (family/key). This list is complete; do not add keys without updating it.
|
||||
|
||||
| Key | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `SP/DND/<ext>` | `1` or absent | Do Not Disturb |
|
||||
| `SP/CFU/<ext>` | dial string digits or absent | Call forward unconditional (always) |
|
||||
| `SP/CFB/<ext>` | digits or absent | Call forward on busy |
|
||||
| `SP/CFNA/<ext>` | digits or absent | Call forward on no answer (instead of voicemail) |
|
||||
| `SP/TCOVR/<timeConditionId>` | `open`, `closed`, or absent (absent = automatic) | Business hours manual override |
|
||||
| `SP/BLOCK/<callerNumber>` | `1` | Inbound caller blocked |
|
||||
| `SP/QPAUSE/<queueNum>/<ext>` | `1` or absent | Agent logged out (paused) in queue |
|
||||
|
||||
## 4. Tech stack (fixed — do not change without a decision record)
|
||||
|
||||
| Area | Choice | Version |
|
||||
|---|---|---|
|
||||
| Telephony engine | Asterisk, built from source, PJSIP channel driver only (no chan_sip) | 24 LTS (latest 24.x; see 01 §2 while only release candidates exist) |
|
||||
| Language | TypeScript (strict mode) | 5.x |
|
||||
| Runtime | Node.js | 24 LTS |
|
||||
| Package manager | pnpm workspaces | 9.x or newer |
|
||||
| API framework | Fastify | 5.x |
|
||||
| Validation | zod | 3.x |
|
||||
| ORM / migrations | drizzle-orm + drizzle-kit | latest |
|
||||
| Database | PostgreSQL | 18 |
|
||||
| Password hashing | `@node-rs/argon2` (argon2id) | latest |
|
||||
| Email | nodemailer | latest |
|
||||
| Job scheduling | croner | latest |
|
||||
| File watching | chokidar | latest |
|
||||
| WebSocket (api) | @fastify/websocket | latest |
|
||||
| Multipart uploads | @fastify/multipart | latest |
|
||||
| Rate limiting | @fastify/rate-limit | latest |
|
||||
| QR codes | qrcode | latest |
|
||||
| Logging | pino | latest |
|
||||
| Tests | vitest | latest |
|
||||
| Frontend | React 19 + Vite + TypeScript | latest |
|
||||
| Routing (web) | react-router | 7.x |
|
||||
| Data fetching (web) | @tanstack/react-query | 5.x |
|
||||
| Styling | Tailwind CSS | 4.x |
|
||||
| Forms (web) | react-hook-form + @hookform/resolvers | latest |
|
||||
| Client state (web) | zustand | 5.x |
|
||||
| Browser SIP | SIP.js | 0.21.x |
|
||||
| Audio conversion | ffmpeg (in the api image) | distro version |
|
||||
| Text-to-speech (optional, M12) | Piper | latest |
|
||||
| Bundled reverse proxy | Caddy | 2.x |
|
||||
| SIP test tools (dev only) | SIPp, baresip | distro versions |
|
||||
|
||||
The AMI client is written by us (`04-backend-api.md` §4). It's about 200 lines,
|
||||
and existing npm AMI packages are unmaintained.
|
||||
|
||||
License for our code: **AGPL-3.0-or-later**. That fits a self-hosted network
|
||||
service, and Asterisk is GPL-2.0. Put the license in `LICENSE` and a
|
||||
`SPDX-License-Identifier: AGPL-3.0-or-later` header in every source file.
|
||||
|
||||
## 5. Repository layout
|
||||
|
||||
```
|
||||
selfphoneng/ (repo root; the folder on disk may be named differently)
|
||||
├── AGENTS.md
|
||||
├── CLAUDE.md (owner's file — do not overwrite)
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── package.json (pnpm workspace root, scripts)
|
||||
├── pnpm-workspace.yaml
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── docs/
|
||||
│ ├── spec/ (this spec)
|
||||
│ ├── decisions/ (short decision records, NNNN-title.md)
|
||||
│ └── admin-guide/ (end-user docs, written in M11)
|
||||
├── asterisk/
|
||||
│ ├── Dockerfile
|
||||
│ ├── entrypoint.sh
|
||||
│ └── conf/ (STATIC Asterisk config, copied into the image)
|
||||
│ ├── asterisk.conf
|
||||
│ ├── modules.conf
|
||||
│ ├── logger.conf
|
||||
│ ├── manager.conf.template
|
||||
│ ├── http.conf
|
||||
│ ├── rtp.conf.template
|
||||
│ ├── pjsip.conf (just #includes the generated file)
|
||||
│ ├── extensions.conf (static helper contexts + #include generated)
|
||||
│ ├── voicemail.conf (#include generated)
|
||||
│ ├── queues.conf (#include generated)
|
||||
│ ├── musiconhold.conf (#include generated)
|
||||
│ ├── confbridge.conf
|
||||
│ ├── features.conf
|
||||
│ ├── res_parking.conf
|
||||
│ └── cdr_manager.conf
|
||||
├── apps/
|
||||
│ ├── api/
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── drizzle.config.ts
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── index.ts (starts everything)
|
||||
│ │ │ ├── config.ts (reads + validates env vars with zod)
|
||||
│ │ │ ├── db/
|
||||
│ │ │ │ ├── schema.ts (drizzle tables, matches 02-data-model.md)
|
||||
│ │ │ │ ├── client.ts
|
||||
│ │ │ │ └── migrations/ (generated by drizzle-kit)
|
||||
│ │ │ ├── crypto/secrets.ts (AES-256-GCM encrypt/decrypt)
|
||||
│ │ │ ├── auth/ (sessions, password hashing, API tokens, guards)
|
||||
│ │ │ ├── ami/
|
||||
│ │ │ │ ├── client.ts (TCP AMI client)
|
||||
│ │ │ │ └── events.ts (typed event parsing)
|
||||
│ │ │ ├── live/ (in-memory live state + WebSocket hub)
|
||||
│ │ │ ├── generator/
|
||||
│ │ │ │ ├── index.ts (render() and apply())
|
||||
│ │ │ │ ├── sanitize.ts
|
||||
│ │ │ │ ├── load.ts (loads a full snapshot from DB)
|
||||
│ │ │ │ ├── destinations.ts
|
||||
│ │ │ │ ├── pjsip.ts
|
||||
│ │ │ │ ├── dialplan/ (one file per feature)
|
||||
│ │ │ │ ├── voicemail.ts
|
||||
│ │ │ │ ├── queues.ts
|
||||
│ │ │ │ ├── moh.ts
|
||||
│ │ │ │ └── __golden__/ (golden test fixtures)
|
||||
│ │ │ ├── routes/ (Fastify route modules, one per resource)
|
||||
│ │ │ ├── services/ (business logic used by routes + jobs)
|
||||
│ │ │ ├── jobs/ (scheduled background jobs)
|
||||
│ │ │ ├── provisioning/
|
||||
│ │ │ │ └── templates/ (phone config templates)
|
||||
│ │ │ ├── integrations/voipms/ (voip.ms REST API client, SMS)
|
||||
│ │ │ └── media/ (ffmpeg audio conversion)
|
||||
│ │ └── test/
|
||||
│ └── web/
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.ts
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── api/ (typed fetch client)
|
||||
│ ├── components/
|
||||
│ ├── pages/admin/
|
||||
│ ├── pages/user/
|
||||
│ ├── softphone/ (SIP.js wrapper + UI)
|
||||
│ └── live/ (WebSocket client)
|
||||
├── packages/
|
||||
│ └── shared/ (zod schemas + TS types shared by api and web)
|
||||
├── deploy/
|
||||
│ ├── caddy/Caddyfile
|
||||
│ └── nginx/selfphoneng.conf.example
|
||||
├── scripts/ (install.sh, backup.sh, restore.sh)
|
||||
└── data/ (gitignored; runtime volumes)
|
||||
├── postgres/
|
||||
├── asterisk-generated/ (→ /etc/asterisk/selfphoneng)
|
||||
├── voicemail/ (→ /var/spool/asterisk/voicemail)
|
||||
├── recordings/ (→ /var/spool/asterisk/monitor)
|
||||
├── sounds-custom/ (→ /var/lib/asterisk/sounds/custom)
|
||||
├── moh/ (→ /var/lib/asterisk/moh)
|
||||
├── astdb/ (→ /var/lib/asterisk/astdb.sqlite3 folder)
|
||||
├── tls/
|
||||
└── backups/
|
||||
```
|
||||
|
||||
Shared zod schemas live in `packages/shared` so the web UI and API validate the same way.
|
||||
|
||||
## 6. Glossary (read if any telephony word is unfamiliar)
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **PBX** | Private Branch Exchange. The phone system for a business. That is what we are building. |
|
||||
| **SIP** | Session Initiation Protocol. The text protocol phones and servers use to set up calls (INVITE, REGISTER, BYE...). |
|
||||
| **RTP** | Real-time Transport Protocol. Carries the actual audio, on UDP ports 10000–20000. |
|
||||
| **SIP trunk** | A SIP account at a provider (voip.ms) that connects the PBX to the public phone network. |
|
||||
| **DID** | Direct Inward Dialing number: a real phone number like 312-555-1234 that rings into the PBX. |
|
||||
| **Extension** | An internal number (e.g. 101) belonging to a user. |
|
||||
| **Endpoint** | In Asterisk PJSIP: one SIP identity (a phone account or a trunk). |
|
||||
| **AOR** | Address of Record. Where Asterisk finds an endpoint's registered devices ("contacts"). |
|
||||
| **Contact** | One registered device. One user can have several (desk phone + softphone). |
|
||||
| **Registration** | A device telling the server "I am 101, reach me at this IP:port". The PBX also registers *to* voip.ms. |
|
||||
| **Dialplan** | Asterisk's routing script (`extensions.conf`). Contexts contain extensions, which contain numbered priorities (steps). |
|
||||
| **Context** | A named section of the dialplan, e.g. `[sp-internal]`. A channel is always "in" a context. |
|
||||
| **Channel** | One leg of a call inside Asterisk (e.g. `PJSIP/101-00000003`). A normal call has two channels bridged together. |
|
||||
| **Bridge** | Asterisk object that connects channels so they hear each other. |
|
||||
| **Local channel** | A fake channel (`Local/101@ctx`) that runs dialplan as if it were a device. Used for ring groups and queues. |
|
||||
| **AMI** | Asterisk Manager Interface. A TCP text protocol (port 5038) to receive events and send commands. |
|
||||
| **AstDB** | Asterisk's built-in key/value database. |
|
||||
| **CDR** | Call Detail Record. One row per call leg: who called whom, when, how long, outcome. |
|
||||
| **linkedid** | Asterisk ID shared by all channels belonging to one logical call. Used to group CDRs. |
|
||||
| **MWI** | Message Waiting Indicator. The voicemail light on a desk phone. |
|
||||
| **BLF** | Busy Lamp Field. Phone buttons that light up when a colleague is on a call. Uses SIP SUBSCRIBE to Asterisk "hints". |
|
||||
| **Hint** | Dialplan line telling Asterisk which device state an extension number shows (`exten => 101,hint,PJSIP/101`). |
|
||||
| **IVR** | Interactive Voice Response: "Press 1 for sales...". 3CX calls this a "digital receptionist". |
|
||||
| **Ring group** | One number that rings several users (all at once or one after another). |
|
||||
| **Queue** | Callers wait in line with music until an agent is free (call-center style). |
|
||||
| **Park** | Put a call in a numbered slot so anyone can pick it up by dialing the slot. |
|
||||
| **Pickup** | Answer a phone that is ringing on someone else's desk. |
|
||||
| **REFER** | SIP message used for transfers. |
|
||||
| **Blind / attended transfer** | Blind: send the call on without talking first. Attended: talk to the target first, then join. |
|
||||
| **DTMF** | The tones of keypad digits. We use RFC 4733 (in-band RTP events). |
|
||||
| **Codec** | Audio encoding. We use G.722 (HD), µ-law (PCMU, US default) and A-law (PCMA). |
|
||||
| **WebRTC** | Browser real-time media. The browser softphone uses SIP over WebSocket + WebRTC media (DTLS-SRTP, ICE). |
|
||||
| **ICE / STUN / TURN** | NAT traversal for WebRTC. STUN discovers the public IP. TURN relays audio when nothing else works. |
|
||||
| **NAT** | Network Address Translation (home/office routers). The #1 cause of one-way or missing audio. |
|
||||
| **E911** | Enhanced 911: an emergency call that carries a registered street address. |
|
||||
| **MLTS** | Multi-Line Telephone System. A PBX, legally. US Kari's Law and RAY BAUM'S Act apply. |
|
||||
| **Feature code** | Star codes like `*97` (check voicemail). |
|
||||
| **Provisioning** | A desk phone downloads its config file from the PBX over HTTP(S). |
|
||||
| **E.164** | International number format (`+13125551234`). Inside the PBX we store North American numbers as 11 digits `13125551234`. |
|
||||
|
||||
## 7. Numbering plan (defaults; configurable in Settings)
|
||||
|
||||
All internal numbers share **one namespace**. A number may belong to only one
|
||||
object (user, ring group, queue, IVR, conference, parking). The API must
|
||||
reject duplicates across all object types (see `04-backend-api.md` §7.2).
|
||||
|
||||
| Range | Object |
|
||||
|---|---|
|
||||
| 100–499 | Users (extensions) |
|
||||
| 500–549 | Ring groups |
|
||||
| 550–599 | Queues |
|
||||
| 600–649 | IVRs (dialable internally so admins can test them) |
|
||||
| 700 | Park (transfer a call here to park it) |
|
||||
| 701–720 | Parking slots |
|
||||
| 800–849 | Conference rooms |
|
||||
| 850–869 | Paging / intercom groups |
|
||||
| `*` codes | Feature codes (`03-config-generator.md` §9) |
|
||||
|
||||
**Reserved and never assignable:** `211, 311, 411, 511, 611, 711, 811, 911, 933`
|
||||
and anything starting with `0` or `1` followed by more than 2 digits. N11 codes
|
||||
are real public services, so a user with extension 911 or 411 would break
|
||||
emergency or directory calls.
|
||||
|
||||
## 8. External number format
|
||||
|
||||
- Store and compare North American numbers as **11 digits** starting with `1` (`13125551234`).
|
||||
- Store international numbers as `011` + country code + number.
|
||||
- The function `normalizeNumber(input, defaultCountry='1')` in `packages/shared/src/phone.ts` is the only place that does this:
|
||||
- strip spaces, dashes, dots, parentheses
|
||||
- `+1XXXXXXXXXX` → `1XXXXXXXXXX`
|
||||
- 10 digits starting with `[2-9]` → prefix `1`
|
||||
- `+<other>` → `011<other>`
|
||||
- otherwise return as-is if it contains only digits, `*` or `#`; else throw.
|
||||
- Display: `1XXXXXXXXXX` → `(XXX) XXX-XXXX`.
|
||||
|
||||
## 9. Roles
|
||||
|
||||
| Role | Can do |
|
||||
|---|---|
|
||||
| `admin` | Everything |
|
||||
| `user` | Their own extension: voicemail, call history, forwarding, DND, greetings, softphone, contacts, SMS on DIDs assigned to them |
|
||||
|
||||
(A third role, `manager`, with read-only reports and queue control, is a later addition. Don't build it in v1.)
|
||||
|
||||
## 10. Open questions for the owner
|
||||
|
||||
Project name: **SelfPhoneNG** (decided). In code and config it is written `selfphoneng`
|
||||
(package scope `@selfphoneng/*`, paths, DB name). Short prefix `sp` (for SelfPhoneNG):
|
||||
`sp-` for dialplan contexts, `SP_` for channel variables, `SP` for the AstDB family, `Sp…` for UserEvent names,
|
||||
`sp_` for the session cookie, API token prefix and CDR variable mappings.
|
||||
|
||||
1. ~~Should v1 ship with the bundled Caddy enabled by default?~~ **Decided (2026-10-17):** Caddy stays an
|
||||
optional compose profile, and the nginx example is documented.
|
||||
@@ -0,0 +1,595 @@
|
||||
# 01 — Infrastructure: Docker, Asterisk base, networking
|
||||
|
||||
## 1. Containers
|
||||
|
||||
| Service | Image | Network | Why |
|
||||
|---|---|---|---|
|
||||
| `postgres` | `postgres:18-alpine` | bridge, port published on `127.0.0.1:${POSTGRES_PORT:-5433}` | Port 5433 avoids clashing with a PostgreSQL the host may already run on 5432 |
|
||||
| `asterisk` | built from `asterisk/Dockerfile` | **host** | SIP and 10,001 RTP ports. Docker port-publishing a 10k UDP range is slow and breaks NAT handling. Host networking is the standard fix. |
|
||||
| `api` | built from `apps/api/Dockerfile` | **host** | Needs AMI on `127.0.0.1:5038` and serves on `127.0.0.1:${API_PORT:-8080}` |
|
||||
| `caddy` (optional, compose profile `caddy`) | `caddy:2` | host | TLS on 80/443 for people without a reverse proxy |
|
||||
|
||||
### 1.1 `docker-compose.yml` (write exactly this, then adjust only if a test fails)
|
||||
|
||||
```yaml
|
||||
name: selfphoneng
|
||||
|
||||
x-shared-volumes: &shared-volumes
|
||||
- ./data/asterisk-generated:/etc/asterisk/selfphoneng
|
||||
- ./data/voicemail:/var/spool/asterisk/voicemail
|
||||
- ./data/recordings:/var/spool/asterisk/monitor
|
||||
- ./data/sounds-custom:/var/lib/asterisk/sounds/custom
|
||||
- ./data/moh:/var/lib/asterisk/moh
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: selfphoneng
|
||||
POSTGRES_USER: selfphoneng
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql # PG 18 images keep data in /var/lib/postgresql/18/docker; mount the parent, NOT .../data
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U selfphoneng -d selfphoneng"]
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
asterisk:
|
||||
build:
|
||||
context: ./asterisk
|
||||
args:
|
||||
ASTERISK_UID: ${APP_UID:-1000}
|
||||
ASTERISK_GID: ${APP_GID:-1000}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
AMI_SECRET: ${AMI_SECRET:?set in .env}
|
||||
TZ: ${TZ:-America/Chicago}
|
||||
volumes:
|
||||
- ./data/asterisk-generated:/etc/asterisk/selfphoneng
|
||||
- ./data/voicemail:/var/spool/asterisk/voicemail
|
||||
- ./data/recordings:/var/spool/asterisk/monitor
|
||||
- ./data/sounds-custom:/var/lib/asterisk/sounds/custom
|
||||
- ./data/moh:/var/lib/asterisk/moh
|
||||
- ./data/astdb:/var/lib/asterisk/db
|
||||
- ./data/asterisk-logs:/var/log/asterisk
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/api/Dockerfile
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
asterisk: { condition: service_started }
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgres://selfphoneng:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5433}/selfphoneng
|
||||
AMI_HOST: 127.0.0.1
|
||||
AMI_PORT: "5038"
|
||||
AMI_USERNAME: selfphoneng
|
||||
volumes:
|
||||
- ./data/asterisk-generated:/etc/asterisk/selfphoneng
|
||||
- ./data/voicemail:/var/spool/asterisk/voicemail
|
||||
- ./data/recordings:/var/spool/asterisk/monitor
|
||||
- ./data/sounds-custom:/var/lib/asterisk/sounds/custom
|
||||
- ./data/moh:/var/lib/asterisk/moh
|
||||
- ./data/backups:/var/lib/selfphoneng/backups
|
||||
- ./data/tls:/var/lib/selfphoneng/tls
|
||||
|
||||
caddy:
|
||||
image: caddy:2
|
||||
profiles: ["caddy"]
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env}
|
||||
volumes:
|
||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./data/caddy:/data
|
||||
```
|
||||
|
||||
(The `x-shared-volumes` anchor documents the shared set. Services list volumes
|
||||
explicitly so each one is easy to read.)
|
||||
|
||||
**File ownership:** Asterisk and the api both read and write the shared
|
||||
folders. Both run as the same UID/GID (`APP_UID`/`APP_GID`, default 1000).
|
||||
`scripts/install.sh` creates `./data/*` owned by that UID.
|
||||
|
||||
### 1.2 `.env.example`
|
||||
|
||||
```dotenv
|
||||
# --- required ---
|
||||
PUBLIC_HOSTNAME=pbx.example.com # DNS name browsers and phones use
|
||||
POSTGRES_PASSWORD=change-me # install.sh generates a random one
|
||||
AMI_SECRET=change-me # install.sh generates a random one
|
||||
APP_SECRET=change-me # 32 random bytes, base64. Encrypts secrets in the DB. NEVER change after first run.
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
|
||||
|
||||
# --- optional ---
|
||||
TZ=America/Chicago
|
||||
API_PORT=8080
|
||||
POSTGRES_PORT=5433
|
||||
APP_UID=1000
|
||||
APP_GID=1000
|
||||
LOG_LEVEL=info
|
||||
TRUST_PROXY=true # true when behind Caddy/nginx
|
||||
```
|
||||
|
||||
`apps/api/src/config.ts` validates these with zod at startup. If one is
|
||||
missing or invalid, it logs a clear message and exits with code 1.
|
||||
|
||||
### 1.3 `scripts/install.sh`
|
||||
|
||||
1. Check `docker` and `docker compose` exist. Otherwise print how to install them and exit.
|
||||
2. If `.env` is missing, copy `.env.example` and fill `POSTGRES_PASSWORD`,
|
||||
`AMI_SECRET`, `APP_SECRET` with `openssl rand -base64 32`. Prompt for
|
||||
`PUBLIC_HOSTNAME` and the admin email and password.
|
||||
3. `mkdir -p` every `./data/*` folder and `chown` it to `APP_UID:APP_GID`.
|
||||
4. Ask "Use bundled Caddy for HTTPS? [y/N]". If yes, run `docker compose --profile caddy up -d --build`. Otherwise run `docker compose up -d --build` and print the path of `deploy/nginx/selfphoneng.conf.example`.
|
||||
5. Print the URL to open.
|
||||
|
||||
## 2. Asterisk image (`asterisk/Dockerfile`)
|
||||
|
||||
Build Asterisk **24 LTS from source**. Pin an exact version: replace `24.X.Y`
|
||||
with the newest 24.x release on the day you write it, and record it here in
|
||||
the same commit.
|
||||
|
||||
> **Asterisk 24 status (checked 2026-10-17):** Asterisk 24 is the LTS line, but
|
||||
> only release candidates are published so far (24.0.0-rc2). Until 24.0.0 is final,
|
||||
> develop against the newest 24.0.0-rcN. Release candidates may sit under a
|
||||
> different download folder than `releases/`, so check the directory listing. Switch to 24.0.0 (or newer
|
||||
> 24.x) as soon as it is final, and don't ship v1.0 on a release candidate. If a module, option
|
||||
> or dialplan function in this spec turns out to have changed in 24, follow the Asterisk 24
|
||||
> docs and update the spec (AGENTS rule 10).
|
||||
|
||||
```dockerfile
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
FROM debian:trixie-slim AS build
|
||||
ARG ASTERISK_VERSION=24.X.Y
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl wget pkg-config autoconf automake libtool \
|
||||
libedit-dev libjansson-dev libxml2-dev uuid-dev libsqlite3-dev libssl-dev \
|
||||
libsrtp2-dev libcurl4-openssl-dev libspeexdsp-dev libogg-dev libvorbis-dev \
|
||||
libopus-dev subversion bzip2 patch python3 xmlstarlet
|
||||
WORKDIR /usr/src
|
||||
RUN curl -fsSLO https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.tar.gz \
|
||||
&& curl -fsSLO https://downloads.asterisk.org/pub/telephony/asterisk/releases/asterisk-${ASTERISK_VERSION}.sha256 \
|
||||
&& sha256sum -c asterisk-${ASTERISK_VERSION}.sha256 \
|
||||
&& tar xzf asterisk-${ASTERISK_VERSION}.tar.gz
|
||||
WORKDIR /usr/src/asterisk-${ASTERISK_VERSION}
|
||||
RUN ./configure --with-pjproject-bundled --with-jansson-bundled \
|
||||
&& make menuselect.makeopts \
|
||||
&& menuselect/menuselect \
|
||||
--disable BUILD_NATIVE \
|
||||
--enable format_mp3 \
|
||||
--enable CORE-SOUNDS-EN-WAV --enable CORE-SOUNDS-EN-G722 \
|
||||
--enable EXTRA-SOUNDS-EN-WAV --enable EXTRA-SOUNDS-EN-G722 \
|
||||
--enable MOH-OPSOUND-WAV \
|
||||
--disable chan_mgcp --disable chan_skinny --disable chan_unistim \
|
||||
menuselect.makeopts \
|
||||
&& contrib/scripts/get_mp3_source.sh || true \
|
||||
&& make -j"$(nproc)" && make install DESTDIR=/out
|
||||
|
||||
FROM debian:trixie-slim
|
||||
ARG ASTERISK_UID=1000
|
||||
ARG ASTERISK_GID=1000
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libedit2 libjansson4 libxml2 libuuid1 libsqlite3-0 libssl3 libsrtp2-1 \
|
||||
libcurl4 libspeexdsp1 libogg0 libvorbis0a libopus0 gettext-base ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd -g ${ASTERISK_GID} asterisk && useradd -u ${ASTERISK_UID} -g asterisk -d /var/lib/asterisk -s /usr/sbin/nologin asterisk
|
||||
COPY --from=build /out/ /
|
||||
COPY conf/ /etc/asterisk/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/run/asterisk \
|
||||
&& chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
Notes for the implementer:
|
||||
- Debian package names change between releases. If an `apt-get install` fails
|
||||
on `trixie`, find the right name with `apt-cache search`. Do not switch base image.
|
||||
- `format_mp3` downloads mp3 decoder source (LGPL). That is allowed. If it
|
||||
fails, delete that line. Uploaded MP3s are converted by ffmpeg in the api anyway.
|
||||
- **Do not** enable `codec_opus` or `codec_g729` from menuselect. Those are
|
||||
closed-source binary downloads. Browsers and phones get G.722 / µ-law instead.
|
||||
- The `.sha256` file name pattern on downloads.asterisk.org may differ. Check
|
||||
the directory listing and use whatever checksum file is published.
|
||||
|
||||
### 2.1 `asterisk/entrypoint.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# 1. Render templates that need secrets from env.
|
||||
envsubst '${AMI_SECRET}' < /etc/asterisk/manager.conf.template > /etc/asterisk/manager.conf
|
||||
chmod 600 /etc/asterisk/manager.conf
|
||||
# 2. Make sure every generated include file exists so Asterisk can boot
|
||||
# before the api has generated anything.
|
||||
for f in pjsip_selfphoneng.conf extensions_selfphoneng.conf extensions_globals_selfphoneng.conf voicemail_selfphoneng.conf \
|
||||
queues_selfphoneng.conf musiconhold_selfphoneng.conf rtp_selfphoneng.conf \
|
||||
confbridge_selfphoneng.conf; do
|
||||
[ -f "/etc/asterisk/selfphoneng/$f" ] || : > "/etc/asterisk/selfphoneng/$f"
|
||||
done
|
||||
chown -R asterisk:asterisk /etc/asterisk/selfphoneng /var/lib/asterisk/db || true
|
||||
# 3. Run in foreground as the asterisk user.
|
||||
exec /usr/sbin/asterisk -f -U asterisk -G asterisk -vvv
|
||||
```
|
||||
|
||||
## 3. Static Asterisk config files (`asterisk/conf/`)
|
||||
|
||||
These are written once by hand and copied into the image. Anything that
|
||||
depends on DB data goes in a generated file under `/etc/asterisk/selfphoneng/`
|
||||
that the static file `#include`s.
|
||||
|
||||
### 3.1 `asterisk.conf`
|
||||
```ini
|
||||
[directories](!)
|
||||
astetcdir => /etc/asterisk
|
||||
astmoddir => /usr/lib/asterisk/modules
|
||||
astvarlibdir => /var/lib/asterisk
|
||||
astdbdir => /var/lib/asterisk/db
|
||||
astkeydir => /var/lib/asterisk
|
||||
astdatadir => /var/lib/asterisk
|
||||
astagidir => /var/lib/asterisk/agi-bin
|
||||
astspooldir => /var/spool/asterisk
|
||||
astrundir => /var/run/asterisk
|
||||
astlogdir => /var/log/asterisk
|
||||
astsbindir => /usr/sbin
|
||||
|
||||
[options]
|
||||
verbose = 3
|
||||
documentation_language = en_US
|
||||
runuser = asterisk
|
||||
rungroup = asterisk
|
||||
defaultlanguage = en
|
||||
```
|
||||
|
||||
### 3.2 `modules.conf`
|
||||
```ini
|
||||
[modules]
|
||||
autoload = yes
|
||||
; channel drivers we do not use
|
||||
noload = chan_iax2.so
|
||||
noload = chan_dahdi.so
|
||||
noload = chan_mgcp.so
|
||||
noload = chan_skinny.so
|
||||
noload = chan_unistim.so
|
||||
noload = chan_ooh323.so
|
||||
noload = chan_motif.so
|
||||
noload = res_xmpp.so
|
||||
noload = res_pjsip_phoneprov.so ; we do our own provisioning
|
||||
noload = cdr_csv.so
|
||||
noload = cdr_custom.so
|
||||
noload = cdr_sqlite3_custom.so
|
||||
noload = cel_sqlite3_custom.so
|
||||
noload = cel_custom.so
|
||||
noload = res_hep.so
|
||||
noload = res_hep_pjsip.so
|
||||
noload = res_hep_rtcp.so
|
||||
noload = pbx_dundi.so
|
||||
noload = pbx_lua.so
|
||||
noload = pbx_ael.so
|
||||
noload = res_config_ldap.so
|
||||
noload = res_snmp.so
|
||||
```
|
||||
|
||||
### 3.3 `logger.conf`
|
||||
```ini
|
||||
[general]
|
||||
dateformat = %F %T.%3q
|
||||
[logfiles]
|
||||
console => notice,warning,error,verbose
|
||||
messages.log => notice,warning,error
|
||||
security.log => security ; for fail2ban/CrowdSec
|
||||
```
|
||||
|
||||
### 3.4 `manager.conf.template`
|
||||
```ini
|
||||
[general]
|
||||
enabled = yes
|
||||
port = 5038
|
||||
bindaddr = 127.0.0.1
|
||||
displayconnects = no
|
||||
timestampevents = yes
|
||||
|
||||
[selfphoneng]
|
||||
secret = ${AMI_SECRET}
|
||||
deny = 0.0.0.0/0.0.0.0
|
||||
permit = 127.0.0.1/255.255.255.255
|
||||
read = all
|
||||
write = all
|
||||
writetimeout = 5000
|
||||
```
|
||||
|
||||
### 3.5 `http.conf` (WebSocket for the browser softphone)
|
||||
```ini
|
||||
[general]
|
||||
enabled = yes
|
||||
bindaddr = 127.0.0.1 ; only the reverse proxy talks to it
|
||||
bindport = 8088
|
||||
enablestatic = no
|
||||
```
|
||||
Asterisk serves SIP-over-WebSocket at `ws://127.0.0.1:8088/ws`. The reverse
|
||||
proxy exposes it as `wss://PUBLIC_HOSTNAME/ws`. TLS is handled by the proxy,
|
||||
not by Asterisk.
|
||||
|
||||
### 3.6 `rtp.conf`
|
||||
```ini
|
||||
[general]
|
||||
rtpstart = 10000
|
||||
rtpend = 20000
|
||||
#include selfphoneng/rtp_selfphoneng.conf
|
||||
```
|
||||
(The generator writes `icesupport`, `stunaddr` and `[ice_host_candidates]` into
|
||||
`rtp_selfphoneng.conf`, see `03-config-generator.md` §4.)
|
||||
|
||||
### 3.7 `pjsip.conf`
|
||||
```ini
|
||||
; Everything is generated. Do not add sections here.
|
||||
#include selfphoneng/pjsip_selfphoneng.conf
|
||||
```
|
||||
|
||||
### 3.8 `extensions.conf`
|
||||
```ini
|
||||
[general]
|
||||
static = yes
|
||||
writeprotect = yes
|
||||
clearglobalvars = yes
|
||||
|
||||
[globals]
|
||||
#include selfphoneng/extensions_globals_selfphoneng.conf
|
||||
|
||||
; Static helper contexts (sp-sub-*) are generated too (03 §5) so that all
|
||||
; dialplan lives in one place in code.
|
||||
#include selfphoneng/extensions_selfphoneng.conf
|
||||
```
|
||||
### 3.9 `voicemail.conf`
|
||||
```ini
|
||||
#include selfphoneng/voicemail_selfphoneng.conf
|
||||
```
|
||||
|
||||
### 3.10 `queues.conf`
|
||||
```ini
|
||||
[general]
|
||||
persistentmembers = no ; members come from config; pause state from AstDB/API
|
||||
autofill = yes
|
||||
monitor-type = MixMonitor
|
||||
shared_lastcall = yes
|
||||
log_membername_as_agent = yes
|
||||
#include selfphoneng/queues_selfphoneng.conf
|
||||
```
|
||||
|
||||
### 3.11 `musiconhold.conf`
|
||||
```ini
|
||||
[default]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/default
|
||||
sort = random
|
||||
#include selfphoneng/musiconhold_selfphoneng.conf
|
||||
```
|
||||
`install.sh` copies the stock opsound MOH files (CC-BY-SA, open) into `./data/moh/default/` on first install.
|
||||
|
||||
### 3.12 `features.conf`
|
||||
```ini
|
||||
[general]
|
||||
transferdigittimeout = 3
|
||||
xfersound = beep
|
||||
xferfailsound = beeperr
|
||||
pickupexten = *8
|
||||
featuredigittimeout = 1000
|
||||
|
||||
[featuremap]
|
||||
blindxfer = ##
|
||||
atxfer = *2
|
||||
automixmon = *1 ; on-demand recording toggle (needs Dial option X)
|
||||
```
|
||||
|
||||
### 3.13 `res_parking.conf`
|
||||
```ini
|
||||
[general]
|
||||
parkeddynamic = no
|
||||
|
||||
[default]
|
||||
parkext = 700
|
||||
parkpos = 701-720
|
||||
context = sp-parkedcalls
|
||||
parkingtime = 120
|
||||
comebacktoorigin = yes
|
||||
comebackdialtime = 30
|
||||
parkedmusicclass = default
|
||||
findslot = next
|
||||
parkext_exclusive = no
|
||||
parkinghints = yes
|
||||
```
|
||||
|
||||
### 3.14 `confbridge.conf`
|
||||
```ini
|
||||
[general]
|
||||
[default_bridge]
|
||||
type = bridge
|
||||
max_members = 20
|
||||
mixing_interval = 20
|
||||
internal_sample_rate = auto
|
||||
record_conference = no
|
||||
|
||||
[default_user]
|
||||
type = user
|
||||
music_on_hold_when_empty = yes
|
||||
announce_user_count = yes
|
||||
announce_join_leave = no
|
||||
dsp_drop_silence = yes
|
||||
|
||||
[default_menu]
|
||||
type = menu
|
||||
* = playback_and_continue(conf-usermenu)
|
||||
1 = toggle_mute
|
||||
#include selfphoneng/confbridge_selfphoneng.conf
|
||||
```
|
||||
|
||||
### 3.15 `cdr.conf` and `cdr_manager.conf`
|
||||
```ini
|
||||
; cdr.conf
|
||||
[general]
|
||||
enable = yes
|
||||
unanswered = yes
|
||||
congestion = yes
|
||||
endbeforehexten = yes
|
||||
```
|
||||
```ini
|
||||
; cdr_manager.conf
|
||||
[general]
|
||||
enabled = yes
|
||||
[mappings]
|
||||
SP_DID => sp_did
|
||||
SP_DIRECTION => sp_direction
|
||||
SP_REC => sp_recording
|
||||
```
|
||||
The api turns `Cdr` AMI events into rows in `cdr` (see `04-backend-api.md` §5.4).
|
||||
|
||||
## 4. Networking and NAT
|
||||
|
||||
### 4.1 Ports
|
||||
|
||||
| Port | Proto | Who connects | Expose to internet? |
|
||||
|---|---|---|---|
|
||||
| 443 | TCP | Browsers (UI + WSS softphone), phones (provisioning) | Yes |
|
||||
| 80 | TCP | Let's Encrypt HTTP challenge (Caddy only) | Only with Caddy |
|
||||
| 5060 | UDP+TCP | voip.ms, remote desk phones / softphones | Yes, ideally limited to voip.ms IPs + known remote IPs |
|
||||
| 5061 | TCP | TLS SIP (optional, remote phones, voip.ms encrypted) | Optional |
|
||||
| 10000–20000 | UDP | RTP audio from voip.ms, phones, browsers | Yes |
|
||||
| 5038 | TCP | AMI | **Never** (bound to 127.0.0.1) |
|
||||
| 8088 | TCP | Asterisk HTTP/WS | **Never** (bound to 127.0.0.1) |
|
||||
| 8080 | TCP | api | **Never** (bound to 127.0.0.1, behind proxy) |
|
||||
| 5433 | TCP | PostgreSQL | **Never** (bound to 127.0.0.1) |
|
||||
|
||||
### 4.2 NAT rules (most audio problems come from here)
|
||||
|
||||
The settings page "Network" (stored in `settings`, generated into PJSIP transports) has:
|
||||
- `publicIp`: the WAN IP. The UI has a "Detect" button (the api asks `https://api.ipify.org`, and the user can override it).
|
||||
- `localNets`: list of CIDRs that are "inside" (default `192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12`).
|
||||
|
||||
The admin guide (M11) must tell the user to:
|
||||
1. **Turn off SIP ALG** on every router between the server and the internet. It rewrites SIP packets and breaks calls.
|
||||
2. Port-forward UDP 5060 and UDP 10000–20000 to the server. With double NAT (ISP
|
||||
router in front of their own router), do it on both routers, or use DMZ on the outer one.
|
||||
3. Set `publicIp`. With a dynamic IP, also enable the "Re-check public IP every
|
||||
5 minutes" option (job `publicIpWatch`). When the IP changes, the job updates
|
||||
settings and triggers a regenerate + Asterisk restart (§4.3).
|
||||
|
||||
### 4.3 Changes that need an Asterisk restart
|
||||
|
||||
PJSIP transports cannot be safely reloaded. If the generator sees that the
|
||||
transport sections changed, it writes the files, does NOT reload, and sets
|
||||
`settings.restartRequired = true`. The UI then shows a banner: "Network
|
||||
settings changed. Restart telephony to apply (active calls will drop)." with
|
||||
a button. The button calls `POST /api/system/asterisk/restart`, which sends
|
||||
AMI `Command: core restart gracefully` (waits for calls to end) or, with
|
||||
`?now=true`, `core restart now`.
|
||||
|
||||
## 5. Reverse proxy
|
||||
|
||||
### 5.1 `deploy/caddy/Caddyfile`
|
||||
```
|
||||
{$PUBLIC_HOSTNAME} {
|
||||
encode zstd gzip
|
||||
@sipws path /ws
|
||||
handle @sipws {
|
||||
reverse_proxy 127.0.0.1:8088
|
||||
}
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 `deploy/nginx/selfphoneng.conf.example`
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name pbx.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/pbx.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/pbx.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 50m; # audio uploads
|
||||
|
||||
# SIP over WebSocket -> Asterisk
|
||||
location = /ws {
|
||||
proxy_pass http://127.0.0.1:8088/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Everything else (UI, API, live WebSocket /api/live, provisioning) -> api
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
Add a comment block to the example: if 443 is handled by an nginx `stream {}`
|
||||
block that forwards with `proxy_protocol`, this server must instead
|
||||
`listen 127.0.0.1:<port> ssl proxy_protocol;` and set
|
||||
`set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;`, or the api
|
||||
will see every client as 127.0.0.1, which breaks login rate limiting and audit logs.
|
||||
|
||||
## 6. api image (`apps/api/Dockerfile`)
|
||||
|
||||
```dockerfile
|
||||
FROM node:24-bookworm-slim AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile && pnpm --filter @selfphoneng/shared build \
|
||||
&& pnpm --filter @selfphoneng/web build && pnpm --filter @selfphoneng/api build
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
# pg_dump must be the same major version as the server (18). Debian's own
|
||||
# postgresql-client is older, so use the official PostgreSQL apt repo (PGDG).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg tini ca-certificates curl gnupg \
|
||||
&& install -d /usr/share/postgresql-common/pgdg \
|
||||
&& curl -fsSL -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/apps/api/dist ./dist
|
||||
COPY --from=build /src/apps/api/node_modules ./node_modules
|
||||
COPY --from=build /src/apps/api/src/db/migrations ./migrations
|
||||
COPY --from=build /src/apps/api/src/provisioning/templates ./provisioning-templates
|
||||
COPY --from=build /src/apps/web/dist ./public
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/usr/bin/tini","--"]
|
||||
CMD ["node","dist/index.js"]
|
||||
```
|
||||
(If pnpm's symlinked `node_modules` doesn't survive `COPY`, use `pnpm deploy --filter @selfphoneng/api --prod /out` and copy `/out` instead.)
|
||||
|
||||
On start, `index.ts` does, in order: validate env → connect DB → run
|
||||
migrations → create the initial admin if none exists → start Fastify → connect AMI →
|
||||
sync AstDB → `generator.apply({ force: true })` → start jobs.
|
||||
@@ -0,0 +1,552 @@
|
||||
# 02 — Data model (PostgreSQL 18)
|
||||
|
||||
The DDL below is the contract. `apps/api/src/db/schema.ts` (drizzle) must
|
||||
produce exactly these tables. Generate migrations with `drizzle-kit generate`.
|
||||
Never edit an applied migration. Add a new one instead.
|
||||
|
||||
Conventions:
|
||||
- Primary keys: `uuid` with `default gen_random_uuid()` unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
|
||||
- Every table has `created_at timestamptz not null default now()` and
|
||||
`updated_at timestamptz not null default now()` (left out below to save space,
|
||||
**but add them**). Exceptions: `cdr`, `audit_log`, `webhook_deliveries`, `generator_runs` (append-only, `created_at` only).
|
||||
- Columns ending in `_enc` hold secrets encrypted with AES-256-GCM using
|
||||
`APP_SECRET` (format: `base64(iv[12] | tag[16] | ciphertext)`). Only
|
||||
`crypto/secrets.ts` encrypts and decrypts. API responses never include `_enc`
|
||||
values. They return `"********"` or a `hasPassword: true` flag.
|
||||
- "Internal number" columns are `text` holding digits only (validated in the API).
|
||||
- External numbers use the normalized format from `00-overview.md` §8.
|
||||
|
||||
## 1. The `Destination` type (used in many tables)
|
||||
|
||||
A destination says "where a call goes next". It is stored as `jsonb` and
|
||||
validated by the zod schema `DestinationSchema` in `packages/shared/src/destination.ts`:
|
||||
|
||||
```ts
|
||||
export const DestinationSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('user'), id: z.string().uuid() }), // extensions.id; rings, then that user's no-answer handling
|
||||
z.object({ type: z.literal('voicemail'), id: z.string().uuid() }), // extensions.id; straight to voicemail
|
||||
z.object({ type: z.literal('ring_group'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('queue'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('ivr'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('time_condition'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('conference'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('announcement'), id: z.string().uuid() }),
|
||||
z.object({ type: z.literal('external'), number: z.string() }), // normalized external number
|
||||
z.object({ type: z.literal('hangup') }),
|
||||
]);
|
||||
```
|
||||
|
||||
Because jsonb can't have foreign keys, the API must:
|
||||
- check the referenced row exists when saving (`services/destinations.ts → assertDestinationExists`)
|
||||
- refuse to delete any row that a destination points to, and return HTTP 409 with the list of
|
||||
places using it (`services/destinations.ts → findReferences(type, id)`, which
|
||||
scans every jsonb destination column listed in this file).
|
||||
|
||||
Destination columns (keep this list in sync with `findReferences`):
|
||||
`dids.destination`, `ring_groups.no_answer_destination`,
|
||||
`queues.timeout_destination`, `ivrs.timeout_destination`,
|
||||
`ivrs.invalid_destination`, `ivr_options.destination`,
|
||||
`time_conditions.open_destination`, `time_conditions.closed_destination`,
|
||||
`time_conditions.holiday_destination`, `announcements.destination`,
|
||||
`settings['routing.unmatchedDidDestination']`.
|
||||
|
||||
## 2. DDL
|
||||
|
||||
```sql
|
||||
create extension if not exists citext;
|
||||
|
||||
-- ───────────── settings ─────────────
|
||||
-- Key/value app settings. Keys are listed in §3. Values are JSON.
|
||||
create table settings (
|
||||
key text primary key,
|
||||
value jsonb not null
|
||||
);
|
||||
|
||||
-- ───────────── people and auth ─────────────
|
||||
create type user_role as enum ('admin', 'user');
|
||||
|
||||
create table users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email citext not null unique,
|
||||
display_name text not null,
|
||||
password_hash text not null, -- argon2id
|
||||
role user_role not null default 'user',
|
||||
is_active boolean not null default true,
|
||||
totp_secret_enc text, -- null = 2FA off (M11)
|
||||
must_change_password boolean not null default false,
|
||||
last_login_at timestamptz
|
||||
);
|
||||
|
||||
create table sessions (
|
||||
id text primary key, -- sha256(token) hex; raw token only in cookie
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
expires_at timestamptz not null,
|
||||
ip inet,
|
||||
user_agent text
|
||||
);
|
||||
create index on sessions (user_id);
|
||||
|
||||
create table api_tokens (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
token_prefix text not null, -- first 8 chars, shown in UI
|
||||
token_hash text not null unique, -- sha256 hex
|
||||
created_by uuid not null references users(id),
|
||||
last_used_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
-- ───────────── extensions (telephony identity of a user) ─────────────
|
||||
create type outbound_class as enum ('internal', 'local', 'national', 'international');
|
||||
create type record_mode as enum ('never', 'inbound', 'outbound', 'all');
|
||||
|
||||
create table extensions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique, -- e.g. '101'
|
||||
user_id uuid unique references users(id) on delete set null,
|
||||
name text not null, -- caller ID name, e.g. 'Dan Doe'
|
||||
sip_password_enc text not null, -- generated, 24 chars, never user-chosen
|
||||
webrtc_enabled boolean not null default true,
|
||||
max_contacts int not null default 5,
|
||||
ring_seconds int not null default 25, -- 5..120
|
||||
voicemail_enabled boolean not null default true,
|
||||
voicemail_pin_enc text not null, -- 4–10 digits
|
||||
voicemail_email boolean not null default true, -- email new voicemails to user
|
||||
voicemail_attach_audio boolean not null default true,
|
||||
voicemail_delete_after_email boolean not null default false,
|
||||
outbound_class outbound_class not null default 'national',
|
||||
outbound_caller_id_did_id uuid references dids(id) on delete set null, -- see note *
|
||||
emergency_caller_id_did_id uuid references dids(id) on delete set null,
|
||||
emergency_location text, -- free text e.g. "Floor 2, Room 210"
|
||||
record_mode record_mode not null default 'never',
|
||||
pickup_group text, -- e.g. 'sales'; same group can pick each other up
|
||||
language text not null default 'en',
|
||||
moh_class text not null default 'default',
|
||||
blf_numbers text[] not null default '{}' -- internal numbers shown as BLF keys on their phones/web
|
||||
);
|
||||
-- * dids is created below; in the migration create dids first or add these FKs after.
|
||||
|
||||
create table user_runtime_state ( -- mirrored into AstDB, see 00 §3.3
|
||||
extension_id uuid primary key references extensions(id) on delete cascade,
|
||||
dnd boolean not null default false,
|
||||
cfu text, -- forward always to this number (internal or external)
|
||||
cfb text, -- forward on busy
|
||||
cfna text -- forward on no answer
|
||||
);
|
||||
|
||||
-- ───────────── trunks and numbers ─────────────
|
||||
create type trunk_provider as enum ('voipms', 'generic');
|
||||
create type sip_transport as enum ('udp', 'tcp', 'tls');
|
||||
create type trunk_auth_mode as enum ('registration', 'ip');
|
||||
|
||||
create table trunks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null unique,
|
||||
provider trunk_provider not null default 'voipms',
|
||||
enabled boolean not null default true,
|
||||
server_host text not null, -- e.g. 'chicago1.voip.ms'
|
||||
server_port int not null default 5060,
|
||||
transport sip_transport not null default 'udp',
|
||||
auth_mode trunk_auth_mode not null default 'registration',
|
||||
username text, -- voip.ms sub-account e.g. '123456_pbx'
|
||||
password_enc text,
|
||||
from_user text, -- defaults to username
|
||||
from_domain text, -- defaults to server_host
|
||||
codecs text[] not null default '{ulaw}', -- allowed: ulaw, alaw, g722
|
||||
srtp boolean not null default false, -- only with transport=tls
|
||||
max_channels int, -- null = unlimited
|
||||
-- voip.ms REST API (SMS, DID import). Optional.
|
||||
voipms_api_username text,
|
||||
voipms_api_password_enc text
|
||||
);
|
||||
|
||||
create table dids (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique, -- normalized, '13125551234'
|
||||
trunk_id uuid not null references trunks(id) on delete restrict,
|
||||
label text, -- 'Main line'
|
||||
destination jsonb not null, -- Destination
|
||||
caller_id_name_prefix text, -- e.g. 'SALES:' prepended to caller name
|
||||
record_inbound boolean not null default false,
|
||||
sms_enabled boolean not null default false,
|
||||
e911_address text -- informational; real address is set at voip.ms
|
||||
);
|
||||
|
||||
create table did_sms_users ( -- who may read/send SMS for a DID
|
||||
did_id uuid not null references dids(id) on delete cascade,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
primary key (did_id, user_id)
|
||||
);
|
||||
|
||||
create table outbound_routes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
position int not null, -- lower = matched first
|
||||
enabled boolean not null default true,
|
||||
patterns jsonb not null, -- [{ "match": "NXXNXXXXXX", "strip": 0, "prepend": "1" }]
|
||||
required_class outbound_class not null, -- caller's class must be >= this
|
||||
trunk_ids uuid[] not null, -- tried in order (failover)
|
||||
caller_id_did_id uuid references dids(id) on delete set null -- overrides user's CID
|
||||
);
|
||||
|
||||
create table blocked_numbers (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique, -- normalized; mirrored to AstDB SP/BLOCK
|
||||
reason text
|
||||
);
|
||||
|
||||
-- ───────────── call handling objects ─────────────
|
||||
create type ring_strategy as enum ('ringall', 'hunt');
|
||||
|
||||
create table ring_groups (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique,
|
||||
name text not null,
|
||||
strategy ring_strategy not null default 'ringall',
|
||||
ring_seconds int not null default 25, -- ringall: total; hunt: per member
|
||||
caller_id_name_prefix text,
|
||||
music_instead_of_ring boolean not null default false,
|
||||
no_answer_destination jsonb not null -- Destination
|
||||
);
|
||||
|
||||
create table ring_group_members (
|
||||
ring_group_id uuid not null references ring_groups(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
position int not null,
|
||||
primary key (ring_group_id, extension_id)
|
||||
);
|
||||
|
||||
create type queue_strategy as enum ('ringall', 'leastrecent', 'fewestcalls', 'random', 'rrmemory', 'linear');
|
||||
|
||||
create table queues (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique,
|
||||
name text not null,
|
||||
strategy queue_strategy not null default 'rrmemory',
|
||||
member_ring_seconds int not null default 15,
|
||||
retry_seconds int not null default 5,
|
||||
wrapup_seconds int not null default 10,
|
||||
max_wait_seconds int not null default 300,
|
||||
max_callers int not null default 0, -- 0 = unlimited
|
||||
moh_class text not null default 'default',
|
||||
announce_position boolean not null default true,
|
||||
announce_every_seconds int not null default 60,
|
||||
join_when_no_agents boolean not null default false,
|
||||
record boolean not null default false,
|
||||
sla_seconds int not null default 20, -- for reports
|
||||
caller_id_name_prefix text,
|
||||
timeout_destination jsonb not null -- Destination when max wait reached / no agents
|
||||
);
|
||||
|
||||
create table queue_members (
|
||||
queue_id uuid not null references queues(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
penalty int not null default 0,
|
||||
primary key (queue_id, extension_id)
|
||||
);
|
||||
-- Agent logged in/out lives in AstDB SP/QPAUSE and table queue_member_state:
|
||||
create table queue_member_state (
|
||||
queue_id uuid not null references queues(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
paused boolean not null default false,
|
||||
primary key (queue_id, extension_id)
|
||||
);
|
||||
|
||||
create table sound_files (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
kind text not null check (kind in ('prompt', 'moh')),
|
||||
moh_class_id uuid references moh_classes(id) on delete cascade, -- when kind='moh'
|
||||
source text not null check (source in ('upload', 'tts', 'phone')),
|
||||
tts_text text,
|
||||
original_filename text,
|
||||
asterisk_name text not null unique, -- path for Playback() without extension, e.g. 'custom/3f2a...'
|
||||
duration_ms int not null
|
||||
);
|
||||
create table moh_classes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null unique -- slug [a-z0-9_-]; 'default' is built in and not stored here
|
||||
);
|
||||
|
||||
create table ivrs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique,
|
||||
name text not null,
|
||||
greeting_sound_id uuid not null references sound_files(id) on delete restrict,
|
||||
invalid_sound_id uuid references sound_files(id) on delete set null, -- null = stock 'pbx-invalid'
|
||||
timeout_seconds int not null default 5,
|
||||
max_retries int not null default 3,
|
||||
allow_direct_dial boolean not null default true, -- caller may dial an internal number
|
||||
timeout_destination jsonb not null,
|
||||
invalid_destination jsonb not null
|
||||
);
|
||||
create table ivr_options (
|
||||
ivr_id uuid not null references ivrs(id) on delete cascade,
|
||||
digit text not null check (digit ~ '^[0-9*#]$'),
|
||||
destination jsonb not null,
|
||||
primary key (ivr_id, digit)
|
||||
);
|
||||
|
||||
create table time_conditions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
timezone text not null, -- IANA, e.g. 'America/Chicago'
|
||||
open_ranges jsonb not null, -- [{ "days": ["mon","tue"], "start": "08:00", "end": "17:00" }]
|
||||
use_holidays boolean not null default true,
|
||||
override_digit smallint unique check (override_digit between 1 and 9), -- feature codes *31N/*32N/*33N
|
||||
override_state text check (override_state in ('open', 'closed')), -- null = automatic; mirrored to AstDB SP/TCOVR
|
||||
open_destination jsonb not null,
|
||||
closed_destination jsonb not null,
|
||||
holiday_destination jsonb -- null = use closed_destination
|
||||
);
|
||||
|
||||
create table holidays ( -- global, used by every time condition with use_holidays
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
start_date date not null,
|
||||
end_date date not null, -- inclusive; = start_date for one day
|
||||
recurring_yearly boolean not null default false
|
||||
);
|
||||
|
||||
create table announcements ( -- play a sound, then continue
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
sound_id uuid not null references sound_files(id) on delete restrict,
|
||||
destination jsonb not null
|
||||
);
|
||||
|
||||
create table conferences (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique,
|
||||
name text not null,
|
||||
pin_enc text, -- null = no PIN
|
||||
admin_pin_enc text,
|
||||
max_members int not null default 20,
|
||||
record boolean not null default false
|
||||
);
|
||||
|
||||
create table paging_groups (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
number text not null unique,
|
||||
name text not null,
|
||||
duplex boolean not null default false -- false = one-way page, true = intercom
|
||||
);
|
||||
create table paging_group_members (
|
||||
paging_group_id uuid not null references paging_groups(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
primary key (paging_group_id, extension_id)
|
||||
);
|
||||
|
||||
-- ───────────── devices (provisioning) ─────────────
|
||||
create type phone_vendor as enum ('yealink', 'grandstream', 'polycom', 'generic');
|
||||
|
||||
create table devices (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
mac text not null unique, -- 12 lowercase hex chars, no separators
|
||||
vendor phone_vendor not null,
|
||||
model text,
|
||||
label text,
|
||||
provisioning_password_enc text not null, -- HTTP basic auth password for this device
|
||||
last_provisioned_at timestamptz,
|
||||
last_provisioned_ip inet
|
||||
);
|
||||
|
||||
-- ───────────── history, media, messaging ─────────────
|
||||
create table cdr ( -- one row per AMI Cdr event, raw
|
||||
id bigserial primary key,
|
||||
created_at timestamptz not null default now(),
|
||||
uniqueid text not null,
|
||||
linkedid text not null,
|
||||
start_at timestamptz,
|
||||
answer_at timestamptz,
|
||||
end_at timestamptz,
|
||||
duration int, billsec int,
|
||||
src text, dst text, dcontext text, clid text,
|
||||
channel text, dstchannel text, lastapp text, lastdata text,
|
||||
disposition text, -- ANSWERED, NO ANSWER, BUSY, FAILED, CONGESTION
|
||||
accountcode text, userfield text,
|
||||
sp_did text, sp_direction text, sp_recording text,
|
||||
raw jsonb not null
|
||||
);
|
||||
create index on cdr (linkedid);
|
||||
|
||||
create type call_direction as enum ('inbound', 'outbound', 'internal');
|
||||
create type call_status as enum ('answered', 'missed', 'voicemail', 'abandoned', 'busy', 'failed');
|
||||
|
||||
create table calls ( -- one row per logical call (linkedid)
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
linkedid text not null unique,
|
||||
direction call_direction not null,
|
||||
status call_status not null,
|
||||
started_at timestamptz not null,
|
||||
answered_at timestamptz,
|
||||
ended_at timestamptz not null,
|
||||
from_number text, from_name text,
|
||||
to_number text,
|
||||
did_id uuid references dids(id) on delete set null,
|
||||
answered_by_extension_id uuid references extensions(id) on delete set null,
|
||||
talk_seconds int not null default 0,
|
||||
recording_id uuid references recordings(id) on delete set null
|
||||
);
|
||||
create index on calls (started_at desc);
|
||||
|
||||
create table call_extensions ( -- which extensions were involved (for per-user history)
|
||||
call_id uuid not null references calls(id) on delete cascade,
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
role text not null check (role in ('caller', 'rang', 'answered')),
|
||||
primary key (call_id, extension_id, role)
|
||||
);
|
||||
|
||||
create table recordings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
linkedid text not null,
|
||||
file_path text not null unique, -- relative to recordings root
|
||||
started_at timestamptz not null,
|
||||
duration_ms int,
|
||||
size_bytes bigint
|
||||
);
|
||||
|
||||
create table voicemail_messages ( -- index of files Asterisk wrote
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
extension_id uuid not null references extensions(id) on delete cascade,
|
||||
msg_id text not null unique, -- 'msg_id' field from msgNNNN.txt
|
||||
folder text not null, -- INBOX | Old | Urgent
|
||||
caller_number text, caller_name text,
|
||||
duration_s int not null,
|
||||
received_at timestamptz not null,
|
||||
file_path text not null, -- current path to .wav
|
||||
emailed_at timestamptz,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
create table contacts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
owner_user_id uuid references users(id) on delete cascade, -- null = company phonebook
|
||||
name text not null,
|
||||
company text,
|
||||
email text,
|
||||
numbers jsonb not null, -- [{ "label": "mobile", "number": "13125551234" }]
|
||||
notes text
|
||||
);
|
||||
|
||||
create table sms_messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
did_id uuid not null references dids(id) on delete cascade,
|
||||
direction text not null check (direction in ('in', 'out')),
|
||||
remote_number text not null,
|
||||
body text not null,
|
||||
media jsonb not null default '[]', -- [{ "url": "...", "contentType": "image/jpeg" }]
|
||||
status text not null, -- queued | sent | failed | received
|
||||
provider_id text,
|
||||
error text,
|
||||
sent_by_user_id uuid references users(id) on delete set null,
|
||||
read_at timestamptz
|
||||
);
|
||||
create index on sms_messages (did_id, remote_number, created_at desc);
|
||||
|
||||
-- ───────────── integration, audit, system ─────────────
|
||||
create table webhooks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
url text not null,
|
||||
secret_enc text not null, -- HMAC-SHA256 signing secret
|
||||
events text[] not null, -- see 04 §9
|
||||
enabled boolean not null default true
|
||||
);
|
||||
create table webhook_deliveries (
|
||||
id bigserial primary key,
|
||||
created_at timestamptz not null default now(),
|
||||
webhook_id uuid not null references webhooks(id) on delete cascade,
|
||||
event text not null,
|
||||
payload jsonb not null,
|
||||
attempt int not null default 0,
|
||||
status text not null, -- pending | ok | failed
|
||||
response_code int,
|
||||
next_attempt_at timestamptz
|
||||
);
|
||||
|
||||
create table emergency_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
extension_number text not null,
|
||||
dialed text not null,
|
||||
caller_id_used text not null,
|
||||
location text,
|
||||
notified jsonb not null default '[]' -- [{ "channel": "email", "to": "...", "ok": true }]
|
||||
);
|
||||
|
||||
create table audit_log (
|
||||
id bigserial primary key,
|
||||
created_at timestamptz not null default now(),
|
||||
actor_user_id uuid references users(id) on delete set null,
|
||||
actor_ip inet,
|
||||
action text not null, -- 'create' | 'update' | 'delete' | 'login' | 'login_failed' | ...
|
||||
entity_type text,
|
||||
entity_id text,
|
||||
summary text not null,
|
||||
diff jsonb -- secrets replaced with "********"
|
||||
);
|
||||
|
||||
create table generator_runs (
|
||||
id bigserial primary key,
|
||||
created_at timestamptz not null default now(),
|
||||
trigger text not null, -- 'api-change' | 'startup' | 'nightly' | 'manual'
|
||||
files_changed text[] not null,
|
||||
commands text[] not null, -- AMI commands sent
|
||||
ok boolean not null,
|
||||
error text,
|
||||
duration_ms int not null
|
||||
);
|
||||
```
|
||||
|
||||
Table creation order in the migration must respect foreign keys: `moh_classes`
|
||||
before `sound_files`, `trunks` → `dids` → `extensions`, `recordings` before `calls`.
|
||||
|
||||
## 3. Settings keys
|
||||
|
||||
All values are JSON. `services/settings.ts` exposes `get(key)` / `set(key, value)`
|
||||
with a zod schema per key and a default. Missing rows return the default.
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `general.companyName` | string | `"My Company"` | |
|
||||
| `general.timezone` | string (IANA) | value of env `TZ` | |
|
||||
| `general.language` | string | `"en"` | |
|
||||
| `network.publicIp` | string \| null | null | |
|
||||
| `network.localNets` | string[] | `["192.168.0.0/16","10.0.0.0/8","172.16.0.0/12"]` | |
|
||||
| `network.lanIp` | string \| null | null | server's own LAN address (ICE host candidate mapping) |
|
||||
| `network.publicIpWatch` | boolean | false | |
|
||||
| `network.stunServer` | string \| null | null | e.g. `stun.example.org:3478`. null = no STUN |
|
||||
| `network.turn` | `{url,username,credentialEnc}` \| null | null | Sent to browsers for WebRTC |
|
||||
| `sip.udpPort` | number | 5060 | |
|
||||
| `sip.tcpEnabled` | boolean | true | |
|
||||
| `sip.tlsEnabled` | boolean | false | needs cert in `/var/lib/selfphoneng/tls` |
|
||||
| `sip.tlsPort` | number | 5061 | |
|
||||
| `numbering.users` | `[from,to]` | `[100,499]` | ranges from 00 §7 |
|
||||
| `numbering.ringGroups` | `[from,to]` | `[500,549]` | |
|
||||
| `numbering.queues` | `[from,to]` | `[550,599]` | |
|
||||
| `numbering.ivrs` | `[from,to]` | `[600,649]` | |
|
||||
| `numbering.conferences` | `[from,to]` | `[800,849]` | |
|
||||
| `numbering.paging` | `[from,to]` | `[850,869]` | |
|
||||
| `routing.unmatchedDidDestination` | Destination | `{type:'hangup'}` | |
|
||||
| `routing.defaultCallerIdDidId` | uuid \| null | null | caller ID when no other rule sets one |
|
||||
| `smtp` | `{host,port,secure,username,passwordEnc,from}` \| null | null | |
|
||||
| `voicemail.maxMessageSeconds` | number | 300 | |
|
||||
| `voicemail.maxMessages` | number | 100 | |
|
||||
| `voicemail.retentionDays` | number | 0 | 0 = keep forever |
|
||||
| `recordings.retentionDays` | number | 90 | |
|
||||
| `recordings.announcementSoundId` | uuid \| null | null | "This call may be recorded" |
|
||||
| `emergency.defaultCallerIdDidId` | uuid \| null | null | **required** before 911 works, see 06 §6 |
|
||||
| `emergency.notifyEmails` | string[] | `[]` | |
|
||||
| `emergency.notifyExtensionIds` | uuid[] | `[]` | these phones get a call/page |
|
||||
| `backup.enabled` | boolean | true | |
|
||||
| `backup.time` | `"HH:MM"` | `"03:30"` | |
|
||||
| `backup.keep` | number | 7 | |
|
||||
| `backup.includeRecordings` | boolean | false | |
|
||||
| `webhooks.allowPrivate` | boolean | false | allow webhook URLs on private/loopback IPs |
|
||||
| `setup.completed` | boolean | false | first-run wizard done |
|
||||
| `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 |
|
||||
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
|
||||
| `provisioning.enabled` | boolean | true | |
|
||||
| `system.restartRequired` | boolean | false | set by generator |
|
||||
@@ -0,0 +1,937 @@
|
||||
# 03 — Config generator (DB → Asterisk)
|
||||
|
||||
This is the heart of the product. Read all of it before changing any call-routing code.
|
||||
|
||||
Code lives in `apps/api/src/generator/`.
|
||||
|
||||
## 1. Interface
|
||||
|
||||
```ts
|
||||
// load.ts
|
||||
export type Snapshot = { /* every table from 02-data-model.md that the generator needs, fully loaded,
|
||||
plus resolved settings and decrypted secrets */ };
|
||||
export async function loadSnapshot(db: Db): Promise<Snapshot>;
|
||||
|
||||
// index.ts
|
||||
export type RenderedFiles = Record<GeneratedFileName, string>;
|
||||
export function render(snap: Snapshot): RenderedFiles; // PURE: no I/O, no Date.now(), no randomness
|
||||
export async function apply(opts: { trigger: string; force?: boolean }): Promise<ApplyResult>;
|
||||
export function requestApply(trigger: string): void; // debounced 1000 ms, calls apply()
|
||||
```
|
||||
|
||||
Rules for `render()`:
|
||||
- **Pure and deterministic.** Same snapshot → byte-identical output. Sort
|
||||
everything: users by number, routes by position, members by position, and
|
||||
objects without a natural order by `id`.
|
||||
- The current date is not read inside `render()`. Holiday filtering needs
|
||||
"today", so `loadSnapshot` puts `snap.today` (a `YYYY-MM-DD` string in `general.timezone`) into the snapshot.
|
||||
- Every file starts with the header
|
||||
`; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.`
|
||||
- Never put a value into output without passing it through a sanitizer (§2).
|
||||
|
||||
`apply()` steps:
|
||||
1. Take a process-wide mutex. Only one apply runs at a time.
|
||||
2. `loadSnapshot()` → `render()`.
|
||||
3. For each file, compare with what is on disk. Collect `changed`.
|
||||
4. If nothing changed and not `force`: record a `generator_runs` row and return.
|
||||
5. Copy the current files to `/etc/asterisk/selfphoneng/.prev/` (keep one previous version).
|
||||
6. For each changed file, write `<name>.tmp` then `rename()` it over the old one (atomic).
|
||||
7. Work out reload commands from the changed files (§3). If the PJSIP
|
||||
transport sections changed, don't reload PJSIP. Set
|
||||
`system.restartRequired = true` instead (see `01-infrastructure.md` §4.3).
|
||||
8. Send each command as AMI `Action: Command`. If AMI is down, mark the run
|
||||
`ok=false, error='ami-disconnected'`. The AMI reconnect handler calls `apply({force:true})` later.
|
||||
9. After `queue reload`: re-send every paused queue member (`QueuePause`), because a reload resets pause state.
|
||||
10. Insert a `generator_runs` row. Broadcast `system.configApplied` on the live WebSocket.
|
||||
|
||||
`POST /api/system/config/rollback` copies `.prev/*` back and reloads everything.
|
||||
|
||||
## 2. Sanitizers (`sanitize.ts`)
|
||||
|
||||
Dialplan and config files are code. A display name containing `)` or `;` or
|
||||
a newline could break or inject dialplan. Every value goes through one of these:
|
||||
|
||||
| Function | Allowed | Used for | On bad input |
|
||||
|---|---|---|---|
|
||||
| `digits(v)` | `^[0-9]+$` | internal numbers, DIDs | throw |
|
||||
| `dialable(v)` | `^[0-9*#+]+$` | forward targets, external numbers | throw |
|
||||
| `pattern(v)` | `^[0-9XZN.!\[\]\-]+$`, max 32 | outbound route patterns | throw |
|
||||
| `name(v)` | removes `" ' \ ; , ( ) [ ] { } $ < > \|` and control chars, trims, max 40 chars | caller ID names, labels | clean silently |
|
||||
| `sound(v)` | `^[a-z0-9/_-]+$` | sound file names for Playback | throw |
|
||||
| `ident(v)` | `^[a-z0-9-]+$` | context-name parts (uuids), MOH class names | throw |
|
||||
| `secret(v)` | `^[A-Za-z0-9]+$` | generated SIP passwords | throw |
|
||||
| `pin(v)` | `^[0-9]{4,10}$` | voicemail / conference PINs | throw |
|
||||
| `host(v)` | hostname or IPv4 regex | trunk server host | throw |
|
||||
| `cidr(v)` | IPv4 CIDR regex | local nets | throw |
|
||||
|
||||
The API validates with the same rules (shared zod schemas in `packages/shared`),
|
||||
so a throw here means a bug, and the apply fails loudly. Also write a
|
||||
unit test for each sanitizer with hostile input (`101); System(rm -rf /`, newlines, unicode).
|
||||
|
||||
## 3. Generated files and reload commands
|
||||
|
||||
All files go to `/etc/asterisk/selfphoneng/`.
|
||||
|
||||
| File | Module | AMI `Command:` after change |
|
||||
|---|---|---|
|
||||
| `pjsip_selfphoneng.conf` | res_pjsip | `pjsip reload` (or restart-required if transports changed) |
|
||||
| `extensions_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
||||
| `extensions_globals_selfphoneng.conf` | pbx_config | `dialplan reload` |
|
||||
| `voicemail_selfphoneng.conf` | app_voicemail | `voicemail reload` |
|
||||
| `queues_selfphoneng.conf` | app_queue | `queue reload all` |
|
||||
| `musiconhold_selfphoneng.conf` | res_musiconhold | `moh reload` |
|
||||
| `rtp_selfphoneng.conf` | res_rtp_asterisk | `module reload res_rtp_asterisk.so` |
|
||||
| `confbridge_selfphoneng.conf` | app_confbridge | `module reload app_confbridge.so` |
|
||||
|
||||
Each command is sent once per apply, even if several files map to it.
|
||||
|
||||
**Transport-change detection:** the "transport block" is every section with
|
||||
`type=transport` plus `[global]` and `[system]`. Render it as its own string
|
||||
(`renderTransports(snap)`) and compare its sha256 with the one stored in
|
||||
`settings['system.transportHash']`.
|
||||
|
||||
## 4. `rtp_selfphoneng.conf`
|
||||
|
||||
```ini
|
||||
; GENERATED ...
|
||||
icesupport = yes
|
||||
stunaddr = {network.stunServer} ; omit line when null
|
||||
{if network.lanIp and network.publicIp}
|
||||
[ice_host_candidates]
|
||||
{network.lanIp} => {network.publicIp}
|
||||
{/if}
|
||||
```
|
||||
(`network.lanIp` is the server's own LAN address, see `02-data-model.md` §3.)
|
||||
|
||||
## 5. `pjsip_selfphoneng.conf`
|
||||
|
||||
Render the sections in this order: global, system, transports, extensions
|
||||
(sorted by number), trunks (sorted by name).
|
||||
|
||||
### 5.1 Global, system, transports
|
||||
|
||||
```ini
|
||||
[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:{sip.udpPort}
|
||||
{natLines}
|
||||
|
||||
{if sip.tcpEnabled}
|
||||
[transport-tcp]
|
||||
type = transport
|
||||
protocol = tcp
|
||||
bind = 0.0.0.0:{sip.udpPort}
|
||||
{natLines}
|
||||
{/if}
|
||||
|
||||
{if sip.tlsEnabled}
|
||||
[transport-tls]
|
||||
type = transport
|
||||
protocol = tls
|
||||
bind = 0.0.0.0:{sip.tlsPort}
|
||||
cert_file = /var/lib/selfphoneng/tls/fullchain.pem
|
||||
priv_key_file = /var/lib/selfphoneng/tls/privkey.pem
|
||||
method = tlsv1_2
|
||||
{natLines}
|
||||
{/if}
|
||||
|
||||
[transport-ws]
|
||||
type = transport
|
||||
protocol = ws
|
||||
bind = 0.0.0.0
|
||||
```
|
||||
`{natLines}` is empty when `network.publicIp` is null. Otherwise:
|
||||
```ini
|
||||
external_media_address = {publicIp}
|
||||
external_signaling_address = {publicIp}
|
||||
local_net = {each localNets entry on its own line}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The WebSocket bind address is ignored. The browser connects through Asterisk's HTTP server (`http.conf`).
|
||||
If browser registration fails with a "no transport" error, change
|
||||
`protocol = ws` to `protocol = wss` and fix the spec.
|
||||
- The asterisk container needs `./data/tls:/var/lib/selfphoneng/tls:ro`. Add it to the compose file when TLS is first used (M11).
|
||||
- No `anonymous` endpoint is ever generated, so unauthenticated calls from the internet are rejected.
|
||||
|
||||
### 5.2 One extension (example: 101, Dan Doe, webrtc on)
|
||||
|
||||
```ini
|
||||
; ── extension 101 (Dan Doe) ──
|
||||
[101]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 101-auth
|
||||
aors = 101
|
||||
callerid = "Dan Doe" <101>
|
||||
mailboxes = 101@default ; omit if voicemail disabled
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
dtmf_mode = rfc4733
|
||||
send_pai = yes
|
||||
allow_subscribe = yes
|
||||
named_call_group = sales ; omit if pickup_group null
|
||||
named_pickup_group = sales ; omit if pickup_group null
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=101
|
||||
set_var = SP_CLASS=2 ; internal=0 local=1 national=2 international=3
|
||||
set_var = SP_OUTCID=13125551234 ; omit if outbound_caller_id_did_id null
|
||||
set_var = SP_E911CID=13125551234 ; omit if emergency_caller_id_did_id null
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal ; verify inheritance prefix works in set_var (AGENTS rule 10)
|
||||
|
||||
[101-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 101
|
||||
password = {decrypted sip password}
|
||||
|
||||
[101]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 101@default ; omit if voicemail disabled
|
||||
|
||||
{if webrtc_enabled}
|
||||
[101-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes ; turns on ICE, AVPF, DTLS-SRTP, rtcp-mux
|
||||
disallow = all
|
||||
allow = g722,ulaw ; NO opus: no open source transcoder in Asterisk (AGENTS rule 4)
|
||||
auth = 101-web-auth
|
||||
aors = 101-web
|
||||
callerid = "Dan Doe" <101>
|
||||
mailboxes = 101@default
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
named_call_group = sales
|
||||
named_pickup_group = sales
|
||||
language = en
|
||||
set_var = (same set_var lines as [101])
|
||||
|
||||
[101-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 101-web
|
||||
password = {same decrypted sip password}
|
||||
|
||||
[101-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0 ; browsers don't answer OPTIONS reliably
|
||||
{/if}
|
||||
```
|
||||
|
||||
Desk phones and softphones register as user `101`. The browser softphone
|
||||
registers as `101-web`. Dialing user 101 rings both (§6.2).
|
||||
|
||||
### 5.3 One voip.ms trunk (registration mode)
|
||||
|
||||
Trunk section names use the trunk id: `trunk-{id}`. Example values: server
|
||||
`chicago1.voip.ms`, sub-account `123456_pbx`.
|
||||
|
||||
```ini
|
||||
; ── trunk "voip.ms" ──
|
||||
[trunk-{id}-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 123456_pbx
|
||||
password = {decrypted}
|
||||
|
||||
[trunk-{id}-reg]
|
||||
type = registration
|
||||
transport = transport-udp ; transport-tls when transport=tls
|
||||
outbound_auth = trunk-{id}-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-{id}
|
||||
|
||||
[trunk-{id}]
|
||||
type = aor
|
||||
contact = sip:chicago1.voip.ms:5060
|
||||
qualify_frequency = 60
|
||||
|
||||
[trunk-{id}]
|
||||
type = endpoint
|
||||
transport = transport-udp
|
||||
context = sp-from-trunk-{id}
|
||||
disallow = all
|
||||
allow = ulaw ; from trunks.codecs
|
||||
outbound_auth = trunk-{id}-auth
|
||||
aors = trunk-{id}
|
||||
from_user = 123456_pbx
|
||||
from_domain = chicago1.voip.ms
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_rpid = yes ; carries our caller ID to voip.ms
|
||||
trust_id_inbound = yes
|
||||
dtmf_mode = rfc4733
|
||||
media_encryption = sdes ; only when srtp = true (requires transport = tls)
|
||||
|
||||
[trunk-{id}-identify]
|
||||
type = identify
|
||||
endpoint = trunk-{id}
|
||||
match = chicago1.voip.ms
|
||||
```
|
||||
For `auth_mode = 'ip'`, leave out the `-auth` and `-reg` sections and the `outbound_auth` line.
|
||||
Skip disabled trunks entirely.
|
||||
|
||||
## 6. Dialplan: `extensions_selfphoneng.conf`
|
||||
|
||||
### 6.0 Destination → dialplan (`destinations.ts`)
|
||||
|
||||
`destApp(dest, snap): string` returns ONE dialplan application call that sends the call there:
|
||||
|
||||
| Destination | Output |
|
||||
|---|---|
|
||||
| `user` | `Goto(sp-local-numbers,{ext.number},1)` |
|
||||
| `voicemail` | `Goto(sp-vm-direct,{ext.number},1)` |
|
||||
| `ring_group` | `Goto(sp-rg-{id},s,1)` |
|
||||
| `queue` | `Goto(sp-queue-{id},s,1)` |
|
||||
| `ivr` | `Goto(sp-ivr-{id},s,1)` |
|
||||
| `time_condition` | `Goto(sp-tc-{id},s,1)` |
|
||||
| `conference` | `Goto(sp-conf-{id},s,1)` |
|
||||
| `announcement` | `Goto(sp-ann-{id},s,1)` |
|
||||
| `external` | `Goto(sp-ext-forward,{dialable(number)},1)` |
|
||||
| `hangup` | `Hangup()` |
|
||||
|
||||
If the target row is missing (should be impossible, see 02 §1), output `Hangup()` and log a warning.
|
||||
|
||||
### 6.1 Contexts: complete list
|
||||
|
||||
| Context | Kind | Purpose |
|
||||
|---|---|---|
|
||||
| `sp-from-ext` | static | Entry point for calls from our phones |
|
||||
| `sp-from-trunk-{id}` | per trunk | Entry point for calls from a trunk |
|
||||
| `sp-did-exists` | generated | One empty exten per DID, used to check "is this one of ours?" |
|
||||
| `sp-inbound` | generated | DID → destination |
|
||||
| `sp-local-numbers` | generated | Every internal number + hints |
|
||||
| `sp-featurecodes` | static | `*` codes |
|
||||
| `sp-emergency-dial` / `sp-emergency` | generated | 911 |
|
||||
| `sp-outbound-routes` | generated | Pattern → route |
|
||||
| `sp-route-{id}` | per route | Permission check, caller ID, trunk failover |
|
||||
| `sp-rg-{id}` | per ring group | |
|
||||
| `sp-queue-{id}` | per queue | |
|
||||
| `sp-ivr-{id}` | per IVR | |
|
||||
| `sp-tc-{id}` | per time condition | |
|
||||
| `sp-ann-{id}` | per announcement | |
|
||||
| `sp-conf-{id}` | per conference | |
|
||||
| `sp-page-{id}` | per paging group | |
|
||||
| `sp-vm-direct`, `sp-ext-forward`, `sp-rg-member`, `sp-queue-member` | static | helpers |
|
||||
| `sp-sub-*` | static | Gosub subroutines |
|
||||
| `sp-parkedcalls` | made by res_parking | included by `sp-local-numbers` |
|
||||
|
||||
"static" means the text is a constant string in the generator (`dialplan/static.ts`)
|
||||
with no DB data, so all dialplan stays in one place in code.
|
||||
|
||||
### 6.2 Static subroutines and helpers
|
||||
|
||||
```ini
|
||||
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
|
||||
[sp-sub-normalize]
|
||||
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
|
||||
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
|
||||
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
|
||||
same => n,Return(${N})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
|
||||
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
|
||||
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
|
||||
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── Call a user with all their personal rules.
|
||||
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
|
||||
[sp-sub-user]
|
||||
exten => s,1,Set(__SP_TARGET=${ARG1})
|
||||
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
|
||||
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
|
||||
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
|
||||
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
|
||||
same => n,Return()
|
||||
same => n(vmu),VoiceMail(${ARG1}@default,u)
|
||||
same => n,Return()
|
||||
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
|
||||
same => n,GotoIf($["${FWD}" != ""]?forward)
|
||||
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
|
||||
same => n,Busy(5)
|
||||
same => n,Return()
|
||||
same => n(vmb),VoiceMail(${ARG1}@default,b)
|
||||
same => n,Return()
|
||||
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
|
||||
; Stops after 3 hops so 101→102→101 can't loop forever.
|
||||
[sp-sub-forward]
|
||||
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
|
||||
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
|
||||
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
|
||||
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${ARG1},1)
|
||||
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
|
||||
same => n(stop),Return()
|
||||
|
||||
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
|
||||
[sp-sub-record]
|
||||
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
|
||||
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
|
||||
same => n,MixMonitor(${SP_REC},b)
|
||||
same => n(done),Return()
|
||||
|
||||
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
|
||||
[sp-sub-autoanswer]
|
||||
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
|
||||
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
|
||||
same => n,Return()
|
||||
|
||||
[sp-vm-direct]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,VoiceMail(${EXTEN}@default,u)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-ext-forward]
|
||||
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
|
||||
same => n,Set(__SP_FORWARDED=1)
|
||||
same => n,Goto(sp-outbound-routes,${EXTEN},1)
|
||||
|
||||
[sp-rg-member] ; one member of a ring group (Local channel)
|
||||
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
|
||||
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n(skip),Hangup()
|
||||
|
||||
[sp-queue-member] ; one queue agent (Local channel)
|
||||
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-from-ext]
|
||||
include => sp-emergency-dial
|
||||
include => sp-featurecodes
|
||||
include => sp-local-numbers
|
||||
include => sp-outbound-routes
|
||||
```
|
||||
|
||||
### 6.3 Globals file `extensions_globals_selfphoneng.conf`
|
||||
|
||||
```ini
|
||||
SP_DEFAULT_CID={routing.defaultCallerIdDidId → number, or empty}
|
||||
SP_DEFAULT_E911_CID={emergency.defaultCallerIdDidId → number, or SP_DEFAULT_CID}
|
||||
SP_REC_ANNOUNCE={recordings.announcementSoundId → asterisk_name, or empty}
|
||||
SP_CLASS_101=2 ; one per extension, used by sp-sub-forward
|
||||
SP_TCADMIN_101=1 ; one per extension whose user is an admin (time-condition override codes)
|
||||
```
|
||||
(`routing.defaultCallerIdDidId` is in 02 §3.)
|
||||
|
||||
### 6.4 Trunk entry `sp-from-trunk-{id}`
|
||||
|
||||
```ini
|
||||
[sp-from-trunk-{id}]
|
||||
exten => s,1,Goto(sp-from-trunk-{id},unknown,1)
|
||||
exten => _[+0-9a-zA-Z].,1,Set(__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 (see 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)
|
||||
```
|
||||
|
||||
### 6.5 `sp-did-exists` and `sp-inbound`
|
||||
|
||||
```ini
|
||||
[sp-did-exists]
|
||||
exten => 13125551234,1,NoOp() ; one line per DID
|
||||
|
||||
[sp-inbound]
|
||||
; DID 13125551234 "Main line"
|
||||
exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
same => n,Set(CALLERID(name)=SALES:${CALLERID(name)}) ; only if caller_id_name_prefix
|
||||
same => n,Answer() ; only if record_inbound and announcement set
|
||||
same => n,Playback({SP_REC_ANNOUNCE sound}) ; only if record_inbound and announcement set
|
||||
same => n,Gosub(sp-sub-record,s,1) ; only if record_inbound
|
||||
same => n,{destApp(did.destination)}
|
||||
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,{destApp(routing.unmatchedDidDestination)}
|
||||
```
|
||||
|
||||
### 6.6 `sp-local-numbers`
|
||||
|
||||
```ini
|
||||
[sp-local-numbers]
|
||||
include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never)) ; (ext, ring_seconds, voicemail 1/0, record_mode)
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web ; just PJSIP/101 when webrtc disabled
|
||||
|
||||
; ring groups, queues, ivrs, conferences, paging groups (each one line)
|
||||
exten => 500,1,Goto(sp-rg-{id},s,1)
|
||||
exten => 550,1,Goto(sp-queue-{id},s,1)
|
||||
exten => 600,1,Goto(sp-ivr-{id},s,1)
|
||||
exten => 800,1,Goto(sp-conf-{id},s,1)
|
||||
exten => 850,1,Goto(sp-page-{id},s,1)
|
||||
```
|
||||
|
||||
### 6.7 Ring groups
|
||||
|
||||
`ringall`:
|
||||
```ini
|
||||
[sp-rg-{id}]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)={prefix}${CALLERID(name)}) ; only if prefix
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member,{ring_seconds},t{m if music_instead_of_ring})
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,{destApp(no_answer_destination)}
|
||||
same => n(done),Hangup()
|
||||
```
|
||||
`hunt` (one member at a time, in `position` order):
|
||||
```ini
|
||||
[sp-rg-{id}]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Dial(Local/101@sp-rg-member,{ring_seconds},t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Dial(Local/102@sp-rg-member,{ring_seconds},t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,{destApp(no_answer_destination)}
|
||||
same => n(done),Hangup()
|
||||
```
|
||||
With zero members, leave out the Dial lines.
|
||||
Ring groups deliberately **ignore** members' call forwarding and send nobody to voicemail.
|
||||
|
||||
### 6.8 Queues
|
||||
|
||||
`queues_selfphoneng.conf` (the queue name is its number):
|
||||
```ini
|
||||
[550]
|
||||
strategy = rrmemory
|
||||
timeout = 15 ; member_ring_seconds
|
||||
retry = 5
|
||||
wrapuptime = 10
|
||||
maxlen = 0
|
||||
musicclass = default
|
||||
announce-frequency = 60 ; 0 when announce_position = false
|
||||
announce-position = yes
|
||||
ringinuse = no
|
||||
setinterfacevar = yes
|
||||
; when join_when_no_agents = false add these two lines:
|
||||
joinempty = paused,unavailable,invalid,unknown
|
||||
leavewhenempty = paused,unavailable,invalid,unknown
|
||||
member => Local/101@sp-queue-member/n,0,Dan Doe,hint:101@sp-local-numbers
|
||||
member => Local/102@sp-queue-member/n,0,Ann Lee,hint:102@sp-local-numbers
|
||||
```
|
||||
Dialplan:
|
||||
```ini
|
||||
[sp-queue-{id}]
|
||||
exten => s,1,Set(__SP_TARGET=550)
|
||||
same => n,Answer()
|
||||
same => n,Set(CALLERID(name)={prefix}${CALLERID(name)}) ; only if prefix
|
||||
same => n,Gosub(sp-sub-record,s,1) ; only if record
|
||||
same => n,Queue(550,t,,,{max_wait_seconds})
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
same => n,Hangup()
|
||||
same => n(fail),{destApp(timeout_destination)}
|
||||
```
|
||||
The api handles agent pause/unpause with AMI `QueuePause`, using `Interface: Local/<ext>@sp-queue-member/n`.
|
||||
|
||||
### 6.9 IVR
|
||||
|
||||
```ini
|
||||
[sp-ivr-{id}]
|
||||
include => sp-local-numbers ; only if allow_direct_dial
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
same => n,Background({greeting.asterisk_name})
|
||||
same => n,WaitExten({timeout_seconds})
|
||||
exten => 1,1,{destApp(option 1)} ; one per ivr_options row
|
||||
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= {max_retries}]?giveup)
|
||||
same => n,Playback({invalid sound or pbx-invalid})
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),{destApp(invalid_destination)}
|
||||
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= {max_retries}]?giveup)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),{destApp(timeout_destination)}
|
||||
```
|
||||
Known behavior to mention in the UI: with direct dial on, option `1` waits up to
|
||||
3 s (digit timeout) when internal numbers also start with 1.
|
||||
|
||||
### 6.10 Time conditions (business hours)
|
||||
|
||||
```ini
|
||||
[sp-tc-{id}]
|
||||
exten => s,1,Set(OVR=${DB(SP/TCOVR/{id})})
|
||||
same => n,GotoIf($["${OVR}" = "open"]?open)
|
||||
same => n,GotoIf($["${OVR}" = "closed"]?closed)
|
||||
; holidays (only if use_holidays): one line per DAY
|
||||
same => n,GotoIfTime(*,*,25,dec,America/Chicago?holiday)
|
||||
; open ranges: one line per range
|
||||
same => n,GotoIfTime(08:00-17:00,mon&tue&wed&thu&fri,*,*,America/Chicago?open)
|
||||
same => n,Goto(closed)
|
||||
same => n(open),{destApp(open_destination)}
|
||||
same => n(closed),{destApp(closed_destination)}
|
||||
same => n(holiday),{destApp(holiday_destination ?? closed_destination)}
|
||||
```
|
||||
Holiday rules:
|
||||
- Expand `start_date..end_date` to single days (max 31; validation rejects longer).
|
||||
- `GotoIfTime` can't match a year. So a non-recurring holiday is rendered
|
||||
only if its date is between `snap.today` and `snap.today + 364 days`.
|
||||
Job `nightlyRegenerate` (04 §8) runs at 00:05 local time so past ones drop out.
|
||||
- Months are written `jan feb mar apr may jun jul aug sep oct nov dec`. Days
|
||||
are written `mon tue wed thu fri sat sun`, joined with `&`.
|
||||
- Validation: `start < end` within a range (no ranges across midnight; the user adds two).
|
||||
|
||||
Override codes use `time_conditions.override_digit` (02). Feature codes `*31N` force open,
|
||||
`*32N` force closed, `*33N` automatic (§6.13).
|
||||
|
||||
### 6.11 Announcements, conferences, paging
|
||||
|
||||
```ini
|
||||
[sp-ann-{id}]
|
||||
exten => s,1,Answer()
|
||||
same => n,Playback({sound})
|
||||
same => n,{destApp(destination)}
|
||||
|
||||
; conference without PINs
|
||||
[sp-conf-{id}]
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(CONFBRIDGE(bridge,max_members)={max_members})
|
||||
same => n,Set(CONFBRIDGE(bridge,record_conference)=yes) ; only if record
|
||||
same => n,ConfBridge({number},default_bridge,default_user,default_menu)
|
||||
same => n,Hangup()
|
||||
|
||||
; conference with PIN (and optional admin PIN)
|
||||
[sp-conf-{id}]
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(TRIES=0)
|
||||
same => n(ask),Read(PIN,conf-getpin,10,,1,8)
|
||||
same => n,GotoIf($["${PIN}" = "{admin_pin}"]?admin) ; only if admin_pin
|
||||
same => n,GotoIf($["${PIN}" = "{pin}"]?user)
|
||||
same => n,Set(TRIES=$[${TRIES} + 1])
|
||||
same => n,GotoIf($[${TRIES} < 3]?bad)
|
||||
same => n,Hangup()
|
||||
same => n(bad),Playback(conf-invalidpin)
|
||||
same => n,Goto(ask)
|
||||
same => n(admin),ConfBridge({number},default_bridge,sp_admin_user,default_menu)
|
||||
same => n,Hangup()
|
||||
same => n(user),ConfBridge({number},default_bridge,default_user,default_menu)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-page-{id}]
|
||||
exten => s,1,Page(PJSIP/101&PJSIP/102,{d if duplex}iqb(sp-sub-autoanswer^s^1),30)
|
||||
same => n,Hangup()
|
||||
```
|
||||
Paging only rings desk endpoints (`PJSIP/<ext>`), not `-web`.
|
||||
|
||||
`confbridge_selfphoneng.conf` always contains:
|
||||
```ini
|
||||
[sp_admin_user]
|
||||
type = user
|
||||
admin = yes
|
||||
marked = yes
|
||||
music_on_hold_when_empty = yes
|
||||
```
|
||||
|
||||
### 6.12 Outbound routes
|
||||
|
||||
```ini
|
||||
[sp-outbound-routes]
|
||||
; one line per pattern, routes in position order; disabled routes skipped
|
||||
exten => _NXXNXXXXXX,1,Goto(sp-route-{id},1${EXTEN:0},1) ; strip=0 prepend=1
|
||||
exten => _1NXXNXXXXXX,1,Goto(sp-route-{id},${EXTEN:0},1)
|
||||
exten => _011.,1,Goto(sp-route-{id2},${EXTEN},1)
|
||||
|
||||
[sp-route-{id}]
|
||||
exten => _X.,1,Set(__SP_DIRECTION=outbound)
|
||||
same => n,GotoIf($[${IF($["${SP_CLASS}" = ""]?0:${SP_CLASS})} < {required_class_number}]?denied)
|
||||
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})
|
||||
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_OUTCID}))
|
||||
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DID}))
|
||||
same => n,ExecIf($["${CID}" = ""]?Set(CID=${SP_DEFAULT_CID}))
|
||||
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
|
||||
; trunk 1 (with optional channel limit)
|
||||
same => n,Set(GROUP(sptrunk)={trunk1id})
|
||||
same => n,GotoIf($[${GROUP_COUNT({trunk1id}@sptrunk)} > {max_channels}]?t2) ; only if max_channels
|
||||
same => n,Dial(PJSIP/${EXTEN}@trunk-{trunk1id},120)
|
||||
same => n,GotoIf($["${DIALSTATUS}" != "CHANUNAVAIL" & "${DIALSTATUS}" != "CONGESTION"]?done)
|
||||
same => n(t2),Dial(PJSIP/${EXTEN}@trunk-{trunk2id},120) ; repeat per trunk
|
||||
same => n(done),Hangup()
|
||||
same => n(denied),Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
```
|
||||
Default routes created by the setup wizard (06 §4):
|
||||
|
||||
| Name | Patterns | Required class |
|
||||
|---|---|---|
|
||||
| Local/National (NANP) | `NXXNXXXXXX` (prepend `1`), `1NXXNXXXXXX` | national |
|
||||
| Toll-free | `1800NXXXXXX`, `1888NXXXXXX`, `1877NXXXXXX`, `1866NXXXXXX`, `1855NXXXXXX`, `1844NXXXXXX`, `1833NXXXXXX` | local |
|
||||
| International | `011.` | international — **created disabled** |
|
||||
|
||||
Pattern note: Asterisk sorts patterns by how specific they are, so toll-free can sit next to national safely.
|
||||
|
||||
### 6.13 Feature codes `sp-featurecodes`
|
||||
|
||||
```ini
|
||||
[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 is handled by features.conf pickupexten + named pickup groups
|
||||
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,Set(DB(${K})=1)
|
||||
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
|
||||
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,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
|
||||
same => n,Playback(agent-loginok)
|
||||
same => n,Hangup()
|
||||
exten => _*80X.,1,Page(PJSIP/${EXTEN:3},db(sp-sub-autoanswer^s^1),30) ; intercom
|
||||
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()
|
||||
```
|
||||
The generator also writes `[sp-tc-override]`, with three lines per time condition that has an `override_digit`:
|
||||
```ini
|
||||
[sp-tc-override]
|
||||
exten => *311,1,Set(DB(SP/TCOVR/{id})=open)
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: {id},Value: open)
|
||||
same => n,Playback(activated)
|
||||
same => n,Hangup()
|
||||
exten => *321,1,Set(DB(SP/TCOVR/{id})=closed) ... (same shape, Value: closed)
|
||||
exten => *331,1,Set(X=${DB_DELETE(SP/TCOVR/{id})}) ... (Value: auto)
|
||||
```
|
||||
**Forward validation:** when the api receives `SpToggle Key: CFU` it checks the
|
||||
target against the user's outbound class (04 §5.3). If it isn't allowed, the api
|
||||
deletes the AstDB key, stores nothing, and logs an audit entry. Dialplan also
|
||||
limits forwards via `SP_CLASS_<ext>` in `sp-sub-forward`.
|
||||
|
||||
Sound names like `do-not-disturb`, `call-fwd-unconditional`, `agent-loginok`
|
||||
come from the Asterisk core/extra sound packs. If one is missing, swap in
|
||||
`beep` and note it in the spec.
|
||||
|
||||
### 6.14 Emergency calls
|
||||
|
||||
**Never block a 911 call for any reason.** Not class, not missing caller ID,
|
||||
not trunk limits, not DND.
|
||||
|
||||
```ini
|
||||
[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,Dial(PJSIP/911@trunk-{trunk1id}) ; every ENABLED trunk, in name order, no limits
|
||||
same => n,Dial(PJSIP/911@trunk-{trunk2id})
|
||||
same => n,Hangup()
|
||||
```
|
||||
The `SpEmergency` event triggers notifications (04 §5.3, 06 §6). For the
|
||||
"ring a notification phone" part, the api originates a call into this static context:
|
||||
|
||||
```ini
|
||||
[sp-emergency-alert]
|
||||
; Originated by the api: Channel PJSIP/<notify ext>, Context sp-emergency-alert, Exten <caller ext>
|
||||
exten => _X.,1,Answer()
|
||||
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()
|
||||
```
|
||||
(If an admin uploads a sound named `emergency-alert`, play `custom/emergency-alert` first.)
|
||||
|
||||
## 7. `voicemail_selfphoneng.conf`
|
||||
|
||||
```ini
|
||||
[general]
|
||||
format = wav
|
||||
attach = no ; the api sends emails itself
|
||||
maxmsg = {voicemail.maxMessages}
|
||||
maxsecs = {voicemail.maxMessageSeconds}
|
||||
minsecs = 1
|
||||
maxsilence = 10
|
||||
silencethreshold = 128
|
||||
maxlogins = 3
|
||||
envelope = yes
|
||||
saycid = yes
|
||||
review = no
|
||||
nextaftercmd = yes
|
||||
sendvoicemail = no
|
||||
pollmailboxes = yes
|
||||
pollfreq = 30
|
||||
|
||||
[zonemessages]
|
||||
local = {general.timezone}|'vm-received' Q 'digits/at' IMp
|
||||
|
||||
[default]
|
||||
101 => {pin},{name},,,tz=local|attach=no
|
||||
```
|
||||
Only extensions with `voicemail_enabled` get a line.
|
||||
|
||||
## 8. `musiconhold_selfphoneng.conf`
|
||||
|
||||
```ini
|
||||
[{class.name}]
|
||||
mode = files
|
||||
directory = /var/lib/asterisk/moh/{class.name}
|
||||
sort = random
|
||||
```
|
||||
One per `moh_classes` row. The api keeps the folder contents in sync with `sound_files` (04 §6).
|
||||
|
||||
## 9. Snapshot → endpoint variables summary
|
||||
|
||||
| Variable | Where set | Read by |
|
||||
|---|---|---|
|
||||
| `SP_EXT` | endpoint `set_var` | feature codes, emergency |
|
||||
| `SP_CLASS` | endpoint `set_var`; `sp-sub-forward`; `sp-ext-forward` | `sp-route-*` |
|
||||
| `SP_OUTCID` | endpoint `set_var` | `sp-route-*` |
|
||||
| `SP_E911CID` | endpoint `set_var` | `sp-emergency` |
|
||||
| `SP_RECMODE` | endpoint `set_var` | `sp-route-*` |
|
||||
| `SP_DIRECTION` | `sp-from-trunk-*`, `sp-route-*`, endpoint | `sp-sub-user`, CDR |
|
||||
| `SP_DID` | `sp-inbound` | `sp-route-*` (forwards), CDR |
|
||||
| `SP_TARGET` | object contexts | live state |
|
||||
| `SP_REC` | `sp-sub-record` | CDR → recordings |
|
||||
| `SP_FWDHOPS`, `SP_FORWARDED` | forwards | loop guard |
|
||||
|
||||
## 10. Example: life of an inbound call
|
||||
|
||||
voip.ms sends `INVITE sip:3125551234@us` → PJSIP matches identify →
|
||||
endpoint `trunk-X` → context `sp-from-trunk-X`, exten `3125551234` →
|
||||
normalize to `13125551234` → it's in `sp-did-exists` → caller number
|
||||
normalized, not blocked → `Goto(sp-inbound,13125551234,1)` → sets `SP_DID`
|
||||
→ `Goto(sp-tc-T,s,1)` → it's Tuesday 10:00, open → `Goto(sp-ivr-I,s,1)` →
|
||||
caller presses 2 → `Goto(sp-local-numbers,101,1)` → `sp-sub-user(101,25,1,never)`
|
||||
→ not DND, no forward → rings `PJSIP/101` desk phone + `PJSIP/101-web` browser
|
||||
→ no answer in 25 s → `VoiceMail(101@default,u)` → the api sees a new
|
||||
`msg0000.txt` → emails the user.
|
||||
|
||||
## 11. Things the generator must NOT do
|
||||
|
||||
- Hard-code any number, DID, IP or password.
|
||||
- Emit `chan_sip` config.
|
||||
- Use `System()`, `Shell()`, `AGI()` or `TrySystem()`. Talk to the api with `UserEvent` only.
|
||||
- Write anything outside `/etc/asterisk/selfphoneng/`.
|
||||
|
||||
## 12. Tests
|
||||
|
||||
1. **Golden tests** (`apps/api/src/generator/__golden__/<case>/`):
|
||||
`snapshot.json` + `expected/<file>.conf`. Test: `render(snapshot)` equals
|
||||
expected byte for byte. `UPDATE_GOLDEN=1 pnpm test` rewrites the expected
|
||||
files. **A human (or reviewer agent) must read the diff** before committing.
|
||||
Required cases: `empty`, `one-user`, `voipms-basic` (2 users, 1 trunk, 1 DID → user),
|
||||
`ivr-business-hours`, `ringgroups`, `queue`, `outbound-routes`, `holidays`,
|
||||
`hostile-names` (sanitizer coverage), `webrtc-disabled`.
|
||||
2. **Determinism test:** render the same snapshot twice with shuffled array order → identical output.
|
||||
3. **Asterisk load test** (integration, `pnpm test:asterisk`): start the asterisk
|
||||
container with each golden case's files, then run over AMI
|
||||
`dialplan show sp-local-numbers`, `pjsip show endpoints`, `queue show`.
|
||||
Fail if Asterisk logs `ERROR` while loading.
|
||||
4. **Call tests** (M3+, SIPp scenarios in `apps/api/test/sipp/`): register 101,
|
||||
call 102, answer, BYE. Inbound from a fake trunk → IVR → digit → user.
|
||||
@@ -0,0 +1,417 @@
|
||||
# 04 — Backend (apps/api)
|
||||
|
||||
## 1. Startup order (`src/index.ts`)
|
||||
|
||||
1. `config.ts`: parse env with zod. Exit 1 with a readable message on error.
|
||||
2. Create the pino logger (with redaction, §11).
|
||||
3. Connect PostgreSQL. Run migrations from `./migrations`.
|
||||
4. Bootstrap: if no `admin` user exists, create one from `INITIAL_ADMIN_EMAIL` /
|
||||
`INITIAL_ADMIN_PASSWORD` with `must_change_password = true`.
|
||||
5. Build Fastify: plugins (cookie, rate-limit, multipart, websocket, static for
|
||||
`./public` with SPA fallback to `index.html` for non-`/api`, non-`/prov`, non-`/hooks` paths), then routes.
|
||||
6. Listen on `127.0.0.1:${API_PORT}`.
|
||||
7. Start the AMI client (§4). On every successful login: sync AstDB (§5.5), load
|
||||
live state (§5.1), `generator.apply({ trigger: 'startup', force: true })`.
|
||||
8. Start the voicemail watcher (§5.6) and the jobs (§8).
|
||||
9. On SIGTERM: stop jobs, close WS clients, close AMI, close DB, exit 0.
|
||||
|
||||
## 2. Auth
|
||||
|
||||
### 2.1 Passwords
|
||||
- argon2id via `@node-rs/argon2` with library defaults.
|
||||
- Minimum 10 characters. Reject the 1,000 most common passwords (bundle a list in `auth/common-passwords.txt`).
|
||||
|
||||
### 2.2 Sessions (browser)
|
||||
- `POST /api/auth/login {email, password, totp?}` → on success, create a 32-byte random
|
||||
token. Store `sha256(token)` in `sessions.id` and set the cookie
|
||||
`sp_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600` (14 days).
|
||||
- Sliding expiry: if a session has less than 7 days left when used, extend it to 14 days.
|
||||
- `POST /api/auth/logout` deletes the session row and clears the cookie.
|
||||
- **CSRF:** every non-GET `/api/*` request with cookie auth must carry the header
|
||||
`X-Requested-With: selfphoneng`. The web client always sends it. Browsers can't send
|
||||
it cross-site without CORS, and CORS is off.
|
||||
- Login rate limit: 5 failures per IP per 15 min and 10 per email per hour → HTTP 429.
|
||||
Write `login_failed` to the audit log with the IP.
|
||||
- If `must_change_password`, every route except `/api/auth/*` returns 403 `{code:'password_change_required'}`.
|
||||
|
||||
### 2.3 API tokens (integrations)
|
||||
- Header `Authorization: Bearer sp_<40 random base62 chars>`. Stored as sha256.
|
||||
Tokens act as the admin who created them. Shown once on creation.
|
||||
|
||||
### 2.4 Guards
|
||||
Fastify `preHandler` hooks in `auth/guards.ts`:
|
||||
- `requireAuth`: sets `req.user`.
|
||||
- `requireAdmin`.
|
||||
- `requireSelfOrAdmin(paramName)`: for `/api/me/*` and per-user resources.
|
||||
|
||||
## 3. Secrets (`crypto/secrets.ts`)
|
||||
|
||||
```ts
|
||||
encrypt(plain: string): string // AES-256-GCM, random 12-byte IV, key = base64decode(APP_SECRET)
|
||||
decrypt(blob: string): string
|
||||
generateSipPassword(): string // 24 chars from [A-Za-z0-9], crypto.randomInt
|
||||
generatePin(len = 6): string
|
||||
```
|
||||
Unit test: round-trip, tamper detection (flip one byte → throws), wrong key → throws.
|
||||
|
||||
## 4. AMI client (`ami/client.ts`)
|
||||
|
||||
Write it ourselves. Protocol summary:
|
||||
- TCP to `AMI_HOST:AMI_PORT`. Server sends one banner line: `Asterisk Call Manager/X.Y.Z\r\n`.
|
||||
- Each message is lines `Key: Value\r\n` ending with an empty line `\r\n`.
|
||||
- A message with `Response:` answers an action (matched by `ActionID`).
|
||||
A message with `Event:` is an event.
|
||||
- Keys can repeat (e.g. `Output:` lines of a `Command`, `ChanVariable:`). Parse each
|
||||
message as `Record<string, string | string[]>`, so a repeated key becomes an array.
|
||||
- List actions (e.g. `PJSIPShowContacts`, `CoreShowChannels`) reply
|
||||
`Response: Success` + `EventList: start`, then events with the same `ActionID`,
|
||||
then an event whose name ends in `Complete` with `EventList: Complete`.
|
||||
|
||||
API:
|
||||
```ts
|
||||
class AmiClient extends EventEmitter {
|
||||
constructor(opts: { host; port; username; secret; logger });
|
||||
start(): void; // connects, logs in, reconnects forever
|
||||
isConnected(): boolean;
|
||||
action(fields: Record<string,string>, opts?: { timeoutMs?: number }): Promise<AmiMessage>;
|
||||
actionList(fields: Record<string,string>): Promise<AmiMessage[]>; // collects an EventList
|
||||
command(cmd: string): Promise<string>; // Action: Command, returns joined Output
|
||||
// events: 'connected', 'disconnected', 'event' (msg), and `event:<Name>` (msg)
|
||||
}
|
||||
```
|
||||
Details:
|
||||
- `ActionID` = incrementing integer as a string, prefixed `sp-`.
|
||||
- Login: `Action: Login`, `Username`, `Secret`, `Events: on`.
|
||||
- Send `Action: Ping` every 20 s. If no reply in 10 s, destroy the socket and reconnect.
|
||||
- Reconnect backoff: 1, 2, 4, 8, 16, 30, 30, … seconds.
|
||||
- Pending actions reject with `AmiDisconnectedError` when the socket closes.
|
||||
- Default action timeout: 10 s.
|
||||
- **Never log the Login action fields.**
|
||||
|
||||
Tests: run the parser against recorded raw byte streams in `test/fixtures/ami/`,
|
||||
including messages split across TCP chunks and several messages in one chunk.
|
||||
|
||||
## 5. Live state, events and Asterisk-driven services
|
||||
|
||||
### 5.1 Live state (`live/state.ts`)
|
||||
|
||||
In memory only. Rebuilt from Asterisk on every AMI connect.
|
||||
|
||||
```ts
|
||||
type ExtensionLive = {
|
||||
number: string;
|
||||
state: 'idle' | 'inuse' | 'busy' | 'unavailable' | 'ringing' | 'onhold' | 'ringinuse';
|
||||
contacts: { endpoint: string; uri: string; userAgent?: string; status: 'reachable'|'unreachable'|'unknown'; rttMs?: number }[];
|
||||
dnd: boolean;
|
||||
};
|
||||
type TrunkLive = { trunkId: string; registration: 'registered' | 'rejected' | 'unregistered' | 'unknown'; lastChange: string; reachable: boolean };
|
||||
type ActiveCall = {
|
||||
linkedid: string; startedAt: string; answeredAt?: string;
|
||||
direction: 'inbound'|'outbound'|'internal';
|
||||
from: { number: string; name?: string }; to: { number: string; name?: string };
|
||||
did?: string; state: 'ringing' | 'talking' | 'hold' | 'queued' | 'ivr' | 'voicemail' | 'parked';
|
||||
extensions: string[]; // internal numbers involved
|
||||
channels: string[]; // channel names
|
||||
recording: boolean;
|
||||
};
|
||||
type QueueLive = { number: string; waiting: { linkedid; callerNumber; since }[]; members: { ext; paused; state; callsTaken; lastCall? }[] };
|
||||
type ParkedLive = { slot: string; linkedid: string; callerNumber: string; since: string; parkedBy?: string };
|
||||
type MwiLive = Record<string /*ext*/, { new: number; old: number }>;
|
||||
```
|
||||
|
||||
Initial load on connect (use `actionList`):
|
||||
|
||||
| Data | AMI action | Fields used |
|
||||
|---|---|---|
|
||||
| Extension states | `ExtensionStateList` | `Exten`, `Context` (= `sp-local-numbers`), `Status` |
|
||||
| Contacts | `PJSIPShowContacts` | `Endpoint`/`EndpointName`, `Uri`, `UserAgent`, `Status`, `RoundtripUsec` |
|
||||
| Trunk registrations | `PJSIPShowRegistrationsOutbound` | `ObjectName` (`trunk-{id}-reg`), `Status` |
|
||||
| Channels | `CoreShowChannels` | `Channel`, `Uniqueid`, `Linkedid`, `ChannelStateDesc`, `CallerIDNum`, `ConnectedLineNum`, `Application`, `Exten`, `Context` |
|
||||
| Queues | `QueueStatus` | `QueueParams`, `QueueMember`, `QueueEntry` events |
|
||||
| Parking | `ParkedCalls` | `ParkingSpace`, `ParkeeLinkedid`, `ParkeeCallerIDNum` |
|
||||
| MWI | `MailboxCount` per mailbox | `NewMessages`, `OldMessages` |
|
||||
|
||||
Events to handle (`live/handlers.ts`):
|
||||
|
||||
| Event | Effect |
|
||||
|---|---|
|
||||
| `ExtensionStatus` | update `ExtensionLive.state` (Status: 0 idle, 1 inuse, 2 busy, 4 unavailable, 8 ringing, 9 ringinuse, 16 onhold) |
|
||||
| `ContactStatus` | add/update/remove contact (ContactStatus: Created, Reachable, Unreachable, Removed, Updated) |
|
||||
| `Registry` | trunk registration status (match `Username` + `Domain` to a trunk) |
|
||||
| `Newchannel`, `Newstate`, `NewCallerid`, `NewConnectedLine`, `DialBegin`, `DialEnd`, `BridgeEnter`, `BridgeLeave`, `Hold`, `Unhold`, `Hangup` | maintain `ActiveCall` grouped by `Linkedid`. When the last channel of a linkedid hangs up, drop the call and schedule CDR aggregation (§5.4) for 5 s later |
|
||||
| `VarSet` with `Variable` in (`SP_DID`, `SP_DIRECTION`, `SP_REC`, `SP_TARGET`) | enrich `ActiveCall` (needs `eventfilter` or `read=all`; fine on localhost) |
|
||||
| `QueueCallerJoin` / `QueueCallerLeave` / `QueueCallerAbandon` / `QueueMemberStatus` / `QueueMemberPause` / `AgentConnect` / `AgentComplete` | `QueueLive` |
|
||||
| `ParkedCall` / `UnParkedCall` / `ParkedCallTimeOut` / `ParkedCallGiveUp` | `ParkedLive` |
|
||||
| `MessageWaiting` | `MwiLive` (Mailbox `101@default`) |
|
||||
| `UserEvent` `SpToggle` | §5.3 |
|
||||
| `UserEvent` `SpEmergency` | §5.3 |
|
||||
| `Cdr` | §5.4 |
|
||||
|
||||
Mapping channel name → extension: regex `^PJSIP/(\d+)(?:-web)?-[0-9a-f]+$`. Local
|
||||
channels `Local/101@sp-rg-member-...` also count as 101.
|
||||
Trunk channel: `^PJSIP/trunk-([0-9a-f-]{36})-`.
|
||||
|
||||
### 5.2 Live WebSocket (`GET /api/live`)
|
||||
|
||||
- Auth: session cookie (the browser sends it automatically). Close code 4401 when unauthenticated.
|
||||
- Server → client messages are JSON `{ "type": string, "data": any }`.
|
||||
- On connect: one `snapshot` message containing the live state the user may see, then deltas.
|
||||
- Visibility:
|
||||
- **admin**: everything.
|
||||
- **user**: all `ExtensionLive` without `contacts` (presence/BLF for the whole company),
|
||||
active calls where their extension is in `extensions`, MWI for their own mailbox,
|
||||
queues they are a member of, all parked calls.
|
||||
- Delta types: `extension.updated`, `trunk.updated`, `call.updated`, `call.ended`,
|
||||
`queue.updated`, `parked.updated`, `mwi.updated`, `voicemail.new`, `sms.received`,
|
||||
`emergency.call` (admins + emergency notify users), `system.configApplied`,
|
||||
`system.restartRequired`, `ami.status` (`{connected: boolean}`).
|
||||
- Batch deltas: send at most once every 250 ms per client (merge `*.updated` for the same key).
|
||||
- Ping every 30 s and drop clients that don't pong.
|
||||
|
||||
### 5.3 Dialplan → api events
|
||||
|
||||
`SpToggle` fields: `Key` (DND | CFU | CFB | CFNA | QPAUSE | TCOVR), `Ext`, `Queue`, `Id`, `Value`.
|
||||
- DND / CFU / CFB / CFNA: update `user_runtime_state` for that extension.
|
||||
For CFU/CFB/CFNA with an external number, run `canDial(ext.outbound_class, number)`
|
||||
(uses the outbound routes' patterns and required classes). If not allowed:
|
||||
`DBDel` the key over AMI, keep the DB value null, write an audit entry
|
||||
`forward_rejected`, and push a `toast` to that user's WS clients.
|
||||
- QPAUSE: update `queue_member_state`.
|
||||
- TCOVR: update `time_conditions.override_state` (`auto` → null).
|
||||
- Always broadcast the matching live delta.
|
||||
|
||||
`SpEmergency` fields: `Ext`, `CallerId`, `Dialed`.
|
||||
1. Insert `emergency_events` with `location` from `extensions.emergency_location`.
|
||||
2. Email every address in `emergency.notifyEmails`: subject
|
||||
`EMERGENCY: 911 dialed from ext {ext} ({name})`, body with time, extension,
|
||||
name, location, caller ID sent. Send immediately; don't wait for a queue.
|
||||
3. For each `emergency.notifyExtensionIds`: AMI `Originate` with
|
||||
`Channel: PJSIP/<notifyExt>`, `Context: sp-emergency-alert`, `Exten: <ext>`,
|
||||
`Priority: 1`, `CallerID: "EMERGENCY 911" <<ext>>`, `Async: true`.
|
||||
4. Broadcast `emergency.call`. Fire webhook `emergency.call`.
|
||||
5. Record the outcome of every notification in `emergency_events.notified`.
|
||||
|
||||
### 5.4 CDR ingestion and call aggregation (`services/cdr.ts`)
|
||||
|
||||
- On each `Cdr` event: insert the raw row into `cdr` (fields map 1:1, `raw` = whole message).
|
||||
Variables from `cdr_manager.conf` `[mappings]` arrive as lowercase keys `sp_did`, `sp_direction`, `sp_recording`.
|
||||
- `aggregateCall(linkedid)` runs 5 s after the last hangup (and again from the
|
||||
nightly job for any linkedid in `cdr` without a `calls` row):
|
||||
- `rows` = all cdr rows for the linkedid, ordered by `start_at`.
|
||||
- **direction**: first non-empty `sp_direction`; else `inbound` if any `channel`
|
||||
is a trunk channel; else `outbound` if any `dstchannel` is a trunk channel; else `internal`.
|
||||
- **from**: `src` + name parsed from `clid` of the first row.
|
||||
- **to**: inbound → the DID (`sp_did`); otherwise `dst` of the first row.
|
||||
- **rang**: every extension parsed from any `dstchannel`.
|
||||
- **answered_by**: the extension in `dstchannel` of the first row with `disposition = 'ANSWERED'` and an extension dstchannel.
|
||||
For outbound: answered if the trunk leg is ANSWERED.
|
||||
- **status**, checked in this order: `answered` if answered_by is set (or the outbound trunk leg was answered);
|
||||
`voicemail` if any row has `lastapp = 'VoiceMail'` and `billsec > 0`;
|
||||
`abandoned` if any row has `lastapp = 'Queue'`; `busy` if any row's disposition is `BUSY`;
|
||||
`failed` if any row's disposition is `FAILED` or `CONGESTION`; otherwise `missed`.
|
||||
- **talk_seconds**: max `billsec` of answered extension/trunk rows.
|
||||
- **recording**: if any `sp_recording` is set and the file exists → create
|
||||
`recordings` (size via `fs.stat`, duration via `ffprobe`) and link it.
|
||||
- Insert `calls` + `call_extensions`, then fire webhook `call.ended`.
|
||||
- This is a heuristic. In M7, record real AMI Cdr events from test calls
|
||||
(inbound to user, inbound to ring group, IVR → voicemail, queue abandon,
|
||||
outbound, transfer, park) into `test/fixtures/cdr/*.json` and write a test per scenario.
|
||||
|
||||
### 5.5 AstDB sync (`services/astdb.ts`)
|
||||
|
||||
On every AMI connect, PostgreSQL wins:
|
||||
1. `DBDelTree Family: SP` (wipes our keys).
|
||||
2. For each extension: `DBPut SP DND/<ext> 1` if dnd; CFU/CFB/CFNA if set.
|
||||
3. For each blocked number: `DBPut SP BLOCK/<number> 1`.
|
||||
4. For each paused queue member: `DBPut SP QPAUSE/<queue>/<ext> 1`.
|
||||
5. For each time condition with `override_state`: `DBPut SP TCOVR/<id> <state>`.
|
||||
|
||||
Keep one function per key type (`setDnd(ext, on)`, etc.). Routes call these after writing PostgreSQL.
|
||||
|
||||
### 5.6 Voicemail watcher (`services/voicemail.ts`)
|
||||
|
||||
- Watch `/var/spool/asterisk/voicemail/default/*/{INBOX,Old,Urgent}/msg*.txt` with chokidar (`awaitWriteFinish: { stabilityThreshold: 2000 }`).
|
||||
- Parse the `.txt` file: INI-like, section `[message]`, keys `callerid`, `origtime` (epoch),
|
||||
`duration`, `msg_id`, `flag`. `callerid` looks like `"Name" <13125551234>`.
|
||||
- **add**: upsert `voicemail_messages` by `msg_id` (update `folder`, `file_path`).
|
||||
If it's new and in INBOX: fire `voicemail.new` (WS + webhook) and, if the user has
|
||||
`voicemail_email` and SMTP is configured, send an email (§10) with the `.wav` attached
|
||||
if `voicemail_attach_audio`. Then set `emailed_at`. If `voicemail_delete_after_email`, delete it (below).
|
||||
- **unlink**: if no file with that `msg_id` is left anywhere in the mailbox, set `deleted_at`.
|
||||
- **API operations** (`markRead` = move INBOX→Old, `delete`, `move`): Asterisk expects
|
||||
files in each folder numbered `msg0000…msgNNNN` with no gaps. So every
|
||||
operation must, under a per-mailbox lock: move/delete the `.txt` and `.wav`
|
||||
together, renumber the remaining files in that folder to close gaps, then
|
||||
send AMI `VoicemailRefresh Context: default Mailbox: <ext>` so MWI lights update.
|
||||
First check `manager show commands` on the installed Asterisk for `VoicemailRemove`/`VoicemailMove`
|
||||
actions. If they exist, use them instead and delete the renumbering code.
|
||||
- Greetings: `PUT /api/me/greetings/:kind` (kind: `unavail` | `busy` | `greet` (name) | `temp`)
|
||||
converts the upload to `/var/spool/asterisk/voicemail/default/<ext>/<kind>.wav`
|
||||
(8 kHz mono PCM). `DELETE` removes it.
|
||||
|
||||
## 6. Media (`media/audio.ts`)
|
||||
|
||||
- Accept uploads: wav, mp3, ogg, opus, m4a, flac. Max 20 MB. Max 15 minutes.
|
||||
- Convert with ffmpeg (spawn, no shell):
|
||||
- `ffmpeg -nostdin -y -i <in> -ac 1 -ar 8000 -c:a pcm_s16le <out>.wav`
|
||||
- `ffmpeg -nostdin -y -i <in> -ac 1 -ar 16000 -c:a g722 <out>.g722` (HD copy that Asterisk picks for G.722 calls)
|
||||
- Loudness normalize with `-af loudnorm=I=-16:TP=-1.5:LRA=11` on both.
|
||||
- Duration: `ffprobe -v error -show_entries format=duration -of csv=p=0 <out>.wav`.
|
||||
- Prompts: `/var/lib/asterisk/sounds/custom/<id>.{wav,g722}` → `asterisk_name = custom/<id>`.
|
||||
- MOH: `/var/lib/asterisk/moh/<className>/<id>.wav` (wav only). After any MOH change, call `generator.requestApply`.
|
||||
- Deleting a sound referenced by an IVR/announcement/settings → 409 with references.
|
||||
- Optional TTS (M12): `POST /api/sounds/tts {name, text, voice}` runs Piper (binary
|
||||
and voice models in the api image under `/opt/piper`), then the same conversion.
|
||||
|
||||
## 7. REST API
|
||||
|
||||
### 7.1 Conventions
|
||||
- Base path `/api`. JSON in, JSON out. Dates are ISO 8601 UTC strings.
|
||||
- Every request body and query is validated with zod schemas from `packages/shared`.
|
||||
- Errors: HTTP status + `{ "error": { "code": "snake_case", "message": "Human text", "details": {} } }`.
|
||||
Codes: `validation_failed` (400), `unauthorized` (401), `forbidden` (403), `not_found` (404),
|
||||
`conflict` (409, e.g. number taken, in use), `rate_limited` (429), `internal` (500).
|
||||
- Lists: `?limit=50&cursor=<opaque>` → `{ items: [], nextCursor: string | null }`.
|
||||
- Every create/update/delete of config objects: (1) writes PostgreSQL in a transaction, (2) writes `audit_log`
|
||||
with a secret-redacted diff, (3) calls `generator.requestApply('api-change')`.
|
||||
- Secrets never come back in responses. Use `hasPassword: true`. Separate explicit endpoints reveal them.
|
||||
|
||||
### 7.2 Number allocation (`services/numbering.ts`)
|
||||
`assertNumberAvailable(number, kind, excludeId?)`:
|
||||
1. Digits only, 3–6 chars.
|
||||
2. Not in the reserved list (00 §7).
|
||||
3. Inside the configured range for `kind`.
|
||||
4. Not used by any of: extensions, ring_groups, queues, ivrs, conferences, paging_groups, parking 700–720.
|
||||
Throw `conflict` naming the current owner. `GET /api/numbering/next?kind=user` returns the lowest free number.
|
||||
|
||||
### 7.3 Endpoints
|
||||
|
||||
Auth & self:
|
||||
| Method | Path | Who | Notes |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/login` | public | §2.2 |
|
||||
| POST | `/api/auth/logout` | auth | |
|
||||
| GET | `/api/auth/me` | auth | user + extension summary |
|
||||
| POST | `/api/auth/change-password` | auth | `{currentPassword, newPassword}` |
|
||||
| GET | `/api/me` | user | extension settings, runtime state |
|
||||
| PATCH | `/api/me` | user | `{dnd?, cfu?, cfb?, cfna?, voicemailEmail?, voicemailAttachAudio?, voicemailDeleteAfterEmail?, voicemailPin?}` → DB, then AstDB |
|
||||
| GET | `/api/me/webrtc` | user | `{ wsUrl: "wss://<host>/ws", sipUri: "sip:101-web@<host>", authUser: "101-web", password, displayName, iceServers: [...] }` |
|
||||
| GET | `/api/me/calls` | user | own call history (via `call_extensions`) |
|
||||
| GET | `/api/me/voicemail` | user | list |
|
||||
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
|
||||
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
|
||||
| DELETE | `/api/me/voicemail/:id` | user | |
|
||||
| PUT/DELETE | `/api/me/greetings/:kind` | user | multipart upload |
|
||||
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | personal contacts |
|
||||
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
|
||||
|
||||
Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /x/:id`, `PATCH /x/:id`, `DELETE /x/:id`):
|
||||
| Resource | Extra endpoints |
|
||||
|---|---|
|
||||
| `/api/users` | `POST /:id/reset-password` (sets a temp password, `must_change_password`) |
|
||||
| `/api/extensions` | `POST /:id/regenerate-sip-password`, `GET /:id/sip-credentials` (audited), `GET /:id/softphone-qr` (07 §5) |
|
||||
| `/api/trunks` | `POST /:id/test` (live registration status + OPTIONS RTT), `POST /:id/import-dids` (voip.ms API, 06 §5) |
|
||||
| `/api/dids` | |
|
||||
| `/api/outbound-routes` | `POST /reorder {ids: []}`, `POST /test {extensionId, number}` → which route/trunk would be used, or why it's denied |
|
||||
| `/api/ring-groups` | members are set via `members: [{extensionId}]` in POST/PATCH |
|
||||
| `/api/queues` | `GET /:id/stats?from&to`, `POST /:id/members/:extId/pause`, `.../unpause` |
|
||||
| `/api/ivrs` | options sent as `options: [{digit, destination}]` |
|
||||
| `/api/time-conditions` | `POST /:id/override {state: 'open'|'closed'|'auto'}`, `GET /:id/status` (open/closed now) |
|
||||
| `/api/holidays` | `POST /import-us-federal {year}` (generates the list in code, no network) |
|
||||
| `/api/announcements` | |
|
||||
| `/api/conferences` | |
|
||||
| `/api/paging-groups` | |
|
||||
| `/api/sounds` | upload is multipart `POST /api/sounds`, `GET /:id/audio`, `POST /tts` (M12) |
|
||||
| `/api/moh-classes` | |
|
||||
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) |
|
||||
| `/api/blocked-numbers` | |
|
||||
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) |
|
||||
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
|
||||
| `/api/api-tokens` | POST returns the token once |
|
||||
|
||||
Reports, history, system:
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/api/calls` | filters: `from`, `to`, `direction`, `status`, `extensionId`, `didId`, `q` (number search) |
|
||||
| GET | `/api/calls/export.csv` | same filters |
|
||||
| GET | `/api/recordings`, `/api/recordings/:id/audio`, DELETE `/api/recordings/:id` | |
|
||||
| GET | `/api/reports/summary?from&to` | totals per day, per direction, missed count, average talk time |
|
||||
| GET | `/api/reports/queues?from&to` | per queue: offered, answered, abandoned, avg wait, % within SLA |
|
||||
| GET | `/api/audit-log` | |
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
| POST | `/api/settings/network/detect-public-ip` | |
|
||||
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
|
||||
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
|
||||
| POST | `/api/system/config/apply` | force regenerate |
|
||||
| POST | `/api/system/config/rollback` | |
|
||||
| POST | `/api/system/asterisk/restart?now=false` | |
|
||||
| GET | `/api/system/asterisk/cli?cmd=` | **allowlist only**: `pjsip show endpoints`, `pjsip show registrations`, `pjsip show contacts`, `core show channels`, `queue show`, `dialplan show <sp-context>`. Never pass arbitrary commands |
|
||||
| POST | `/api/system/backups` | create one now |
|
||||
| GET | `/api/system/backups`, `/api/system/backups/:name/download` | |
|
||||
| GET/POST | `/api/setup/status`, `/api/setup/complete` | first-run wizard (05 §2.1) |
|
||||
| GET | `/api/live` | WebSocket (§5.2) |
|
||||
| POST | `/api/live/calls/:linkedid/hangup` | admin |
|
||||
| POST | `/api/live/calls/:linkedid/transfer` | admin `{target}` → AMI `Redirect` of the external party's channel to `sp-local-numbers,<target>,1` |
|
||||
| POST | `/api/live/calls/:linkedid/spy` | admin `{mode: 'listen'|'whisper'|'barge'}` → Originate to admin's ext running `ChanSpy(<channel>,q{''|w|B})` (M10) |
|
||||
| GET | `/healthz` | unauthenticated; 200 if DB ok, AMI connected, else 503 with JSON reasons |
|
||||
|
||||
SMS (06 §5): `GET /api/sms/threads`, `GET /api/sms/threads/:didId/:remoteNumber`,
|
||||
`POST /api/sms/send {didId, to, body}`, `POST /api/sms/threads/:didId/:remote/read`.
|
||||
Users only see DIDs they're assigned in `did_sms_users`. Admins see all.
|
||||
|
||||
Provisioning and inbound hooks (outside `/api`, no session):
|
||||
- `GET /prov/...`: see `07-provisioning.md`.
|
||||
- `GET|POST /hooks/voipms/sms/:token`: see `06-voipms.md` §5.
|
||||
|
||||
## 8. Background jobs (`jobs/`, using croner, times in `general.timezone`)
|
||||
|
||||
| Job | Schedule | Does |
|
||||
|---|---|---|
|
||||
| `nightlyRegenerate` | 00:05 daily | `generator.apply({trigger:'nightly', force:true})` (holidays roll over), aggregate orphan CDRs |
|
||||
| `retentionCleanup` | 02:00 daily | delete recordings older than `recordings.retentionDays` (file + row), voicemail older than `voicemail.retentionDays` if > 0, sessions past expiry, webhook_deliveries older than 30 days |
|
||||
| `backup` | `backup.time` daily if enabled | §8.1 |
|
||||
| `publicIpWatch` | every 5 min if enabled | detect IP; if changed: update settings, apply, set restartRequired, email admins |
|
||||
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
|
||||
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
|
||||
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
|
||||
|
||||
Jobs must never overlap with themselves (use a running flag).
|
||||
|
||||
### 8.1 Backups
|
||||
Folder `/var/lib/selfphoneng/backups/selfphoneng-YYYYMMDD-HHMM.tar.gz` containing:
|
||||
`db.sql` (`pg_dump --no-owner`), `voicemail/`, `sounds-custom/`, `moh/`,
|
||||
`manifest.json`. Do **not** include `asterisk-generated/`: it holds plaintext SIP passwords and is rebuilt from the DB on start.
|
||||
`manifest.json` is `{version, createdAt, includesRecordings}`. The backup file mode is 0600. Recordings are optional
|
||||
(setting `backup.includeRecordings`, default false, because they get large). Keep the newest `backup.keep` backups.
|
||||
Restore is a CLI script only: `scripts/restore.sh <file>`. It stops api+asterisk, restores the DB and files, and starts them again.
|
||||
`APP_SECRET` must be the same as when the backup was made. The script warns about this.
|
||||
|
||||
## 9. Webhooks
|
||||
|
||||
Events: `call.started`, `call.answered`, `call.ended`, `voicemail.new`, `sms.received`,
|
||||
`sms.sent`, `emergency.call`, `trunk.registration_changed`, `extension.registration_changed`.
|
||||
|
||||
- Payload: `{ "id": "<uuid>", "event": "call.ended", "createdAt": "...", "data": {...} }`.
|
||||
`data` uses the same shapes as the REST API.
|
||||
- Headers: `Content-Type: application/json`, `X-SelfPhoneNG-Event`,
|
||||
`X-SelfPhoneNG-Timestamp` (unix seconds),
|
||||
`X-SelfPhoneNG-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`.
|
||||
- Delivery: POST with 10 s timeout. A 2xx response means done. Otherwise retry after 1 m, 5 m, 30 m, 2 h, 12 h, then mark `failed`.
|
||||
- Block private/loopback target IPs unless setting `webhooks.allowPrivate = true` (SSRF protection; homelab users can turn it on).
|
||||
|
||||
## 10. Email (`services/mail.ts`)
|
||||
|
||||
nodemailer transport from `settings.smtp`. Templates are plain TypeScript functions returning `{subject, text, html}`:
|
||||
- **voicemail**: subject `New voicemail from {callerName or number} ({m:ss})`. Body: caller, number,
|
||||
received time in company timezone, duration, link `https://<host>/me/voicemail`.
|
||||
Attachment `voicemail-<date>.wav`.
|
||||
- **emergency**: §5.3.
|
||||
- **trunkDown**, **diskLow**, **publicIpChanged**, **passwordReset**.
|
||||
If SMTP isn't configured, the UI shows a warning wherever email features are used.
|
||||
|
||||
## 11. Logging
|
||||
|
||||
pino JSON to stdout. `redact: ['*.password', '*.secret', '*.token', '*.pin', '*.Secret', 'req.headers.authorization', 'req.headers.cookie']`.
|
||||
Log every apply (files changed, duration), AMI connect/disconnect, auth events, and outgoing webhook failures.
|
||||
Never log SIP passwords, voicemail PINs, AMI login or the raw text of SMS messages (log ids and lengths only).
|
||||
@@ -0,0 +1,153 @@
|
||||
# 05 — Web UI (apps/web)
|
||||
|
||||
One React app with two areas: **Admin** (`/admin/*`, admins only) and **My
|
||||
Phone** (`/me/*`, every user, admins included). The browser softphone is part
|
||||
of My Phone and stays docked on every `/me` page.
|
||||
|
||||
## 1. Global rules
|
||||
|
||||
- React 19 + TypeScript strict + Vite + react-router 7 + TanStack Query + Tailwind 4.
|
||||
- A typed API client in `src/api/` generated from the zod schemas in
|
||||
`packages/shared` (write the wrappers by hand; no codegen tool). Every
|
||||
request sends `X-Requested-With: selfphoneng` and `credentials: 'same-origin'`.
|
||||
- Forms validate with the **same zod schemas** as the API (`@hookform/resolvers/zod` + `react-hook-form`; add both to the allowed deps).
|
||||
- Works at 360 px wide. Admin tables become stacked cards on small screens.
|
||||
- Light and dark mode follow `prefers-color-scheme`, with a manual toggle stored in `localStorage` (wrapped in try/catch).
|
||||
- Accessibility: every control has a label, focus is visible, the dialpad works from the keyboard, and the incoming-call dialog traps focus.
|
||||
- Show phone numbers formatted (`(312) 555-1234`). Inputs accept any format and normalize on blur.
|
||||
- Every destructive action uses a confirm dialog. Delete errors of type 409 show the "in use by" list from the API.
|
||||
- Live data comes from the `/api/live` WebSocket (`src/live/`), stored in a small
|
||||
Zustand store (add `zustand` to the allowed deps). The client reconnects with backoff and shows a
|
||||
thin top banner "Reconnecting…" while disconnected.
|
||||
- Never show a secret unless the user clicks "Reveal", which calls the explicit reveal endpoint.
|
||||
|
||||
## 2. Admin area
|
||||
|
||||
Left sidebar navigation, in this order:
|
||||
|
||||
| Page | Path | Contents |
|
||||
|---|---|---|
|
||||
| Dashboard | `/admin` | Cards: trunks (registered?), extensions online/total, active calls, calls today, missed today, voicemails waiting, disk free. Live call list (from, to, state, duration ticking, recording dot; admin buttons Hang up / Transfer / Listen). Queue tiles (waiting, longest wait, agents available). Warnings banner (restart required, SMTP not set, E911 caller ID not set, trunk down) |
|
||||
| Users & Extensions | `/admin/users` | Table: number, name, email, role, online (contacts count + state dot), DND, devices. Create/edit drawer with tabs: **General** (name, email, role, number with "next free" button), **Phone** (ring seconds, webrtc on/off, pickup group, BLF numbers multiselect, language, MOH class), **Voicemail** (enabled, PIN, email options), **Calling** (outbound class, outbound caller ID DID, recording mode), **Emergency** (E911 caller ID DID, location text), **Credentials** (SIP username/server/password reveal, regenerate, softphone QR), **Forwarding** (current DND/CFU/CFB/CFNA, editable) |
|
||||
| Trunks | `/admin/trunks` | List with status pill. Form: provider (voip.ms / generic). Choosing voip.ms pre-fills port, transport, codecs and shows a POP server dropdown (06 §2) plus the voip.ms-side checklist. Test button. API credentials (optional) + "Import DIDs" |
|
||||
| Phone numbers (DIDs) | `/admin/numbers` | Table: number, label, trunk, destination summary ("→ IVR Main menu"), SMS on/off. Form: number, label, trunk, **DestinationPicker**, caller-name prefix, record inbound, SMS enabled + users allowed |
|
||||
| Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied |
|
||||
| Ring groups | `/admin/ring-groups` | Form: number, name, strategy, ring seconds, members (ordered picker), music instead of ring, caller-name prefix, no-answer DestinationPicker |
|
||||
| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats |
|
||||
| IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations |
|
||||
| Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" |
|
||||
| Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record |
|
||||
| Paging | `/admin/paging` | number, name, members, duplex |
|
||||
| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files |
|
||||
| Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" |
|
||||
| Contacts | `/admin/contacts` | Company phonebook, CSV import |
|
||||
| Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording |
|
||||
| Recordings | `/admin/recordings` | Searchable list, play, download, delete |
|
||||
| Reports | `/admin/reports` | Summary charts (calls per day stacked by direction, missed per day, busiest hours heatmap), queue report table |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### 2.1 First-run setup wizard (`/setup`)
|
||||
|
||||
Shown to admins while `setup.completed` is false. Each step saves when you go
|
||||
to the next one. Steps can be skipped where noted.
|
||||
|
||||
1. Change the initial admin password.
|
||||
2. Company name, timezone (default from the browser), language.
|
||||
3. Network: "Detect public IP", LAN IP (prefilled from `/api/system/status` if detectable), local nets.
|
||||
A big note about SIP ALG and port forwarding (01 §4.2) with a checkbox "I've done this".
|
||||
4. Email (SMTP), skippable. Test button.
|
||||
5. Trunk: voip.ms form (06 §2), with the voip.ms portal checklist next to it. "Test" must show Registered before Next is enabled. Skippable for now ("I'll add it later").
|
||||
6. Phone numbers: Import from voip.ms (if API creds were given) or add manually.
|
||||
7. Users: quick table to add up to 10 users (name, email, extension auto-numbered, temporary password generated and shown once).
|
||||
8. Main number routing: "When someone calls {DID}, ring:" DestinationPicker (common choices first: a user, everyone (creates a ring group of all users), an IVR (creates a simple one)).
|
||||
9. Emergency: default E911 caller ID DID (required if a trunk exists), notification emails. Shows the 06 §6 text about registering the address at voip.ms.
|
||||
10. Done: shows "Dial *43 from any phone for an echo test" and links to the softphone.
|
||||
|
||||
## 3. Shared components (`src/components/`)
|
||||
|
||||
- **DestinationPicker**: a type select (User, Voicemail of user, Ring group, Queue,
|
||||
IVR, Office hours, Conference, Announcement, External number, Hang up), then a
|
||||
searchable target select or a number input. The value is a `Destination` object (02 §1).
|
||||
The read-only display renders "→ Ring group Sales (500)".
|
||||
- **SoundPicker**: select + play button + "upload new" shortcut.
|
||||
- **ExtensionPicker** (single/multi, orderable via drag handle).
|
||||
- **PhoneNumberInput** (normalizes, shows formatted).
|
||||
- **StatusDot**: idle green, ringing amber pulsing, in use red, on hold blue, unavailable gray, DND purple.
|
||||
- **AudioPlayer**: `<audio controls preload="none">` with a download link.
|
||||
- **ConfirmDialog**, **Drawer**, **DataTable** (sorting, client-side filtering for <1000 rows, server pagination otherwise).
|
||||
|
||||
## 4. My Phone area (`/me`)
|
||||
|
||||
| Page | Path | Contents |
|
||||
|---|---|---|
|
||||
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list |
|
||||
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
|
||||
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete. Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
|
||||
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
|
||||
| Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) |
|
||||
| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) |
|
||||
|
||||
### 4.1 Presence panel
|
||||
Right column on wide screens, a tab on mobile. Lists every extension with its
|
||||
StatusDot, name and number, plus a search box. Click to call, or to transfer when
|
||||
in a call (drop the active call onto a person = blind transfer; a menu offers attended).
|
||||
|
||||
## 5. Browser softphone (`src/softphone/`)
|
||||
|
||||
### 5.1 Structure
|
||||
- `SoftphoneEngine.ts`: a class with no React in it, wrapping SIP.js. Exposes an event emitter + state.
|
||||
- `useSoftphone.ts`: a React hook that subscribes to the engine.
|
||||
- `SoftphoneDock.tsx`: collapsed bar at the bottom of `/me` pages (status + dial button). Expands to a panel with dialpad, active calls and controls.
|
||||
- `IncomingCallDialog.tsx`.
|
||||
|
||||
### 5.2 Engine (SIP.js 0.21)
|
||||
|
||||
```ts
|
||||
import { UserAgent, Registerer, Inviter, Invitation, Session, SessionState, RegistererState } from 'sip.js';
|
||||
|
||||
class SoftphoneEngine {
|
||||
async start(creds: WebRtcCreds): Promise<void>; // creds from GET /api/me/webrtc
|
||||
stop(): Promise<void>;
|
||||
call(number: string): Promise<CallHandle>; // target: sip:<normalized>@<host>
|
||||
answer(id: string): Promise<void>;
|
||||
decline(id: string): Promise<void>; // invitation.reject({ statusCode: 486 })
|
||||
hangup(id: string): Promise<void>; // bye() if established, cancel() if early
|
||||
hold(id: string, on: boolean): Promise<void>; // re-INVITE with sessionDescriptionHandlerOptions { hold: on }
|
||||
mute(id: string, on: boolean): void; // sender.track.enabled = !on
|
||||
dtmf(id: string, digit: string): void; // RTCDTMFSender via sessionDescriptionHandler.sendDtmf
|
||||
blindTransfer(id: string, target: string): Promise<void>; // session.refer(UserAgent.makeURI(...))
|
||||
attendedTransfer(id: string, consultId: string): Promise<void>; // session.refer(consultSession)
|
||||
setOutputDevice(deviceId: string): Promise<void>; // audioEl.setSinkId
|
||||
setInputDevice(deviceId: string): Promise<void>; // getUserMedia constraints for next calls
|
||||
}
|
||||
```
|
||||
- UserAgent options: `uri` = `creds.sipUri`, `transportOptions.server` = `creds.wsUrl`,
|
||||
`authorizationUsername` = `creds.authUser`, `authorizationPassword` = `creds.password`,
|
||||
`displayName`, `sessionDescriptionHandlerFactoryOptions.peerConnectionConfiguration.iceServers` = `creds.iceServers`,
|
||||
`contactParams: { transport: 'wss' }`, `logLevel: 'warn'`.
|
||||
- Register with `Registerer` (expires 300). On transport disconnect, reconnect with backoff 2→30 s and re-register.
|
||||
- Remote audio: one hidden `<audio autoplay>` element per call. Attach `MediaStream` from the receivers' tracks.
|
||||
- Max 2 calls at once (call waiting). A second incoming call while busy shows a smaller banner. Answering it puts the first on hold.
|
||||
- **One tab only:** use `BroadcastChannel('selfphoneng-softphone')` to elect a leader tab. Only the
|
||||
leader registers. Other tabs show "Softphone is active in another tab" with a "Use here" button
|
||||
that asks the leader to stop.
|
||||
- Ask for microphone permission only when the user first clicks "Enable softphone". Remember the choice in
|
||||
localStorage (`softphone.enabled`, try/catch).
|
||||
- Incoming call: play `/sounds/ringtone.mp3` (a CC0 file we commit to `apps/web/public/sounds/`, with its license in `SOUNDS-LICENSE.md`) in a loop,
|
||||
show `Notification` if permitted and the tab is hidden, and clicking the notification focuses the tab.
|
||||
Look up the caller name: contacts → extensions → raw number.
|
||||
- Auto-answer header (paging): if the INVITE has `Call-Info: ...;answer-after=0`, answer after a short beep. (Paging only targets desk phones today. Implement the check anyway; it's 5 lines.)
|
||||
- Show call quality warnings if `RTCPeerConnection.getStats()` shows packet loss > 5% or jitter > 50 ms over 5 s.
|
||||
|
||||
### 5.3 Softphone panel UI
|
||||
- Top: registration status (Registered / Connecting / Error with a reason) + DND toggle.
|
||||
- Number input with paste normalization + 12-key dialpad (also typing on the keyboard; Enter calls).
|
||||
- Active call cards: name/number, timer, state; buttons Mute, Hold, Keypad, Transfer, Record (sends DTMF `*1`, which toggles automixmon on the server), Hang up.
|
||||
- Transfer flow: search box (contacts + extensions) → "Transfer now" (blind) or "Talk first" (attended: places a consult call; then "Complete transfer" / "Cancel").
|
||||
- Recent calls (last 10, from `/api/me/calls`).
|
||||
|
||||
## 6. Pages to leave out of v1
|
||||
Wallboard (big-screen queue display), CRM pop-ups, chat. Don't build them.
|
||||
@@ -0,0 +1,139 @@
|
||||
# 06 — voip.ms trunk, numbers, SMS and E911
|
||||
|
||||
voip.ms is the reference provider. Everything here also has a "generic" SIP
|
||||
trunk fallback (same fields, no voip.ms-specific help text or API features).
|
||||
|
||||
> **Verify-before-trusting rule:** voip.ms changes its portal and API over time.
|
||||
> Every item marked **(verify)** must be checked against the current voip.ms
|
||||
> wiki/API docs or a real packet capture during M3/M9, and this file updated.
|
||||
|
||||
## 1. What the customer does in the voip.ms portal (shown as a checklist in the UI)
|
||||
|
||||
1. **Create a sub-account** (Main Menu → Sub Accounts → Create). Use one sub-account per PBX, e.g. `123456_pbx`.
|
||||
- Authentication type: **User/Password** (or IP auth if the server has a static IP)
|
||||
- Password: strong, random
|
||||
- Device type: **Asterisk, IP PBX, Gateway or VoIP Switch** **(verify the label)**
|
||||
- Allowed codecs: **G.711u (ulaw)**. Others optional.
|
||||
- DTMF mode: **RFC 2833**
|
||||
- NAT: **Yes**
|
||||
- Encrypted SIP traffic (TLS/SRTP): optional. If on, set our trunk transport to TLS, port 5061, SRTP on.
|
||||
- CallerID Number: your main DID.
|
||||
- International calling: leave **disabled** unless needed (toll-fraud protection on their side too).
|
||||
2. **Choose a POP server** close to the PBX (e.g. `chicago1.voip.ms`). The sub-account registers to this server.
|
||||
3. **Point each DID at the sub-account** (DID Numbers → Manage DIDs → edit):
|
||||
- Routing: **SIP/IAX → the sub-account**
|
||||
- **POP: the same server** the sub-account registers to. A mismatch is a common cause of inbound calls failing **(verify)**.
|
||||
4. **E911**: register a street address for every DID used as an emergency caller ID (DID → e911). See §6.
|
||||
5. **(Optional) API access** for SMS and DID import: Main Menu → SOAP and REST/JSON API →
|
||||
enable, set an API password, and **whitelist the PBX's public IP**.
|
||||
6. **(Optional) SMS**: on each DID, enable SMS/MMS and set the SMS URL Callback (§5.2).
|
||||
7. **Balance protection**: in voip.ms account settings, set low-balance alerts and
|
||||
per-call/daily limits where offered.
|
||||
|
||||
## 2. Trunk form (voip.ms preset)
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| Name | `voip.ms` | |
|
||||
| Server | dropdown | Filled from API `getServersInfo` when API creds are set; otherwise free text with examples `chicago1.voip.ms`, `atlanta1.voip.ms`, `dallas1.voip.ms`, `newyork1.voip.ms`, `losangeles1.voip.ms`, `seattle1.voip.ms`, `montreal1.voip.ms`, `toronto1.voip.ms` **(verify names)** |
|
||||
| Auth mode | registration | |
|
||||
| Username | | sub-account, format `^\d+_[A-Za-z0-9_]+$` or the 6-digit main account |
|
||||
| Password | | |
|
||||
| Transport | UDP 5060 | TLS 5061 when encryption is enabled on the sub-account |
|
||||
| Codecs | ulaw | |
|
||||
| Max channels | empty | |
|
||||
| API username / password | empty | The API username is the account email |
|
||||
|
||||
The generated PJSIP config is in `03-config-generator.md` §5.3. It follows the
|
||||
voip.ms Asterisk PJSIP wiki: registration + auth + aor + endpoint + identify,
|
||||
`rtp_symmetric`, `rewrite_contact`, `send_rpid` and `from_user` = account.
|
||||
|
||||
## 3. Inbound calls: which DID was dialed?
|
||||
|
||||
- voip.ms's Asterisk example matches inbound calls on `exten => <10-digit DID>`,
|
||||
which means the **Request-URI user is the 10-digit DID** (no leading 1).
|
||||
- The dialplan (03 §6.4) normalizes it to 11 digits and checks `sp-did-exists`. If that fails,
|
||||
it falls back to the user part of the `To:` header. That covers either behavior.
|
||||
- **M3 acceptance check (verify):** run `pjsip set logger on`, call the DID from a
|
||||
mobile phone, and confirm which header carries the DID. Record the finding here.
|
||||
- Inbound caller ID arrives as 10 digits for North American callers. The dialplan normalizes it to 11.
|
||||
|
||||
## 4. Outbound calls
|
||||
|
||||
- voip.ms accepts `1NXXNXXXXXX` (11 digits) and `011…` for international. Our routes always send 11 digits.
|
||||
- Caller ID: we set `CALLERID(num)`, and `send_rpid=yes` sends it in `Remote-Party-ID`.
|
||||
**(verify)** whether voip.ms honors a per-call caller ID that is one of the account's DIDs, or always
|
||||
uses the sub-account's CallerID setting. If it always overrides: show a notice on the
|
||||
extension "Outbound caller ID" field ("voip.ms uses the sub-account caller ID;
|
||||
create one sub-account per caller ID if you need different ones") and, as a workaround, allow
|
||||
several trunks (one per sub-account) with routes that choose the trunk by caller.
|
||||
- Toll-free and NANP routes are created by the setup wizard (03 §6.12). International is created **disabled**.
|
||||
|
||||
## 5. SMS / MMS
|
||||
|
||||
### 5.1 Sending (voip.ms REST API)
|
||||
`integrations/voipms/client.ts`:
|
||||
```
|
||||
GET https://voip.ms/api/v1/rest.php
|
||||
?api_username=<email>&api_password=<api pw>&method=sendSMS
|
||||
&did=<10-digit DID>&dst=<10-digit number>&message=<text>
|
||||
```
|
||||
- Response JSON `{"status":"success","sms":<id>}`. Any other `status` is an error code string. Store it in `sms_messages.error`.
|
||||
- Messages over 160 characters: use `method=sendMMS` (`message` up to 2048 chars **(verify limit)**),
|
||||
plus `media1`…`media3` URLs for pictures. We split nothing ourselves.
|
||||
- Numbers: voip.ms wants 10 digits for NANP, so strip the leading `1`.
|
||||
- The API password goes in a query string, so **never log the request URL**.
|
||||
- Rate limit our sends to 1 per second per DID (queue in memory).
|
||||
- Other methods used: `getDIDsInfo` (DID import), `getServersInfo` (server dropdown),
|
||||
`getSMS` (optional polling fallback). All behind `voipmsApi(method, params)` with a 15 s timeout.
|
||||
|
||||
### 5.2 Receiving
|
||||
Endpoint: `GET|POST /hooks/voipms/sms/:token`. `:token` is a 32-char random value stored
|
||||
in settings key `sms.webhookToken` (shown in the DID's SMS settings with a copy button).
|
||||
The full URL to paste into voip.ms is shown in the UI.
|
||||
|
||||
voip.ms offers two callback styles **(verify both)**:
|
||||
1. **URL callback (GET)** with placeholders. The UI tells the user to paste:
|
||||
`https://<host>/hooks/voipms/sms/<token>?from={FROM}&to={TO}&message={MESSAGE}&id={ID}&date={DATE}`
|
||||
2. **Webhook (JSON POST)**. Accept any JSON body. Parse the known shape (look for
|
||||
`data.payload.from.phone_number`, `data.payload.to[0].phone_number`, `data.payload.text`,
|
||||
`data.payload.media[].url`, `data.payload.id`). If the shape doesn't match, store the raw body in
|
||||
the log at `warn` level (text redacted to its length) and return 200 so voip.ms doesn't retry forever.
|
||||
**M9 task:** capture one real payload into `test/fixtures/voipms/sms-webhook.json` and write the parser test from it.
|
||||
|
||||
Handler: normalize numbers, find the DID (404 if unknown), insert `sms_messages`
|
||||
(`direction='in'`, `status='received'`), dedupe on `provider_id`, broadcast
|
||||
`sms.received` to admins + `did_sms_users`, fire the webhook. Reply `ok` (text/plain, 200).
|
||||
Callback IPs: voip.ms doesn't publish a fixed list **(verify)**, so security is the secret token.
|
||||
Rate limit the endpoint to 60 requests/min.
|
||||
|
||||
## 6. Emergency calling (E911): US rules for a PBX
|
||||
|
||||
Legal background (summary, not legal advice; the admin guide must say "check with counsel"):
|
||||
- **Kari's Law** (US, 47 CFR 9.16): users of a multi-line phone system must be able to dial
|
||||
**911 directly**, with no prefix like 9, and the system must **notify** a central location
|
||||
(front desk, security, admin) when 911 is dialed, where configured.
|
||||
- **RAY BAUM'S Act** (47 CFR 9.16(b)): the 911 call must convey a **dispatchable
|
||||
location** (street address plus room/floor where needed).
|
||||
|
||||
How SelfPhoneNG meets this:
|
||||
1. `911` and `9911` are always dialable from every extension with no restrictions (03 §6.14). Unit test:
|
||||
an extension with `outbound_class = internal`, DND on, and every trunk at max channels can still reach the 911 Dial line.
|
||||
2. Notifications: email + phone ring-out + dashboard banner + webhook (04 §5.3).
|
||||
3. Location: voip.ms delivers the **address registered on the DID** used as caller ID.
|
||||
- Every extension can have its own `emergency_caller_id_did_id` (a DID whose e911 address matches that person's
|
||||
location). Otherwise the system default `emergency.defaultCallerIdDidId` is used.
|
||||
- `extensions.emergency_location` (e.g. "2nd floor, room 210") is put into the notification email. The PSAP
|
||||
can't receive it through voip.ms. The admin guide must explain this limitation, and that remote
|
||||
workers need a DID with their own registered address (or must call 911 from a mobile phone).
|
||||
4. Warnings: the admin dashboard shows a red banner while any trunk exists but
|
||||
`emergency.defaultCallerIdDidId` is unset, or while that DID's `e911_address` field is empty
|
||||
("Confirm this DID has an E911 address at voip.ms").
|
||||
5. **Never place a real 911 test call.** Settings → Emergency → "Test notifications" runs steps 2–4
|
||||
of 04 §5.3 with the subject prefixed `TEST` and dials nothing. (Some carriers offer a test number;
|
||||
check voip.ms docs before adding one.)
|
||||
6. Canada: 911 works the same way through voip.ms. The admin guide links to voip.ms's Canadian e911 info.
|
||||
|
||||
## 7. DID import (`POST /api/trunks/:id/import-dids`)
|
||||
Calls `getDIDsInfo`. Shows a list with checkboxes in the UI. Imported DIDs get
|
||||
destination `{type:'hangup'}` and a warning chip "Not routed yet" until edited.
|
||||
@@ -0,0 +1,181 @@
|
||||
# 07 — Phone provisioning (desk phones, softphones, mobile)
|
||||
|
||||
## 1. How it works
|
||||
|
||||
1. Admin adds a device: MAC + vendor + model + extension (`/admin/devices`).
|
||||
The api generates `provisioning_password` (20 chars).
|
||||
2. The phone learns the provisioning URL in one of three ways: DHCP option 66
|
||||
(`https://pbx.example.com/prov/`), typed into the phone's web UI, or the vendor's redirect service
|
||||
(not supported in v1).
|
||||
3. The phone requests its file over HTTPS with HTTP Basic auth:
|
||||
username = MAC (12 lowercase hex), password = provisioning password.
|
||||
4. The api renders the file from the vendor template with that extension's data.
|
||||
|
||||
Code: `apps/api/src/provisioning/`. Templates are TypeScript functions
|
||||
(`templates/yealink.ts`, `templates/grandstream.ts`, `templates/polycom.ts`) that take a
|
||||
`ProvisionContext` and return a string. No template engine dependency.
|
||||
|
||||
```ts
|
||||
type ProvisionContext = {
|
||||
mac: string; vendor: string; model?: string;
|
||||
host: string; // PUBLIC_HOSTNAME
|
||||
sipPort: number; transport: 'udp'|'tcp'|'tls';
|
||||
ext: { number: string; name: string; sipPassword: string; voicemailNumber: '*97' };
|
||||
blf: { number: string; label: string }[]; // from extensions.blf_numbers
|
||||
timezone: { iana: string; yealinkOffset: string; yealinkName: string; posix: string };
|
||||
provUrl: string; provUser: string; provPassword: string;
|
||||
phonebookUrl: string;
|
||||
ntpServer: string; // default 'pool.ntp.org'
|
||||
};
|
||||
```
|
||||
|
||||
## 2. Endpoints (`routes/provisioning.ts`, no session auth)
|
||||
|
||||
| Path | Vendor | Notes |
|
||||
|---|---|---|
|
||||
| `GET /prov/<mac>.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` |
|
||||
| `GET /prov/y0000000000<xx>.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` |
|
||||
| `GET /prov/cfg<mac>.xml` | Grandstream | |
|
||||
| `GET /prov/phonebook/<mac>.xml` | both | company phonebook + extensions, vendor-specific XML |
|
||||
| `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic |
|
||||
|
||||
Rules:
|
||||
- Unknown MAC → 404, wrong/missing Basic auth → 401 with `WWW-Authenticate: Basic realm="selfphoneng"`.
|
||||
Both get logged with IP. Rate limit: 20 requests / 10 min per IP; after 10 failures, block that IP for 1 h.
|
||||
- If `provisioning.lanOnly` is true and the client IP isn't in `network.localNets` → 403.
|
||||
- On success: update `last_provisioned_at`, `last_provisioned_ip`. Set `Cache-Control: no-store`.
|
||||
- Never serve over plain HTTP. The reverse proxy only listens on 443. (Old phones that can't do modern TLS are not supported.)
|
||||
|
||||
## 3. Templates
|
||||
|
||||
The key names below come from vendor documentation. Each one is marked
|
||||
**(verify per model)**. Before marking M10 done, test on at least one real
|
||||
Yealink and one Grandstream phone, or the vendor's config validator.
|
||||
|
||||
### 3.1 Yealink (T3x/T4x/T5x)
|
||||
|
||||
```
|
||||
#!version:1.0.0.1
|
||||
## account 1
|
||||
account.1.enable = 1
|
||||
account.1.label = {ext.number} {ext.name}
|
||||
account.1.display_name = {ext.name}
|
||||
account.1.auth_name = {ext.number}
|
||||
account.1.user_name = {ext.number}
|
||||
account.1.password = {ext.sipPassword}
|
||||
account.1.sip_server.1.address = {host}
|
||||
account.1.sip_server.1.port = {sipPort}
|
||||
account.1.sip_server.1.transport_type = {0 udp | 1 tcp | 2 tls}
|
||||
account.1.sip_server.1.expires = 300
|
||||
account.1.dtmf.type = 1
|
||||
account.1.subscribe_mwi_to_vm = 1
|
||||
voice_mail.number.1 = *97
|
||||
features.pickup.direct_pickup_enable = 1
|
||||
features.pickup.direct_pickup_code = **
|
||||
features.pickup.group_pickup_enable = 1
|
||||
features.pickup.group_pickup_code = *8
|
||||
## BLF keys (start at linekey 2; linekey 1 is the line)
|
||||
linekey.{n}.type = 16
|
||||
linekey.{n}.line = 1
|
||||
linekey.{n}.value = {blf.number}
|
||||
linekey.{n}.label = {blf.label}
|
||||
linekey.{n}.pickup_value = **{blf.number}
|
||||
## time
|
||||
local_time.time_zone = {timezone.yealinkOffset}
|
||||
local_time.time_zone_name = {timezone.yealinkName}
|
||||
local_time.ntp_server1 = {ntpServer}
|
||||
## phonebook
|
||||
remote_phonebook.data.1.url = {phonebookUrl}
|
||||
remote_phonebook.data.1.name = Company
|
||||
## keep provisioning
|
||||
static.auto_provision.server.url = {provUrl}
|
||||
static.auto_provision.server.username = {provUser}
|
||||
static.auto_provision.server.password = {provPassword}
|
||||
static.auto_provision.repeat.enable = 1
|
||||
static.auto_provision.repeat.minutes = 1440
|
||||
```
|
||||
Phonebook XML:
|
||||
```xml
|
||||
<YealinkIPPhoneDirectory>
|
||||
<DirectoryEntry><Name>Dan Doe</Name><Telephone>101</Telephone></DirectoryEntry>
|
||||
</YealinkIPPhoneDirectory>
|
||||
```
|
||||
|
||||
### 3.2 Grandstream (GRP/GXP)
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gs_provision version="1">
|
||||
<mac>{mac}</mac>
|
||||
<config version="1">
|
||||
<P271>1</P271> <!-- account 1 active -->
|
||||
<P270>{ext.number} {ext.name}</P270> <!-- account name -->
|
||||
<P47>{host}:{sipPort}</P47> <!-- SIP server -->
|
||||
<P35>{ext.number}</P35> <!-- SIP user ID -->
|
||||
<P36>{ext.number}</P36> <!-- auth ID -->
|
||||
<P34>{ext.sipPassword}</P34> <!-- password -->
|
||||
<P3>{ext.name}</P3> <!-- display name -->
|
||||
<P33>*97</P33> <!-- voicemail -->
|
||||
<P130>{0 udp | 1 tcp | 2 tls}</P130>
|
||||
<P212>2</P212> <!-- config upgrade via HTTPS -->
|
||||
<P237>{host}/prov</P237> <!-- config server path -->
|
||||
<P1360>{provUser}</P1360> <!-- HTTP(S) username -->
|
||||
<P1361>{provPassword}</P1361> <!-- HTTP(S) password -->
|
||||
<P30>{ntpServer}</P30>
|
||||
<P64>{timezone.posix}</P64>
|
||||
</config>
|
||||
</gs_provision>
|
||||
```
|
||||
All P-values are **(verify per model)**. BLF/MPK keys on Grandstream differ by
|
||||
model family, so they are **left out of v1**. Show a note in the UI.
|
||||
All XML values must be XML-escaped.
|
||||
|
||||
### 3.3 Polycom (VVX)
|
||||
Stretch goal for M10. Serve `<mac>.cfg` as a master config whose `CONFIG_FILES`
|
||||
points to `phone-<mac>.cfg` with `reg.1.address`, `reg.1.auth.userId`,
|
||||
`reg.1.auth.password`, `reg.1.server.1.address`, `msg.mwi.1.subscribe`. Mark as experimental.
|
||||
|
||||
### 3.4 Timezone mapping
|
||||
`provisioning/timezones.ts`: a table for at least the US/Canada zones (Eastern, Central,
|
||||
Mountain, Arizona, Pacific, Alaska, Hawaii, Atlantic, Newfoundland) plus UTC and London,
|
||||
mapping IANA → Yealink offset/name → POSIX TZ string. Anything else falls back to UTC with a UI warning.
|
||||
|
||||
## 4. Generic devices
|
||||
Vendor `generic`: no file is served. The device page shows manual settings instead:
|
||||
server, port, transport, username, password (reveal), voicemail `*97`.
|
||||
|
||||
## 5. Softphone apps (desktop + mobile)
|
||||
|
||||
Recommended open source apps (put in the user's "Set up a phone app" page):
|
||||
- **Linphone** (Android, iOS, Windows, macOS, Linux; GPL). Supports remote provisioning via QR code.
|
||||
- **MicroSIP** (Windows; GPL). Manual setup.
|
||||
- **Baresip** (Linux, CLI; BSD). Manual setup, also used in our tests.
|
||||
|
||||
Linphone provisioning:
|
||||
- `GET /api/extensions/:id/softphone-qr` (admin) or `GET /api/me/softphone-qr` returns a PNG QR (via
|
||||
`qrcode`) of `https://<host>/prov/linphone/<token>.xml`.
|
||||
- `token` = base64url(`extensionId` + `.` + `expiryUnix` + `.` + HMAC-SHA256(APP_SECRET, extensionId + expiry)), valid 24 h.
|
||||
It is stateless, so there's no table. Regenerating the SIP password makes old configs useless anyway.
|
||||
- XML:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config xmlns="http://www.linphone.org/xsds/lpconfig.xsd">
|
||||
<section name="proxy_0">
|
||||
<entry name="reg_proxy"><sip:{host}:{sipPort};transport={udp|tcp|tls}></entry>
|
||||
<entry name="reg_identity">"{name}" <sip:{ext}@{host}></entry>
|
||||
<entry name="reg_expires">600</entry>
|
||||
<entry name="reg_sendregister">1</entry>
|
||||
</section>
|
||||
<section name="auth_info_0">
|
||||
<entry name="username">{ext}</entry>
|
||||
<entry name="passwd">{sipPassword}</entry>
|
||||
<entry name="domain">{host}</entry>
|
||||
</section>
|
||||
</config>
|
||||
```
|
||||
**(verify against Linphone's remote provisioning docs)**
|
||||
|
||||
Mobile limitation (state it plainly in the UI and admin guide): without push
|
||||
notifications, iOS and battery-optimized Android apps miss calls while in the
|
||||
background. A push gateway is a v2 item. For v1, recommend the desk phone + browser softphone,
|
||||
or call forwarding to a mobile number (CFNA) as the fallback.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 08 — Security, operations, legal
|
||||
|
||||
## 1. Threat model (what attacks a PBX on the internet)
|
||||
|
||||
| Threat | Example | Controls |
|
||||
|---|---|---|
|
||||
| SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2); 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Dialplan injection | Name `x); System(...)` | Sanitizers (03 §2), no `System()` anywhere |
|
||||
| SSRF via webhooks | Webhook to `http://127.0.0.1:5038` | Private IP block by default (04 §9) |
|
||||
| Stolen backup | Backup file copied | Backups exclude generated plaintext configs; DB secrets are encrypted with `APP_SECRET` (kept in `.env`, not in the backup); mode 0600 |
|
||||
| AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret |
|
||||
|
||||
## 2. Required controls
|
||||
|
||||
### 2.1 Network
|
||||
- Expose only 443/tcp, 5060/udp+tcp (5061/tcp if TLS), 10000–20000/udp.
|
||||
- Admin guide includes an nftables example that limits 5060/5061 to voip.ms
|
||||
server IPs (from the voip.ms wiki list, **verify**) plus `sip.extraAllowedNets`, for setups with no remote phones.
|
||||
|
||||
### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`)
|
||||
When true, the generator adds:
|
||||
```ini
|
||||
[sp-phone-acl]
|
||||
type = acl
|
||||
contact_deny = 0.0.0.0/0.0.0.0
|
||||
contact_permit = 192.168.0.0/16 ; each network.localNets + sip.extraAllowedNets
|
||||
```
|
||||
and `contact_acl = sp-phone-acl` on every extension endpoint (not trunks). Golden test required.
|
||||
|
||||
### 2.3 fail2ban / CrowdSec
|
||||
- `logger.conf` writes `security.log` (01 §3.3).
|
||||
- Admin guide: fail2ban `asterisk` jail pointed at `./data/asterisk-logs/security.log`, or the CrowdSec
|
||||
Asterisk collection with the same log path **(verify collection name)**.
|
||||
- The api's own auth failures go to stdout JSON with `event: "auth.failed"` and `ip`, so they can be parsed too.
|
||||
|
||||
### 2.4 Web
|
||||
- Security headers on every api response:
|
||||
`Content-Security-Policy: default-src 'self'; connect-src 'self' wss://{host}; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`,
|
||||
`Permissions-Policy: microphone=(self), camera=()`.
|
||||
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
|
||||
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
|
||||
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
|
||||
|
||||
### 2.5 Files and permissions
|
||||
- `/etc/asterisk/selfphoneng/*` mode 0640, owner app UID.
|
||||
- `.env` mode 0600 (install.sh sets it).
|
||||
- Recordings and voicemail are served only through authenticated api routes, never as static files.
|
||||
|
||||
## 3. Operations
|
||||
|
||||
### 3.1 Install / update
|
||||
- `scripts/install.sh` (01 §1.3).
|
||||
- `scripts/update.sh`: `git pull --ff-only`, `docker compose build`, `docker compose up -d`. Migrations run automatically on api start. Print the new version.
|
||||
- Version: `package.json` version at the root, exposed at `/api/system/status`. Semver. `CHANGELOG.md` kept by hand.
|
||||
- Asterisk security releases: bump `ASTERISK_VERSION` in the Dockerfile and rebuild. The admin guide explains how.
|
||||
|
||||
### 3.2 Monitoring
|
||||
- `GET /healthz` for an external monitor (Uptime Kuma etc.): 200 / 503 + JSON reasons.
|
||||
- Emails to admins: trunk down > 5 min, disk < 10%, public IP changed, backup failed.
|
||||
- Logs: `docker compose logs api`, `./data/asterisk-logs/messages.log`.
|
||||
|
||||
### 3.3 Troubleshooting page in the admin guide (M11)
|
||||
Must cover: one-way audio (NAT/publicIp/SIP ALG/RTP ports), trunk won't register (credentials, server,
|
||||
IP whitelist when IP auth, firewall), inbound calls don't arrive (DID routing + POP), browser softphone won't
|
||||
register (WSS proxy path `/ws`, certificate, microphone permission), voicemail emails not sent (SMTP test), and
|
||||
how to use `/admin/system` CLI views and `pjsip set logger on` (via `docker compose exec asterisk asterisk -rx`).
|
||||
|
||||
## 4. Licensing compliance
|
||||
- Our code: AGPL-3.0-or-later. Add `LICENSE`, SPDX headers, and an About page (`/admin/system` → About) listing
|
||||
the license and a link to the source repository (AGPL requires offering source to network users).
|
||||
- Asterisk: GPL-2.0, runs as a separate program. We don't link to it, so there's no license conflict.
|
||||
- Asterisk core/extra sounds and opsound MOH: CC-BY-SA. Credit them on the About page.
|
||||
- Ringtone: CC0, documented in `apps/web/public/sounds/SOUNDS-LICENSE.md`.
|
||||
- No closed-source codecs (AGENTS rule 4).
|
||||
|
||||
## 5. Legal features (not legal advice; the admin guide says to check local law)
|
||||
- **Call recording consent:** US federal law and most states are one-party consent, but several states
|
||||
(e.g. California, Florida, Illinois, Maryland, Massachusetts, Montana, New Hampshire, Pennsylvania, Washington)
|
||||
require all parties to consent. When any recording is enabled, the Recording settings tab shows this notice and
|
||||
strongly suggests setting `recordings.announcementSoundId`. The setup wizard doesn't turn recording on.
|
||||
- **E911 / Kari's Law / RAY BAUM'S Act:** see 06 §6.
|
||||
- **Data retention:** retention settings for recordings and voicemail (04 §8). Deleting a user offers
|
||||
"delete their voicemail and recordings too".
|
||||
@@ -0,0 +1,240 @@
|
||||
# 09 — Roadmap: milestones and tasks
|
||||
|
||||
Work strictly in order. Each task lists what to **read**, what to **build**, and
|
||||
when it's **done**. A milestone is done only when every task in it is done
|
||||
and the milestone check passes.
|
||||
|
||||
Legend: 📖 read · 🔨 build · ✅ done when
|
||||
|
||||
---
|
||||
|
||||
## M0 — Skeleton that boots
|
||||
|
||||
**M0-T1 Workspace**
|
||||
📖 00 §4, §5
|
||||
🔨 pnpm workspace with `apps/api`, `apps/web`, `packages/shared`. TypeScript strict in all three, ESLint + Prettier, vitest. Root scripts `dev`, `build`, `test`, `lint`, `typecheck`. `LICENSE` (AGPL-3.0-or-later), `.gitignore` (includes `data/`, `.env`), `README.md` (one paragraph + link to spec).
|
||||
✅ `pnpm install && pnpm lint && pnpm typecheck && pnpm test` passes on a clean clone.
|
||||
|
||||
**M0-T2 Shared phone utils**
|
||||
📖 00 §8
|
||||
🔨 `packages/shared/src/phone.ts`: `normalizeNumber`, `formatNumber`, `isReservedNumber`. Exhaustive tests (at least 25 cases, including `+1 (312) 555-1234`, `3125551234`, `13125551234`, `+44 20 7946 0958`, `*97`, `911`, garbage).
|
||||
✅ Tests pass.
|
||||
|
||||
**M0-T3 api hello**
|
||||
📖 01 §1.2, 04 §1 (steps 1, 2, 5, 6 only)
|
||||
🔨 `config.ts` with zod env parsing, pino logger with redaction, Fastify with `GET /healthz` → `{ok:true}`, static serving of `./public` with SPA fallback.
|
||||
✅ `pnpm --filter @selfphoneng/api dev` serves `/healthz`. A missing `APP_SECRET` exits 1 with a clear message.
|
||||
|
||||
**M0-T4 web hello**
|
||||
🔨 Vite + React + Tailwind + react-router. One page "SelfPhoneNG" with a dark-mode toggle. In dev, Vite proxies `/api` and `/ws` to the api.
|
||||
✅ `pnpm --filter @selfphoneng/web build` outputs `dist/`. The page renders at 360 px and desktop widths.
|
||||
|
||||
**M0-T5 Asterisk image**
|
||||
📖 01 §2, §3
|
||||
🔨 `asterisk/Dockerfile`, `entrypoint.sh`, and every static conf file from 01 §3. Pin `ASTERISK_VERSION` to the newest 24.x and write it into 01 §2.
|
||||
✅ `docker compose up -d asterisk` stays up. `docker compose exec asterisk asterisk -rx "core show version"` prints 24.x. `module show like pjsip` lists res_pjsip. The log has no `ERROR` lines.
|
||||
|
||||
**M0-T6 Full compose**
|
||||
📖 01 §1
|
||||
🔨 `docker-compose.yml`, `.env.example`, api Dockerfile, `scripts/install.sh`.
|
||||
✅ On a fresh Ubuntu/Debian VM: `./scripts/install.sh` → `curl http://127.0.0.1:8080/healthz` returns ok, and the web page loads through the api.
|
||||
|
||||
**M0-T7 CI**
|
||||
🔨 CI workflow (`.github/workflows/ci.yml`; it's also Gitea Actions-compatible) running lint, typecheck, test, and building both Docker images.
|
||||
✅ Green on push.
|
||||
|
||||
---
|
||||
|
||||
## M1 — Database, auth, admin shell
|
||||
|
||||
**M1-T1 Schema + migrations**
|
||||
📖 02 (all)
|
||||
🔨 `db/schema.ts` matching the DDL exactly, plus the first migration. `services/settings.ts` with a zod schema and default per key (02 §3).
|
||||
✅ Migration applies to an empty DB. A test compares `information_schema.columns` for 5 key tables against the spec.
|
||||
|
||||
**M1-T2 Secrets**
|
||||
📖 04 §3
|
||||
🔨 `crypto/secrets.ts` + tests.
|
||||
✅ Round-trip, tamper and wrong-key tests pass.
|
||||
|
||||
**M1-T3 Auth**
|
||||
📖 04 §2
|
||||
🔨 Login/logout/me/change-password, sessions, CSRF header check, rate limits, guards, initial admin bootstrap, `must_change_password` enforcement, audit log writes for login events.
|
||||
✅ Integration tests (Fastify `inject` + a test DB): good login sets the cookie; bad password → 401 and is audited; 6th failure → 429; POST without `X-Requested-With` → 403; non-admin hitting admin route → 403.
|
||||
|
||||
**M1-T4 Users API + admin shell UI**
|
||||
📖 04 §7.1, 05 §1, §2 (Users page, General tab only)
|
||||
🔨 `/api/users` CRUD, login page, change-password page, admin layout with sidebar (pages not built yet show "Coming in M<n>"), Users table + drawer (General tab).
|
||||
✅ An admin can log in, is forced to change the password, then creates/edits/deletes a user. Every change shows up in `audit_log`.
|
||||
|
||||
---
|
||||
|
||||
## M2 — Phones register and call each other
|
||||
|
||||
**M2-T1 AMI client**
|
||||
📖 04 §4
|
||||
🔨 `ami/client.ts` + parser tests with recorded fixtures.
|
||||
✅ Against the real container: login works, `command('core show version')` returns text, killing Asterisk triggers reconnect with backoff, and pending actions reject.
|
||||
|
||||
**M2-T2 Generator core**
|
||||
📖 03 §1, §2, §3, §11, §12.1–12.2
|
||||
🔨 `loadSnapshot`, `render` (initially: header only), `apply` with mutex, debounce, atomic writes, `.prev`, reload command mapping, `generator_runs`. Sanitizers + hostile-input tests.
|
||||
✅ `apply` writes files and sends only the needed reloads. Sanitizer tests pass.
|
||||
|
||||
**M2-T3 PJSIP for extensions**
|
||||
📖 03 §5.1, §5.2, 01 §4.3
|
||||
🔨 Render global/system/transports/extension endpoints; transport-hash restart detection; `rtp_selfphoneng.conf`.
|
||||
✅ Golden cases `empty`, `one-user`, `webrtc-disabled`, `hostile-names` pass. Asterisk loads them without errors (`pjsip show endpoints` lists 101 and 101-web).
|
||||
|
||||
**M2-T4 Base dialplan**
|
||||
📖 03 §6.0–6.3, §6.6 (users only), §9
|
||||
🔨 `dialplan/static.ts` (all static contexts from §6.2), the globals file, `sp-local-numbers` for users with hints. Voicemail is disabled for now (ARG3 = 0) until M4.
|
||||
✅ Golden tests updated. `dialplan show sp-local-numbers` shows users and hints.
|
||||
|
||||
**M2-T5 Extensions API + UI**
|
||||
📖 02 (extensions, user_runtime_state), 04 §7.2, §7.3 (extensions), 05 §2 (Users page tabs General/Phone/Credentials)
|
||||
🔨 Numbering service, extensions CRUD (generates SIP password and voicemail PIN), credentials reveal (audited), regenerate password. Every change calls `requestApply`.
|
||||
✅ Creating user 101 in the UI → within 2 s `pjsip show endpoint 101` exists.
|
||||
|
||||
**M2-T6 Live state v1 + dashboard**
|
||||
📖 04 §5.1 (extension states + contacts only), §5.2
|
||||
🔨 Live store, initial load, ExtensionStatus/ContactStatus handlers, `/api/live` WS, dashboard "extensions online", StatusDots in the Users table.
|
||||
✅ **Milestone check:** register two softphones (e.g. baresip/MicroSIP/Linphone) on the LAN as 101 and 102. 101 calls 102 → two-way audio for 30 s. The dashboard shows both online, and the dots go red during the call. Write `apps/api/test/sipp/register-and-call.xml` (or a baresip script) that automates register + call + BYE and run it in `pnpm test:asterisk`.
|
||||
|
||||
---
|
||||
|
||||
## M3 — Real phone number in and out (voip.ms)
|
||||
|
||||
**M3-T1 Trunks**
|
||||
📖 03 §5.3, 06 §1, §2
|
||||
🔨 Trunks CRUD + form with the voip.ms preset and checklist; registration status in live state (`Registry`, `PJSIPShowRegistrationsOutbound`); `POST /api/trunks/:id/test`.
|
||||
✅ With a real voip.ms sub-account, the trunk shows **Registered** in the UI.
|
||||
|
||||
**M3-T2 DIDs + inbound routing (to a user)**
|
||||
📖 03 §6.4, §6.5, 06 §3, 02 §1
|
||||
🔨 Destination zod schema + `destApp` + `findReferences`/`assertDestinationExists`; DIDs CRUD; minimal DestinationPicker (user + hangup + external); `sp-from-trunk-*`, `sp-did-exists`, `sp-inbound`.
|
||||
✅ A call from a mobile phone to the DID rings 101. **Record in 06 §3 which header carried the DID.** Golden case `voipms-basic` passes.
|
||||
|
||||
**M3-T3 Outbound routes + caller ID**
|
||||
📖 03 §6.12, 06 §4
|
||||
🔨 Outbound routes CRUD with reorder + test box; class check; caller ID priority; trunk failover; channel limit; default routes created when the first trunk is created.
|
||||
✅ 101 dials a mobile number → it rings, and the caller ID shown is the DID. An extension with class `local` dialing a national number hears `ss-noservice`. `011…` is denied while the route is disabled. **Record in 06 §4 whether per-call caller ID is honored.**
|
||||
|
||||
**M3-T4 Emergency**
|
||||
📖 03 §6.14, 06 §6, 04 §5.3
|
||||
🔨 Emergency contexts, `SpEmergency` handler (email, ring-out, banner, `emergency_events`), test-notification button, dashboard warnings.
|
||||
✅ Unit/golden tests show 911 bypasses class/limits. The test-notification button sends the email and rings the notify extension. **Do not dial 911.**
|
||||
|
||||
**M3-T5 Setup wizard (steps 1–6, 9, 10)**
|
||||
📖 05 §2.1
|
||||
✅ A fresh install can be taken to "inbound and outbound calls work" using only the wizard.
|
||||
|
||||
---
|
||||
|
||||
## M4 — Voicemail
|
||||
|
||||
**M4-T1** Voicemail config (03 §7), turn on ARG3 in `sp-sub-user`, `*97`/`*98`, MWI via `mailboxes`.
|
||||
✅ An unanswered call to 101 leaves a message, the desk phone MWI light turns on, and `*97` plays it.
|
||||
|
||||
**M4-T2** Watcher + DB index + email (04 §5.6, §10), SMTP settings + test.
|
||||
✅ The voicemail email arrives within 10 s with a playable attachment.
|
||||
|
||||
**M4-T3** User portal: Home + Voicemail page + greetings (05 §4), API operations with renumbering.
|
||||
✅ Mark read/delete in the web UI updates the phone's MWI light within 5 s. A greeting recorded in the browser plays to callers.
|
||||
|
||||
---
|
||||
|
||||
## M5 — Browser softphone
|
||||
|
||||
**M5-T1** `/api/me/webrtc`, reverse-proxy `/ws` (Caddy + nginx example), engine (05 §5.2).
|
||||
✅ The browser registers (`pjsip show contacts` shows 101-web), and calls between browser and desk phone have two-way audio, both on the LAN and from a phone hotspot outside the LAN.
|
||||
|
||||
**M5-T2** Dock UI, incoming dialog, hold/mute/DTMF, blind + attended transfer, one-tab leader election, audio device selection, notifications.
|
||||
✅ A manual test script in `apps/web/TESTING.md` (12 steps) passes in Chrome and Firefox.
|
||||
|
||||
**M5-T3** Presence panel + click-to-call (`/api/me/click-to-call`).
|
||||
✅ Presence dots change in < 1 s. Click-to-call rings the user's desk phone first.
|
||||
|
||||
---
|
||||
|
||||
## M6 — Call flows
|
||||
|
||||
**M6-T1** Sounds: upload, convert, play, delete with references (04 §6, 05 §2 Sounds).
|
||||
**M6-T2** Ring groups (03 §6.7) + UI.
|
||||
**M6-T3** IVR (03 §6.9) + keypad-grid UI.
|
||||
**M6-T4** Time conditions + holidays + override codes (03 §6.10, §6.13) + weekly grid UI + US federal holiday generator.
|
||||
**M6-T5** Announcements (03 §6.11).
|
||||
**M6-T6** Full DestinationPicker (all types) used everywhere.
|
||||
**M6-T7** Feature codes DND/CF/pickup/echo/say-ext (03 §6.13) + `SpToggle` handling + forward validation (04 §5.3) + `/me/settings` forwarding UI.
|
||||
**M6-T8** Blocked numbers (AstDB sync, 04 §5.5).
|
||||
✅ **Milestone check:** golden cases `ringgroups`, `ivr-business-hours`, `holidays` pass. Manual scenario: DID → office hours (open) → IVR "press 1 sales (ring group 500 ringall), 2 support (user 102)". Outside hours → voicemail of 101. `*311` forces open. A blocked number is rejected.
|
||||
|
||||
---
|
||||
|
||||
## M7 — History, recordings, live dashboard
|
||||
|
||||
**M7-T1** CDR ingestion + aggregation (04 §5.4) with recorded fixtures for 8 scenarios.
|
||||
**M7-T2** Call history pages (admin + `/me/calls`) + CSV export.
|
||||
**M7-T3** Recording (03 `sp-sub-record`, record modes, DID record flag, announcement) + recordings page + retention job.
|
||||
**M7-T4** Full live state (calls, parking, queues placeholders) + dashboard live call list + admin hangup/transfer.
|
||||
✅ Each fixture scenario produces the expected `calls` row. A recorded call is playable from history. The live list matches `core show channels` during 3 concurrent calls.
|
||||
|
||||
---
|
||||
|
||||
## M8 — Queues, park, pickup, conference, paging, MOH
|
||||
|
||||
**M8-T1** Queues (03 §6.8) + members + pause via `*45`/UI + queue live tiles + `GET /:id/stats`.
|
||||
**M8-T2** Parking (`res_parking.conf`, `sp-parkedcalls` include) + parked calls in the live UI + BLF hints for slots.
|
||||
**M8-T3** Conferences with PIN/admin PIN (03 §6.11).
|
||||
**M8-T4** Paging/intercom (`sp-page-*`, `*80`) + auto-answer test on a real Yealink or Grandstream.
|
||||
**M8-T5** MOH classes (03 §8).
|
||||
✅ Golden `queue` case passes. Scenario: 3 callers wait in a queue with 2 agents. One agent pauses with `*45550`. The caller who waits past `max_wait_seconds` goes to the timeout destination. A parked call on 701 can be picked up from another phone.
|
||||
|
||||
---
|
||||
|
||||
## M9 — SMS
|
||||
|
||||
**M9-T1** voip.ms API client (06 §5.1) + DID import + server list.
|
||||
**M9-T2** Inbound hook (06 §5.2) with a captured real fixture.
|
||||
**M9-T3** SMS UI (admin + `/me/sms`), unread badges, `did_sms_users`.
|
||||
✅ Two-way SMS with a mobile phone works. Messages show in < 2 s.
|
||||
|
||||
---
|
||||
|
||||
## M10 — Provisioning and phone apps
|
||||
|
||||
**M10-T1** Devices CRUD + `/prov` endpoints + auth/rate-limit (07 §2).
|
||||
**M10-T2** Yealink + Grandstream templates + phonebook XML + timezone table (07 §3).
|
||||
**M10-T3** Linphone QR provisioning (07 §5) + "Set up a phone app" page.
|
||||
**M10-T4** Spy/whisper/barge (04 §7.3).
|
||||
✅ A factory-reset Yealink pointed at the URL registers with the right name and BLF keys. Linphone on a phone registers by scanning the QR.
|
||||
|
||||
---
|
||||
|
||||
## M11 — Hardening, ops, docs
|
||||
|
||||
**M11-T1** Security headers, phone ACL setting (08 §2.2), TOTP for admins, PIN rules.
|
||||
**M11-T2** TLS SIP transport (cert path, compose volume, trunk TLS + SRTP option for voip.ms).
|
||||
**M11-T3** Backups + restore script (04 §8.1). Test restore on a second VM.
|
||||
**M11-T4** Jobs: publicIpWatch, trunkWatch, diskWatch, retention (04 §8).
|
||||
**M11-T5** Webhooks + API tokens (04 §9, §2.3).
|
||||
**M11-T6** Reports page (05 §2).
|
||||
**M11-T7** `docs/admin-guide/`: install, voip.ms setup with screenshots, NAT/SIP ALG, firewall + fail2ban/CrowdSec, E911 duties, recording law notice, troubleshooting (08 §3.3), updating, backup/restore.
|
||||
**M11-T8** Security review: run through 08 §1 line by line, and get a review from a separate agent that didn't write the code. Fix findings.
|
||||
✅ An outside tester follows only the admin guide and gets inbound/outbound calls working on a fresh VM.
|
||||
|
||||
---
|
||||
|
||||
## M12 — Nice to have (after v1.0)
|
||||
- Piper TTS for prompts (04 §6).
|
||||
- Record prompts by phone.
|
||||
- Wallboard page for queues.
|
||||
- Push gateway for mobile apps.
|
||||
- Polycom provisioning (07 §3.3).
|
||||
- Multi-tenant (big redesign; needs a decision record).
|
||||
|
||||
---
|
||||
|
||||
## Definition of done for v1.0
|
||||
All of M0–M11 done, plus: 7 days running on the owner's server handling real calls with
|
||||
no crash/restart of the api or Asterisk, and nightly backups restoring cleanly.
|
||||
Reference in new issue
Block a user