docs: admin guide (install, voip.ms, network, E911, troubleshooting, updating)
ci / check (push) Successful in 56s
ci / images (push) Successful in 23s

docs/admin-guide/: install and the setup wizard, voip.ms with screenshots, NAT and SIP
ALG, firewall (ufw, nftables limited to the voip.ms POP), fail2ban and CrowdSec, E911
duties, recording consent, troubleshooting (08 §3.3), updating, backup and restore,
monitoring and log rotation. scripts/update.sh as 08 §3.1 describes. The e2e checks
fail2ban's and CrowdSec's stock Asterisk rules against a real failed sign-in, takes the
screenshots and checks every link and anchor.

Task: M11-T7
This commit is contained in:
iamdoubz committed 2026-10-20 08:57:54 -05:00
1 parent 29ee4ba105
commit 324f9d9fe2
17 files changed
+1002 -1

No files matched your search

+2 -1
View File
@@ -5,8 +5,9 @@ browser softphone, voicemail to email, IVRs, ring groups, queues, office hours,
recording, live dashboard, SMS and phone provisioning. Bring your own SIP trunk and 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. numbers (reference provider: voip.ms). Built on Asterisk 24 LTS + TypeScript + PostgreSQL 18.
**Status:** specification stage. Nothing is built yet. **Status:** pre-1.0, not yet tested by an outside installer.
- Installing and running it: the [admin guide](docs/admin-guide/README.md)
- Start here if you are writing code (human or AI): [`AGENTS.md`](AGENTS.md) - Start here if you are writing code (human or AI): [`AGENTS.md`](AGENTS.md)
- The full spec: [`docs/spec/`](docs/spec/00-overview.md) - The full spec: [`docs/spec/`](docs/spec/00-overview.md)
- Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md) - Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md)
+114
View File
@@ -0,0 +1,114 @@
# SelfPhoneNG admin guide
This guide takes you from an empty server to working inbound and outbound calls, then covers
keeping the system safe, legal and up to date.
1. **Install** (this page)
2. [voip.ms setup](voipms.md): the sub-account, numbers and the trunk
3. [Network, firewall and attacks](network.md): NAT, SIP ALG, ports, firewall, fail2ban / CrowdSec
4. [Emergency calls and recording law](legal.md): E911 duties and call recording consent
5. [Troubleshooting](troubleshooting.md)
6. [Updating, backups and monitoring](maintenance.md)
---
## What you need
- A Linux server (a VM is fine) with a **public IP** or a router you can forward ports on.
A small office needs very little: 1–2 CPUs and 2 GB of memory is plenty.
- **Docker Engine with the compose plugin**: <https://docs.docker.com/engine/install/>.
Check with `docker compose version`.
- `git` and `openssl` (both are on almost every Linux install).
- A **DNS name** for the server, e.g. `pbx.example.com`, with an A record pointing at its public IP.
Browsers need HTTPS for the microphone, so the browser phone only works through this name.
- A **voip.ms account** with at least one phone number (DID). Other SIP providers work as a
"generic" trunk, but this guide uses voip.ms.
## Ports
Open these to the internet (on the server's firewall, and forwarded on your router if the
server is behind one; see [Network](network.md)):
| Port | Protocol | For |
|---|---|---|
| 443 | TCP | Web app, browser phone, phone setup files |
| 80 | TCP | Only with the bundled Caddy: getting the HTTPS certificate |
| 5060 | UDP (+TCP) | SIP: voip.ms and desk phones |
| 5061 | TCP | Only if you turn on SIP over TLS |
| 10000–20000 | UDP | Call audio (RTP) |
Everything else (the database, the api on 8080, Asterisk's 5038 and 8088) listens on
127.0.0.1 only. Don't open them.
## Install
```sh
git clone https://git.dou.bet/spng/selfphoneng.git
cd selfphoneng
./scripts/install.sh
```
The script:
1. Checks Docker is there.
2. Writes `.env` with random passwords and asks for:
- the **public host name** (`pbx.example.com`),
- the **first admin's email and password** (at least 10 characters).
3. Makes the `data/` folders.
4. Asks **"Use bundled Caddy for HTTPS?"**
- **Yes**: Caddy gets a Let's Encrypt certificate for your host name by itself. Ports 80 and
443 must reach the server and nothing else may use them.
- **No**: you already run a reverse proxy. Copy `deploy/nginx/selfphoneng.conf.example`,
put in your host name and certificate paths, and reload nginx. Any proxy works if it sends
`/ws` to `127.0.0.1:8088` (the browser phone's WebSocket) and everything else to
`127.0.0.1:8080`, with WebSocket upgrades allowed on both.
5. Builds and starts everything. The first build compiles Asterisk, so it takes a while.
When it's done, check the health URL on the server:
```sh
curl http://127.0.0.1:8080/healthz # {"ok":true}
```
> **Keep `.env` safe and never change `APP_SECRET`.** It encrypts the SIP and trunk passwords in
> the database, and backups can only be restored with it. Copy `.env` somewhere safe (a password
> manager) now.
## First sign-in: the setup wizard
Open `https://pbx.example.com/` and sign in with the admin email and password you gave the
script. You're asked to pick a new password, then the setup wizard walks you through:
| Step | What to do |
|---|---|
| Company | Name and time zone (office hours and reports use it). |
| Network | The **public IP** (Detect fills it in) and the **local networks**. Do the router steps shown there: SIP ALG off and the port forwards ([Network](network.md)). |
| Email | Your SMTP server, then **Send test**. Voicemail-to-email and alerts need it. |
| Trunk | Your voip.ms sub-account ([voip.ms setup](voipms.md)). Wait for **Registered**. |
| Phone numbers | Add your DIDs, or **Import numbers from voip.ms** if you gave API details. |
| Users | People and their extensions. |
| Main number | Where calls to your main number go. |
| Emergency | The **default emergency caller ID** (required once a trunk exists) and who's told when someone dials 911 ([E911](legal.md#emergency-calls-e911)). |
You can leave the wizard and come back; everything it sets is also in the admin pages.
Then:
- **Place a test call out.** From a desk phone, or from the browser phone (a user with an
extension signs in and calls from **My phone**), call your mobile.
- **Place a test call in.** From your mobile, call your main number.
If either fails, see [Troubleshooting](troubleshooting.md).
## Phones
- **Desk phones (Yealink, Grandstream, Polycom/Poly, Snom, Fanvil):** add them on **Phones** with
their MAC address. The page shows the setup URL and password to enter on the phone.
- **Any other SIP phone:** use the details on the user's **Credentials** tab.
- **Browser phone:** every user with an extension gets one on **My phone** after signing in. There
is nothing to set up.
## Turning on two-step sign-in
Admins can (and should) turn on two-step sign-in: header → **Account** → **Turn on two-step sign-in**. Keep
the recovery codes somewhere safe.
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+95
View File
@@ -0,0 +1,95 @@
# Emergency calls and recording law
> This page explains what SelfPhoneNG does and what the law expects of you as the operator of a
> phone system. It is **not legal advice**. Rules differ by country, state and type of business:
> check with counsel for your situation.
## Emergency calls (E911)
In the US, two federal rules apply to multi-line phone systems like this one:
- **Kari's Law** (47 CFR 9.16):
- anyone must be able to dial **911 directly**, with no prefix like 9 first;
- when someone does, the system must **notify** someone on site (front desk, security) where
that's set up.
- **RAY BAUM'S Act** (47 CFR 9.16(b)): a 911 call must carry a **dispatchable location**: the street
address plus floor or room where that's needed to find the caller.
### What SelfPhoneNG does
- **911 and 9911 always work** from every extension.
- This holds even if the extension may only call internally, is on do-not-disturb, or every
trunk is busy.
- Nothing you set can turn this off.
- **It tells people.** When someone dials 911, SelfPhoneNG:
- emails the addresses in **Settings → Emergency → Email these people**;
- rings the phones under **Ring these phones**;
- shows a red banner on every admin's dashboard;
- fires the `emergency.call` webhook.
- **It sends a caller ID that has an address.**
- voip.ms gives the 911 operator the street address **registered on the number used as caller ID**.
- That number is the extension's own **Emergency caller ID** (user → **Emergency** tab), or else
the company's **Default emergency caller ID** (Settings → Emergency).
![Settings → Emergency](img/settings-emergency.png)
### What you must do
1. **Register an E911 address at voip.ms** for every number used as an emergency caller ID (DID →
e911). This address is what the 911 operator gets.
2. **Pick a default emergency caller ID** on Settings → Emergency. The dashboard stays red until you
do, and until that number's **E911 address** note is filled in on Phone numbers (a reminder that
you registered it).
3. **Give people in other places their own number.**
- Anyone who works somewhere else (another building, at home) needs an emergency caller ID whose
E911 address is *where they are*.
- Otherwise 911 is sent to the main office. Remote workers without such a number should call
911 from a mobile phone, and must be told so.
4. **Fill in each person's Location** (e.g. "2nd floor, room 210") on their **Emergency** tab.
- It goes into the notification email, so whoever is on site can meet the responders.
- The 911 operator **does not** receive it. voip.ms only passes the registered street address.
If a room number is needed to find someone in your building, register it in the voip.ms
address itself.
5. **Test the notifications, never 911 itself.**
- **Settings → Emergency → Test notifications** sends the email, rings the phones and shows the
banner, all marked TEST. It dials nothing.
- **Never place a real call to 911 to test.**
6. **Canada:** 911 works the same way through voip.ms. Check voip.ms's E911 documentation for
Canadian rules.
## Call recording
Recording calls without the right consent can be a crime. In the US:
- Federal law and most states are **one-party consent**: one person on the call (you) agreeing
is enough.
- Several states need **everyone on the call** to agree, for example California, Florida,
Illinois, Maryland, Massachusetts, Montana, New Hampshire, Pennsylvania and Washington. A call
between states can bring the stricter rule in.
- Other countries have their own rules; in the EU and UK, recordings are also personal data under
the GDPR.
### What SelfPhoneNG does
- **Nothing is recorded unless you turn it on.** You can record:
- every call to a number (**Phone numbers → Record every call**);
- a queue's calls (the queue's **Record calls** switch).
The setup wizard never turns recording on.
- **Settings → Recording → "Notice played before a recorded call"** plays a sound you upload
(e.g. "This call may be recorded for quality and training") to everyone who calls a number set
to record, before anything else. Upload it on **Sounds & music**.
- **Queue recordings play no notice of their own.** If you record a queue, send its callers
through a number that plays the notice, or through an announcement that says it
(**Sounds & music → Announcements**: play a sound, then go to the queue).
- **Retention.** Recordings and voicemail are deleted after the number of days you set there.
0 keeps them.
![Settings → Recording](img/settings-recording.png)
### What you should do
- **Set the notice** whenever anything is recorded, even in one-party states. It's the simplest
way to have consent from everyone.
- **Tell your staff** their calls are recorded (a written policy).
- **Keep recordings no longer than you need them**, and limit who can sign in as an admin:
admins can play every recording.
+140
View File
@@ -0,0 +1,140 @@
# Updating, backups and monitoring
## Updating SelfPhoneNG
When nobody is on a call, run:
```sh
./scripts/update.sh
```
It:
1. pulls the latest code (`git pull --ff-only`);
2. pulls newer PostgreSQL and Caddy images;
3. rebuilds the api and Asterisk images;
4. restarts what changed.
Database changes apply by themselves when the api starts. **Calls in progress drop** if Asterisk is
rebuilt. Make a backup first (**Settings → Backups → Back up now**).
If `git pull` refuses because you changed files in the folder, put your changes somewhere else.
Settings belong in the web app and `.env`, not in the code.
### Asterisk security releases
SelfPhoneNG builds Asterisk from source, checking the download against Asterisk's published
checksum. New Asterisk versions come with SelfPhoneNG updates.
To take an Asterisk fix before that, build the version you need yourself. Release numbers are
listed at <https://downloads.asterisk.org/pub/telephony/asterisk/releases/>.
```sh
docker compose build --build-arg ASTERISK_VERSION=24.0.1 asterisk
docker compose up -d asterisk
docker compose exec asterisk asterisk -rx "core show version"
```
The next `update.sh` goes back to the version the code names.
## Backups
**Settings → Backups:**
- **Back up every day** at a time you choose, keeping the newest few.
- **Back up now** makes one right away.
- The list downloads any of them.
![Settings → Backups](img/settings-backups.png)
A backup (`data/backups/selfphoneng-YYYYMMDD-HHMM.tar.gz`) holds:
- the database: users, extensions, numbers, settings, call history;
- voicemail;
- your sounds and music on hold;
- call recordings, if you tick **"Include call recordings"** (they can be large).
If a daily backup fails, the admins get an email, and the dashboard warns while today's backup is
missing.
**Two things a backup doesn't hold:**
1. **`.env`.** Its `APP_SECRET` encrypts the SIP and trunk passwords inside the backup.
- **A backup can only be restored with the `.env` it was made with**, so keep a copy of `.env`
in a password manager.
- The backup itself holds no plain passwords.
2. **A copy anywhere else.** Backups sit on the same disk as the system. Copy them off the server
regularly, e.g. with a nightly cron job:
`rsync -a /opt/selfphoneng/data/backups/ backup-host:selfphoneng/`.
## Restoring
```sh
./scripts/restore.sh /path/to/selfphoneng-20261019-0300.tar.gz
```
The script:
- refuses if `.env`'s `APP_SECRET` isn't the one the backup was made with;
- shows what the backup holds and asks you to type `yes`;
- replaces the database and those folders, then starts everything again.
Asterisk's config is rebuilt from the restored database. Sign in with the passwords from the
backup.
### Moving to a new server
1. Clone SelfPhoneNG on the new server.
2. **Copy your old `.env`** into the folder.
3. Run `./scripts/install.sh`. It keeps the existing `.env`.
4. Run `./scripts/restore.sh <backup>`.
5. If the public IP changed: update it on **Settings → Network**, change your DNS record and any
IP-based settings at voip.ms (IP authentication, API whitelist), then
`docker compose restart asterisk`.
6. Desk phones fetch their setup from the host name, so they follow the DNS change.
## Monitoring
**Emails to admins.** SelfPhoneNG emails every admin when:
- a trunk has been down for 5 minutes (one email per outage);
- the disk is under 10% free (once a day);
- the public IP changed (with the re-check turned on);
- a daily backup failed.
These need **Settings → Email** working.
**Health check** for an uptime monitor (Uptime Kuma or similar), on the server itself:
```sh
curl http://127.0.0.1:8080/healthz # 200 {"ok":true}, or 503 when the database is down
```
From outside, monitor `https://pbx.example.com/healthz`.
**The dashboard** shows trunks, active calls, queues and warnings (emergency setup, certificate,
backups, restart needed) live.
### Keeping logs small
Asterisk's log files in `data/asterisk-logs/` grow forever. Rotate them with logrotate. Create
`/etc/logrotate.d/selfphoneng`:
```
/opt/selfphoneng/data/asterisk-logs/*.log {
weekly
rotate 8
compress
delaycompress
missingok
notifempty
copytruncate
}
```
`copytruncate` lets Asterisk keep writing to the same file. fail2ban and CrowdSec notice the file
shrinking.
The api's logs are Docker's. Limit them in `/etc/docker/daemon.json` with
`"log-opts": {"max-size": "20m", "max-file": "5"}`, restart Docker, then
`docker compose up -d --force-recreate` (the limit only applies to new containers).
+164
View File
@@ -0,0 +1,164 @@
# Network, firewall and attacks
Most call problems come from the network between the server and the internet, and any PBX on the
internet is attacked within hours of going up. This page covers both.
## NAT: a server behind a router
Calls carry the server's address inside the SIP messages. Behind a router (NAT), the server has to
know its **public IP** and which networks are **local**, or the other side sends the audio to
the wrong place.
1. **Turn off SIP ALG** (also called "SIP helper" or "SIP fixup") on every router between the
server and the internet. It rewrites SIP messages and is the classic cause of one-way audio and
calls dropping after 30 seconds. Where to find it depends on the router; search for
"<router model> disable SIP ALG".
2. **Forward ports** to the server's LAN IP:
- UDP 5060 (SIP);
- UDP 10000–20000 (audio);
- TCP 443 (and 80 with the bundled Caddy);
- TCP 5061 only if you use SIP over TLS.
3. **Double NAT** (the ISP's box in front of your own router): forward the ports on both, or put
your router in the ISP box's DMZ / bridge mode.
4. **Public IP and local networks.**
- The wizard's Network step sets both (go back to it at `/setup?step=3`).
- The public IP is also on **Settings → Network**: **Detect** fills it in.
- Local networks are the networks that reach the server without NAT. The default
(`192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12`) fits most offices.
5. **Dynamic IP?** Tick **"Re-check the public IP every 5 minutes"** on Settings → Network.
- When the IP changes, SelfPhoneNG updates its settings and emails the admins
("Public IP changed to …").
- **Then restart telephony** so Asterisk uses the new address: on the server, run
`docker compose restart asterisk` (calls in progress drop). The dashboard shows "SIP port or
transport changes need a telephony restart" until you do.
![Settings → Network](img/settings-network.png)
## Firewall
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
host's network, so the host firewall (ufw, firewalld, nftables) applies to them directly.
With **ufw**:
```sh
ufw allow OpenSSH
ufw allow 443/tcp
ufw allow 80/tcp # only with the bundled Caddy
ufw allow 5060/udp
ufw allow 10000:20000/udp
ufw enable
```
### Only voip.ms may reach SIP (no remote phones)
If every desk phone is in the office, nothing else on the internet needs port 5060. Limit it to
voip.ms and your local networks.
1. **Look up your POP's address** (the server you put on the trunk):
```sh
dig +short chicago1.voip.ms
```
2. **Save this as `/etc/nftables.d/selfphoneng.nft`**, with that address in `sip_ok`. Add any
other office that has phones (the same networks as Settings → Security → "Other networks
allowed").
```nft
table inet selfphoneng {
set sip_ok {
type ipv4_addr
flags interval
elements = { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 208.100.60.8 }
}
chain input {
type filter hook input priority 0; policy accept;
meta l4proto { tcp, udp } th dport { 5060, 5061 } ip saddr != @sip_ok drop
meta nfproto ipv6 meta l4proto { tcp, udp } th dport { 5060, 5061 } drop
}
}
```
3. **Load it:** `nft -f /etc/nftables.d/selfphoneng.nft`. To keep it after a reboot, include it
from `/etc/nftables.conf`.
This table only drops SIP from elsewhere and leaves your other rules alone. voip.ms can change
its addresses: if the trunk stops registering one day, run `dig` again.
## Desk phones only from the office
**Settings → Security → "Only accept desk phones from the networks below"** makes Asterisk refuse
desk phones signing in from anywhere but your local networks and the ones you list. A stolen phone
password is then useless from outside.
- Browser phones aren't affected: they come in through HTTPS.
- **"Only to phones on the local networks"** does the same for the phone setup files.
## Blocking password guessers: fail2ban or CrowdSec
Asterisk writes every failed sign-in to `data/asterisk-logs/security.log`. Use one of the two tools
below; both read that file on the host.
### fail2ban
Install it (`apt install fail2ban`) and create `/etc/fail2ban/jail.d/selfphoneng.conf`. Change
the path to where you cloned SelfPhoneNG:
```ini
[asterisk]
enabled = true
logpath = /opt/selfphoneng/data/asterisk-logs/security.log
maxretry = 5
findtime = 10m
bantime = 1d
# never ban your own offices
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
```
Then:
```sh
systemctl restart fail2ban
fail2ban-client status asterisk
```
The stock `asterisk` filter already understands the log. We test it against real failed
sign-ins (`scripts/e2e/m11-t7.mjs`).
### CrowdSec
Install CrowdSec and a bouncer (<https://docs.crowdsec.net/>), then:
```sh
cscli collections install crowdsecurity/asterisk
```
Add `/etc/crowdsec/acquis.d/selfphoneng.yaml`:
```yaml
filenames:
- /opt/selfphoneng/data/asterisk-logs/security.log
labels:
type: asterisk
```
and run `systemctl reload crowdsec`.
## Web sign-in attacks
The web sign-in is rate limited. Failed attempts are written to the api's log as
`"event":"auth.failed"` with the `ip`, so you can feed them to fail2ban or CrowdSec too
(`docker compose logs api`). Turn on two-step sign-in for every admin.
## Toll fraud
Someone with a stolen SIP password calling expensive international numbers is the attack that costs
real money. Keep:
- international calling **off** at voip.ms and in **Outbound rules** unless you need it;
- the voip.ms **balance alerts and spending limits** on;
- the **desk phone network limit** above on if your phones are all in the office.
Never reuse a SIP password. SelfPhoneNG generates long random ones; phones get them from the
setup files.
+152
View File
@@ -0,0 +1,152 @@
# Troubleshooting
Run the commands on this page on the server, from the folder you cloned SelfPhoneNG into.
## Looking inside Asterisk
Ask Asterisk directly:
```sh
docker compose exec asterisk asterisk -rx "pjsip show registrations" # the trunk: Registered?
docker compose exec asterisk asterisk -rx "pjsip show endpoints" # phones and trunks
docker compose exec asterisk asterisk -rx "pjsip show contacts" # who is signed in, from where
docker compose exec asterisk asterisk -rx "core show channels" # calls right now
docker compose exec asterisk asterisk -rx "queue show" # queues and agents
```
### Seeing the SIP messages themselves
Open Asterisk's console and turn on the SIP logger:
```sh
docker compose exec asterisk asterisk -rvvv
pjsip set logger on
```
Make the failing call and read what scrolls by. To stop:
```
pjsip set logger off
exit
```
The logger prints passwords' hashes and phone numbers; don't paste it publicly without editing.
### Logs
| What | Where |
|---|---|
| Asterisk notices, warnings, errors | `data/asterisk-logs/messages.log` |
| Failed sign-ins (fail2ban reads this) | `data/asterisk-logs/security.log` |
| The api (web app, config changes, emails) | `docker compose logs api` |
| Everything, live | `docker compose logs -f` |
## One-way audio, or no audio
The call connects but one side (or both) hears nothing. It's almost always NAT
([Network](network.md#nat-a-server-behind-a-router)).
1. **SIP ALG** must be off on every router in the path. This is the first suspect.
2. **Public IP:** Settings → Network must show the server's current public IP. If it changed,
update it and restart telephony (`docker compose restart asterisk`).
3. **Audio ports:** UDP 10000–20000 must be forwarded to the server and open on its firewall.
4. **Local networks** (setup wizard, Network step) must list your office networks, and only them.
5. **Calls drop after about 30 seconds:** same causes. The answer never reaches the other side,
so it gives up.
## The trunk won't register
Trunks shows **Rejected** or **Not registered**.
- **Rejected:** voip.ms said no.
- The username must be the sub-account (`123456_pbx`) and the password its password, not your
voip.ms portal password.
- Retype the password on the trunk and save.
- **Not registered / Unreachable:** Asterisk gets no answer.
- Check the server name (`chicago1.voip.ms`, no `sip:` or port).
- Check the server can reach it: `ping chicago1.voip.ms`.
- Check outbound UDP 5060 isn't blocked by a firewall.
- If you limited SIP to voip.ms with nftables, the POP's address may have changed
([Firewall](network.md#only-voipms-may-reach-sip-no-remote-phones)).
- **IP authentication:** there's nothing to register.
- The trunk shows **Reachable** when voip.ms answers.
- The sub-account's allowed IP must be your server's current public IP.
- **TLS:**
- TLS must be turned on in Settings → Security, with a valid certificate.
- Encryption must also be on at the voip.ms sub-account.
- Port 5061.
Then run `pjsip show registrations`, and use the SIP logger to see voip.ms's answer.
## Incoming calls don't arrive
The trunk is **Registered** and outgoing calls work, but calls to your number fail or reach the
wrong place.
1. **At voip.ms** (DID Numbers → Manage DIDs), the number must be routed to **SIP/IAX → your
sub-account**, on the **same POP** the trunk uses. A different POP is the most common cause.
2. **In SelfPhoneNG**, the number must be on **Phone numbers** with a destination. Imported numbers
hang up until you route them.
3. **Office hours or a holiday** may be sending the call elsewhere: check the number's schedule.
4. Still nothing? Run the SIP logger, call from your mobile, and check an INVITE arrives.
- **No INVITE:** the problem is at voip.ms or the firewall.
- **An INVITE, then an error:** see what Asterisk replied.
## The browser phone won't connect
On **My phone**, the phone stays "connecting" or calls fail.
- **Use the address with HTTPS and the real host name.** Browsers block the microphone on plain
HTTP or an IP address.
- **Microphone permission:** the browser must be allowed to use it. Look for the camera/mic icon
in the address bar.
- **The reverse proxy must pass `/ws`** to `127.0.0.1:8088` with WebSocket upgrades.
- The bundled Caddy and `deploy/nginx/selfphoneng.conf.example` both do.
- On your own proxy, check: `curl -i https://pbx.example.com/ws` should answer
`426 Upgrade Required` with `Server: Asterisk`, not 404 or the web app.
- **Certificate:** the browser must trust the HTTPS certificate (no warning page).
- **No audio on browser calls:** see [one-way audio](#one-way-audio-or-no-audio). Browsers use the
same audio ports.
## Voicemail emails (or alerts) aren't sent
1. **Settings → Email → Send test** to your own address. The error shown says what the mail server
answered.
2. Check the server, port and security setting match your provider:
- 587 with STARTTLS;
- or 465 with TLS from the start.
Many providers need an app password.
3. The user's email address must be right, and voicemail-to-email on for them.
4. The api's log has every send error: `docker compose logs api | grep -i mail`.
## "SIP port or transport changes need a telephony restart"
Changes to the public IP, SIP ports or TLS only take effect when Asterisk restarts. Run this when
nobody is on a call:
```sh
docker compose restart asterisk
```
The banner goes away a few seconds after Asterisk is back.
## Locked out
- **Forgot the admin password:** another admin can reset it on **Users & Extensions**.
- **Lost the phone with the two-step codes:** use a recovery code, or have another admin turn
two-step sign-in off for you (Users & Extensions → the user → turn off two-step sign-in).
- **The only admin is locked out:** keep two admins so this can't happen. If it does, the api
makes a new admin from `.env` when no admin is left:
1. Make the locked-out account a plain user:
```sh
docker compose exec postgres psql -U selfphoneng \
-c "update users set role = 'user' where email = 'you@example.com'"
```
2. In `.env`, set `INITIAL_ADMIN_EMAIL` to an email **no account uses yet** and
`INITIAL_ADMIN_PASSWORD` to a temporary password.
3. Run `docker compose up -d --force-recreate api`.
4. Sign in with the new email; you're asked to change the password.
5. On **Users & Extensions**, make your old account an admin again, reset its password (and
two-step sign-in), sign in with it, and delete the temporary admin.
+104
View File
@@ -0,0 +1,104 @@
# voip.ms setup
SelfPhoneNG connects to voip.ms through a **sub-account**. voip.ms changes its portal now and
then; if a menu name below doesn't match, look for the closest one.
## 1. In the voip.ms portal
1. **Create a sub-account** (Main Menu → Sub Accounts → Create Sub Account). Use one sub-account
per phone system, e.g. `123456_pbx`.
- **Authentication type:** User/Password. If your server has a fixed public IP, IP
authentication works too.
- **Password:** long and random. You'll paste it into SelfPhoneNG, not type it into phones.
- **Device type:** Asterisk, IP PBX, Gateway or VoIP Switch.
- **Allowed codecs:** G.711u (ulaw). Others are optional.
- **DTMF mode:** RFC 2833.
- **NAT:** Yes.
- **CallerID number:** your main number.
- **International calling:** leave it **off** unless you need it. It's your best protection
against toll fraud if a password ever leaks.
- **Encrypted SIP traffic (TLS/SRTP):** optional; see [SIP over TLS](#sip-over-tls).
2. **Pick a POP server** near the PBX, e.g. `chicago1.voip.ms`. Write it down: the trunk and every
number must use the same one.
3. **Point each number at the sub-account** (DID Numbers → Manage DIDs → edit the number):
- **Routing:** SIP/IAX → your sub-account.
- **POP:** the **same server** as above. A different POP is the most common reason incoming
calls don't arrive.
4. **E911:** register a street address on every number you'll use as an emergency caller ID
(DID → e911). Read [Emergency calls](legal.md#emergency-calls-e911) first.
5. **Balance protection:** set a low-balance alert and the daily or per-call spending limits voip.ms
offers, so a stolen password can't run up a big bill.
6. **(Optional) API access**, for importing your numbers and the server list: Main Menu → SOAP
and REST/JSON API. Enable it, set an API password and allow your server's public IP.
## 2. The trunk in SelfPhoneNG
**Trunks → New trunk** (or the wizard's Trunk step). The form has the same checklist built in.
![New trunk form with the voip.ms checklist open](img/voipms-trunk-form.png)
| Field | What to enter |
|---|---|
| Provider | voip.ms |
| Server | The POP you picked, e.g. `chicago1.voip.ms` |
| Sign-in | Username and password (registration); IP address only if you set the sub-account to IP authentication |
| Username | The sub-account, e.g. `123456_pbx` |
| Password | The sub-account's password |
| Transport / Port | UDP 5060 (TLS 5061 if you turned encryption on) |
| voip.ms API (optional) | Your voip.ms email and the API password. Fills the server list and lets you import numbers. |
Save, then:
- Within a few seconds the trunk shows **Registered**.
- **Test** checks that the server answers.
- **Rejected** means the username or password is wrong. **Not registered** means Asterisk can't
reach the server. See [Troubleshooting](troubleshooting.md#the-trunk-wont-register).
## 3. Phone numbers
On **Phone numbers**, either:
- use **Add number**: the 10-digit number, a label, and where its calls go; or
- on the trunk form, use **Import numbers from voip.ms** (needs the API details) and route each
number afterwards. Imported numbers hang up until you route them.
![Add number form](img/add-number.png)
Each number can also:
- record every call (see [recording law](legal.md#call-recording));
- have **office hours**;
- have an **E911 address** note: a reminder of what you registered at voip.ms. 911 calls don't
use it; voip.ms sends the address registered there.
## 4. Outbound calls
The wizard creates the outbound rules for:
- North American numbers (sent as 11 digits);
- toll-free numbers;
- international numbers, created **turned off**.
Turn international on under **Outbound rules** only if you need it, and give it only to the users
who need it (the user's **Calling** tab).
911 always works from every extension, whatever its rules (see [E911](legal.md#emergency-calls-e911)).
## SIP over TLS
TLS encrypts the call setup between SelfPhoneNG and voip.ms; SRTP encrypts the audio.
1. **Get a certificate into `data/tls`.**
- With the bundled Caddy: run `./scripts/tls-cert.sh`. It copies Caddy's certificate for your
host name.
- With your own certbot certificate:
`./scripts/tls-cert.sh /etc/letsencrypt/live/pbx.example.com/fullchain.pem /etc/letsencrypt/live/pbx.example.com/privkey.pem`
2. **Keep it renewed.** Run the same command every day from cron; it does nothing when the
certificate hasn't changed, e.g.
`17 4 * * * cd /opt/selfphoneng && ./scripts/tls-cert.sh`.
3. **Turn it on.** **Settings → Security → Accept SIP over TLS**, then restart telephony
(`docker compose restart asterisk`), and open TCP 5061.
4. **Set the trunk and voip.ms.** Set the trunk's transport to TLS (port 5061) and turn on
encryption on the voip.ms sub-account.
The dashboard warns when the certificate is missing or expires within 14 days.
+2
View File
@@ -92,6 +92,8 @@ aren't filtered. The setting is on Settings → Security, with the provisioning
### 3.1 Install / update ### 3.1 Install / update
- `scripts/install.sh` (01 §1.3). - `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. - `scripts/update.sh`: `git pull --ff-only`, `docker compose build`, `docker compose up -d`. Migrations run automatically on api start. Print the new version.
As built (M11-T7): also `docker compose pull --ignore-buildable` (postgres, caddy) and `build --pull`;
adds `--profile caddy` when the bundled Caddy container is running. No CHANGELOG.md yet.
- Version: `package.json` version at the root, exposed at `/api/system/status`. Semver. `CHANGELOG.md` kept by hand. - 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. - Asterisk security releases: bump `ASTERISK_VERSION` in the Dockerfile and rebuild. The admin guide explains how.
+7
View File
@@ -251,3 +251,10 @@ answers. `/api/reports/summary` agrees with call history (today's bars, missed,
heatmap) and `/api/reports/queues` shows 560 with 1 offered, 1 answered, 100% in time. The Reports heatmap) and `/api/reports/queues` shows 560 with 1 offered, 1 answered, 100% in time. The Reports
page shows the same at 1280 px and 390 px, without scrolling sideways on the phone. About 3 page shows the same at 1280 px and 390 px, without scrolling sideways on the phone. About 3
minutes. minutes.
`m11-t7.mjs` (m2-live, after `m1-t4.mjs`) checks the admin guide (`docs/admin-guide/`). A baresip
phone signs in to extension 270 with the wrong password; fail2ban's stock `asterisk` filter
(`fail2ban-regex`, Debian's fail2ban) matches the resulting `security.log` lines with the phone's
IP, and CrowdSec's `crowdsecurity/asterisk` collection parses them (`cscli explain`). It then takes
the guide's screenshots into the given folder (run it with `docs/admin-guide/img`) and checks every
relative link, image and `#anchor` in the guide. About 4 minutes.
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T7: what the admin guide (docs/admin-guide/) claims, checked.
// - fail2ban: a phone with the wrong password registers against the real Asterisk; fail2ban's
// stock `asterisk` filter (fail2ban-regex, Debian's fail2ban) matches those lines of our
// security.log, with the phone's IP, and CrowdSec's crowdsecurity/asterisk collection parses it.
// - The guide's screenshots are taken from the running app into <imgDir>.
// - Every relative link and image in the guide points at a file that exists.
// Runs on m2-live, after m1-t4.mjs.
// Usage: node scripts/e2e/m11-t7.mjs <baseUrl> <adminEmail> <adminPassword> <imgDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch } from './cdp.mjs';
import { startPhones } from './phones.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PHONES = 'sp-live-phones-m11t7';
const GUIDE = resolve(dirname(fileURLToPath(import.meta.url)), '../../docs/admin-guide');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => spawnSync('docker', a, { encoding: 'utf8' });
const [base, email, pw, imgDir] = process.argv.slice(2);
const until = async (what, ms, fn) => {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
assert.ok(Date.now() < end, `timed out: ${what}`);
await sleep(500);
}
};
// 1. fail2ban on a real failed registration.
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const r = await fetch(`${base}/api/extensions`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: JSON.stringify({ number: '270', name: 'Front Desk' }),
});
assert.equal(r.status, 201, await r.text());
const securityLog = () => docker('exec', AST, 'cat', '/var/log/asterisk/security.log').stdout ?? '';
await until('extension 270 in Asterisk', 30_000, () =>
/270/.test(docker('exec', AST, 'asterisk', '-rx', 'pjsip show endpoints').stdout),
);
const phones = startPhones({
name: PHONES,
net: NET,
ast: AST,
phones: [{ ext: '270', password: 'not-the-password', answer: 'auto' }],
});
try {
await until('failed registrations in security.log', 180_000, () =>
/ChallengeResponseFailed|InvalidPassword/.test(securityLog()),
);
const phoneIp = docker(
'inspect',
'-f',
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',
PHONES,
).stdout.trim();
const dir = mkdtempSync(join(tmpdir(), 'sp-f2b-'));
writeFileSync(join(dir, 'security.log'), securityLog());
const f2b = docker(
'run',
'--rm',
'-v',
`${dir}:/logs`,
'debian:trixie-slim',
'sh',
'-c',
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends fail2ban >/dev/null 2>&1 && fail2ban-regex --print-all-matched /logs/security.log /etc/fail2ban/filter.d/asterisk.conf',
);
const out = f2b.stdout ?? '';
const matched = Number(/Lines: \d+ lines, \d+ ignored, (\d+) matched/.exec(out)?.[1] ?? 0);
assert.ok(matched >= 1, `fail2ban matched nothing:\n${out}${f2b.stderr}`);
assert.ok(out.includes(phoneIp), `fail2ban's matches don't name the phone (${phoneIp}):\n${out}`);
console.log(`fail2ban: the stock asterisk filter matched ${matched} lines for ${phoneIp}`);
// CrowdSec: the crowdsecurity/asterisk collection parses the same file.
const cs = 'sp-crowdsec-m11t7';
docker('rm', '-f', cs);
docker(
'run',
'-d',
'--name',
cs,
'-e',
'CROWDSEC_BYPASS_DB_VOLUME_CHECK=true',
'-e',
'COLLECTIONS=crowdsecurity/asterisk',
'-v',
`${dir}:/logs`,
'crowdsecurity/crowdsec:latest',
);
try {
await until(
'CrowdSec up',
120_000,
() => docker('exec', cs, 'cscli', 'lapi', 'status').status === 0,
);
const explain = docker(
'exec',
cs,
'cscli',
'explain',
'--file',
'/logs/security.log',
'--type',
'asterisk',
).stdout;
const parsed = explain.match(/🟢 crowdsecurity\/asterisk-logs/g)?.length ?? 0;
assert.ok(parsed >= 1, `CrowdSec parsed nothing:\n${explain}`);
console.log(`crowdsec: crowdsecurity/asterisk-logs parsed ${parsed} lines`);
} finally {
docker('rm', '-f', cs);
}
} finally {
phones.stop();
}
// 2. Screenshots for the guide.
const p = await launch({ width: 1280, height: 900, shotsDir: imgDir });
try {
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', email);
await p.fill('Password', pw);
await p.click('Sign in');
await p.waitFor(`location.pathname.startsWith('/admin')`, 'signed in');
await p.goto(`${base}/admin/trunks`);
await p.waitFor(`document.body?.innerText.includes('New trunk')`, 'trunks page');
await p.click('New trunk');
await p.waitFor(`!!document.querySelector('dialog[open] details')`, 'trunk form');
await p.evaluate(`document.querySelector('dialog[open] details').open = true`);
await p.fill('Server', 'chicago1.voip.ms');
await p.shot('voipms-trunk-form');
// Numbers need a trunk (it won't register: the screenshot is of the form).
const t = await fetch(`${base}/api/trunks`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: JSON.stringify({
name: 'voip.ms',
serverHost: 'chicago1.voip.ms',
username: '123456_pbx',
password: 'not-a-real-password-1',
}),
});
assert.equal(t.status, 201, await t.text());
await p.goto(`${base}/admin/numbers`);
await p.waitFor(`document.body?.innerText.includes('Add number')`, 'numbers page');
await p.click('Add number');
await p.waitFor(`!!document.querySelector('dialog[open]')`, 'number form');
await p.shot('add-number');
for (const tab of ['network', 'emergency', 'recording', 'backups']) {
await p.goto(`${base}/admin/settings/${tab}`);
await p.waitFor(
`!!document.querySelector('main form, main section') && !!document.querySelector('main h1')`,
tab,
);
await sleep(500);
await p.shot(`settings-${tab}`);
}
console.log(`screenshots: 6 in ${imgDir}`);
// 3. Links and images in the guide.
let links = 0;
for (const f of readdirSync(GUIDE).filter((f) => f.endsWith('.md'))) {
const md = readFileSync(join(GUIDE, f), 'utf8');
for (const [, target, anchor] of md.matchAll(
/\]\((?!https?:|mailto:)([^)#\s]*)(?:#([^)\s]+))?\)/g,
)) {
const file = resolve(GUIDE, target || f);
assert.ok(existsSync(file), `${f}: broken link ${target}`);
// GitHub's heading ids: lower case, punctuation dropped, spaces to dashes.
if (anchor)
assert.ok(
[...readFileSync(file, 'utf8').matchAll(/^#+ (.+)$/gm)].some(
([, h]) =>
h
.toLowerCase()
.replace(/[^\w\- ]/g, '')
.replace(/ /g, '-') === anchor,
),
`${f}: no heading for #${anchor} in ${target || f}`,
);
links++;
}
}
console.log(`guide: ${links} relative links and images, none broken`);
console.log('M11-T7: PASS');
} finally {
await p.close();
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Update to the latest code (08 §3.1). Migrations run when the api starts. Calls in progress drop
# when Asterisk's container is recreated, so run it when the phones are quiet.
# Usage: scripts/update.sh (run from anywhere)
set -eu
cd "$(dirname "$0")/.."
git pull --ff-only
profile=$(docker ps -q --filter label=com.docker.compose.project=selfphoneng --filter label=com.docker.compose.service=caddy | grep -q . && echo '--profile caddy' || true)
# shellcheck disable=SC2086 # empty or one flag
docker compose $profile pull --ignore-buildable # postgres, caddy
# shellcheck disable=SC2086
docker compose $profile build --pull
# shellcheck disable=SC2086
docker compose $profile up -d
echo "Updated to $(sed -n 's/^ "version": "\(.*\)",$/\1/p' package.json) ($(git rev-parse --short HEAD))"