Files
selfphoneng/docs/spec/01-infrastructure.md
T
iamdoubz 547bb68a6f 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.
2026-10-17 14:11:09 -05:00

596 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.