diff --git a/README.md b/README.md
index e11e036..cc65b76 100644
--- a/README.md
+++ b/README.md
@@ -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
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)
- The full spec: [`docs/spec/`](docs/spec/00-overview.md)
- Build order: [`docs/spec/09-roadmap.md`](docs/spec/09-roadmap.md)
diff --git a/docs/admin-guide/README.md b/docs/admin-guide/README.md
new file mode 100644
index 0000000..d308ed7
--- /dev/null
+++ b/docs/admin-guide/README.md
@@ -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**: .
+ 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.
diff --git a/docs/admin-guide/img/add-number.png b/docs/admin-guide/img/add-number.png
new file mode 100644
index 0000000..4c96c0c
Binary files /dev/null and b/docs/admin-guide/img/add-number.png differ
diff --git a/docs/admin-guide/img/settings-backups.png b/docs/admin-guide/img/settings-backups.png
new file mode 100644
index 0000000..52423a4
Binary files /dev/null and b/docs/admin-guide/img/settings-backups.png differ
diff --git a/docs/admin-guide/img/settings-emergency.png b/docs/admin-guide/img/settings-emergency.png
new file mode 100644
index 0000000..7d8f303
Binary files /dev/null and b/docs/admin-guide/img/settings-emergency.png differ
diff --git a/docs/admin-guide/img/settings-network.png b/docs/admin-guide/img/settings-network.png
new file mode 100644
index 0000000..802bfe5
Binary files /dev/null and b/docs/admin-guide/img/settings-network.png differ
diff --git a/docs/admin-guide/img/settings-recording.png b/docs/admin-guide/img/settings-recording.png
new file mode 100644
index 0000000..07d733e
Binary files /dev/null and b/docs/admin-guide/img/settings-recording.png differ
diff --git a/docs/admin-guide/img/voipms-trunk-form.png b/docs/admin-guide/img/voipms-trunk-form.png
new file mode 100644
index 0000000..fc7a284
Binary files /dev/null and b/docs/admin-guide/img/voipms-trunk-form.png differ
diff --git a/docs/admin-guide/legal.md b/docs/admin-guide/legal.md
new file mode 100644
index 0000000..32bf422
--- /dev/null
+++ b/docs/admin-guide/legal.md
@@ -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).
+
+
+
+### 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.
+
+
+
+### 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.
diff --git a/docs/admin-guide/maintenance.md b/docs/admin-guide/maintenance.md
new file mode 100644
index 0000000..d630452
--- /dev/null
+++ b/docs/admin-guide/maintenance.md
@@ -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 .
+
+```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.
+
+
+
+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 `.
+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).
diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md
new file mode 100644
index 0000000..83a42be
--- /dev/null
+++ b/docs/admin-guide/network.md
@@ -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
+ " 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.
+
+
+
+## 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 (), 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.
diff --git a/docs/admin-guide/troubleshooting.md b/docs/admin-guide/troubleshooting.md
new file mode 100644
index 0000000..2c91cb9
--- /dev/null
+++ b/docs/admin-guide/troubleshooting.md
@@ -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.
diff --git a/docs/admin-guide/voipms.md b/docs/admin-guide/voipms.md
new file mode 100644
index 0000000..abb6fbc
--- /dev/null
+++ b/docs/admin-guide/voipms.md
@@ -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.
+
+
+
+| 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.
+
+
+
+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.
diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md
index 21eaadc..07f9dcd 100644
--- a/docs/spec/08-security-ops.md
+++ b/docs/spec/08-security-ops.md
@@ -92,6 +92,8 @@ aren't filtered. The setting is on Settings → Security, with the provisioning
### 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.
+ 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.
- Asterisk security releases: bump `ASTERISK_VERSION` in the Dockerfile and rebuild. The admin guide explains how.
diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md
index 18cdc3f..e103b3f 100644
--- a/scripts/e2e/README.md
+++ b/scripts/e2e/README.md
@@ -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
page shows the same at 1280 px and 390 px, without scrolling sideways on the phone. About 3
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.
diff --git a/scripts/e2e/m11-t7.mjs b/scripts/e2e/m11-t7.mjs
new file mode 100644
index 0000000..36ccb46
--- /dev/null
+++ b/scripts/e2e/m11-t7.mjs
@@ -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 .
+// - 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
+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();
+}
diff --git a/scripts/update.sh b/scripts/update.sh
new file mode 100755
index 0000000..923aac6
--- /dev/null
+++ b/scripts/update.sh
@@ -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))"