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:
iamdoubz committed 2026-10-17 14:11:09 -05:00
commit 547bb68a6f
13 files changed
+3804

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# AGENTS.md — Rules for AI coding agents working on SelfPhoneNG
Read this file first, every time. Then read `docs/spec/00-overview.md`.
Then read ONLY the spec files your task points to.
## What this project is
A fully open source, self-hosted business phone system (PBX) with a web admin
panel, a browser softphone, voicemail, IVRs, ring groups, queues and desk-phone
provisioning: the same kind of product as 3CX. The user brings their own SIP
trunk / phone number (the reference provider is **voip.ms**).
We do **not** write a SIP stack. **Asterisk** does all SIP and audio work. We
write the software around it: database, config generator, API, web UI.
## How the spec is organized
| File | What it covers | Read it when |
|---|---|---|
| `docs/spec/00-overview.md` | Goals, architecture, tech stack, repo layout, glossary, numbering plan | Always |
| `docs/spec/01-infrastructure.md` | Docker, Asterisk image, static Asterisk config, networking, NAT, reverse proxy, env vars | Working on Docker or Asterisk base config |
| `docs/spec/02-data-model.md` | Every database table and column | Touching the DB or any feature that stores data |
| `docs/spec/03-config-generator.md` | How DB rows become Asterisk config and dialplan (the most important file) | Working on call routing or any telephony feature |
| `docs/spec/04-backend-api.md` | API server, auth, AMI client, live state, background jobs, REST endpoints, WebSocket events | Working on the backend |
| `docs/spec/05-web-ui.md` | Admin panel, user portal, browser softphone | Working on the frontend |
| `docs/spec/06-voipms.md` | voip.ms trunk setup, inbound DID handling, SMS, E911 | Working on trunks, SMS, or emergency calling |
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
## Hard rules
1. **Do one task at a time.** Tasks have IDs like `M3-T2` in `09-roadmap.md`.
Do not start the next task until the current one meets every acceptance criterion.
2. **Follow the file paths in the spec exactly.** Do not invent new top-level folders.
3. **Do not add dependencies** that are not listed in `00-overview.md` §4 without
writing the reason in `docs/decisions/NNNN-title.md` (one short paragraph).
4. **Everything must be open source.** Never add a closed-source or binary-only
component. In particular: do NOT install Asterisk's binary `codec_opus` or `codec_g729` modules.
5. **Never hand-edit generated Asterisk files** (`/etc/asterisk/selfphoneng/*.conf`).
Change the generator in `apps/api/src/generator/` instead.
6. **Every generator change needs a golden-file test** (see `03-config-generator.md` §12).
7. **Never log secrets** (SIP passwords, API keys, session tokens, voicemail PINs).
8. **Validate all API input with zod.** Never build SQL by string concatenation.
Never put raw user input into dialplan text without running it through the
sanitizers in `03-config-generator.md` §2.
9. **If the spec is unclear**, choose the simplest option that fits the spec,
leave a comment `// TODO(spec): <question>` at that spot, and list the question
in your final summary. Do not stop working to wait for an answer.
10. **Asterisk dialplan snippets in this spec are reference code.** They are
believed correct for Asterisk 24 but must be proven by the tests named in
each task. If a snippet is wrong, fix it, and update the spec file in the same commit.
## Commands (once M0 is done)
```bash
pnpm install # install everything
pnpm dev # api + web in watch mode (needs docker compose up -d postgres asterisk)
pnpm test # all unit tests (vitest)
pnpm lint # eslint + prettier check
pnpm typecheck # tsc --noEmit in every package
pnpm db:migrate # run drizzle migrations
docker compose up -d # full stack
```
Before you say a task is done, `pnpm lint && pnpm typecheck && pnpm test` must all pass.
## Commit style
`<area>: <what changed>`, e.g. `generator: add ring group hunt strategy`.
One task per commit (or a short series), with the task ID in the body.
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md — SelfPhoneNG Project Guide
This file tells Claude how to work on this repository. Read it fully before
making changes. It encodes hard-won conventions, security invariants, and
bugs already fixed — do not reintroduce them.
---
## MEMANTO - Your Active Memory Companion
**MEMANTO is not a passive store. It is an active companion agent that works alongside you.**
Don't treat MEMANTO like a static blob you query once and forget. It's a teammate you keep
talking to, every preference, decision, and correction flows through it. MEMANTO remembers,
recalls, and answers so you hold context across sessions, honor prior decisions, and avoid
repeating mistakes the user already corrected.
Every memory operation in this session goes through MEMANTO. There is no exception.
> **CRITICAL**: All `memanto` commands are **shell commands**. Always run them using the Bash tool.
> Never simulate, describe, or "pretend to call" them. If you cannot run the shell, say so explicitly instead of inventing memory state.
### NON-NEGOTIABLE RULES
These are not suggestions. Follow each one on every turn.
0. **Activate the `selfphoneng` agent at the start of every session, before any memory op.** Run
`memanto agent activate selfphoneng` first thing. This machine hosts multiple projects and the
session-start sync may activate a *different* project's agent (e.g. `gmmff`), so the
auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as selfphoneng context
until you've activated `selfphoneng` and re-synced. Confirm with `memanto agent list` (the
active one is marked). All `recall`/`remember`/`answer` calls read and write the *active*
agent's store, so getting this wrong silently pollutes or mis-reads another project's memory.
---
+14
View File
@@ -0,0 +1,14 @@
# SelfPhoneNG
A fully open source, self-hosted business phone system: extensions, desk phones and a
browser softphone, voicemail to email, IVRs, ring groups, queues, office hours, call
recording, live dashboard, SMS and phone provisioning. Bring your own SIP trunk and
numbers (reference provider: voip.ms). Built on Asterisk 24 LTS + TypeScript + PostgreSQL 18.
**Status:** specification stage. Nothing is built yet.
- Start here if you are writing code (human or AI): [`AGENTS.md`](AGENTS.md)
- The full spec: [`docs/spec/`](docs/spec/00-overview.md)
- Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md)
License: AGPL-3.0-or-later.
+383
View File
@@ -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.
+595
View File
@@ -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.
+552
View File
@@ -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 |
+937
View File
@@ -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.
+417
View File
@@ -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).
+153
View File
@@ -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.
+139
View File
@@ -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.
+181
View File
@@ -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">&lt;sip:{host}:{sipPort};transport={udp|tcp|tls}&gt;</entry>
<entry name="reg_identity">"{name}" &lt;sip:{ext}@{host}&gt;</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.
+88
View File
@@ -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".
+240
View File
@@ -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.