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). + +![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. 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. + +![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 `. +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. + +![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 (), 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. + +![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. 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))"